From 4bfe1983a3ca0f425974b7d7c287cd79f2cd1978 Mon Sep 17 00:00:00 2001 From: UserIsMH Date: Wed, 18 Jun 2025 19:09:36 +0200 Subject: [PATCH] aktiv-inaktiv der Lizenzen ist gefixt --- .claude/settings.local.json | 3 +- ...ocker_20250618_030000_encrypted.sql.gz.enc | 1 + v2_adminpanel/BLUEPRINT_MIGRATION_COMPLETE.md | 42 - v2_adminpanel/FEHLERSUCHE.md | 318 -- v2_adminpanel/MIGRATION_2FA.md | 43 - v2_adminpanel/MIGRATION_DISCREPANCIES.md | 156 - v2_adminpanel/ROUTING_ISSUES_REPORT.md | 118 - v2_adminpanel/TEMPLATE_FIXES_NEEDED.md | 73 - v2_adminpanel/__pycache__/app.cpython-312.pyc | Bin 5126 -> 8568 bytes .../app_no_duplicates.cpython-312.pyc | Bin 138120 -> 0 bytes .../app_refactored.cpython-312.pyc | Bin 172148 -> 0 bytes .../__pycache__/config.cpython-312.pyc | Bin 2358 -> 0 bytes v2_adminpanel/app.py.backup | 5032 ----------------- v2_adminpanel/app.py.backup_20250616_233145 | 4461 --------------- .../app.py.backup_before_blueprint_migration | 4461 --------------- ...p.py.backup_before_cleanup_20250616_223830 | 4475 --------------- ...p.py.backup_before_cleanup_20250616_223919 | 4475 --------------- v2_adminpanel/app.py.old | 5021 ---------------- v2_adminpanel/app_before_blueprint.py | 4460 --------------- v2_adminpanel/app_new.py | 124 - v2_adminpanel/app_with_duplicates.py | 4462 --------------- v2_adminpanel/cleanup_commented_routes.py | 156 - .../cleanup_commented_routes_auto.py | 153 - v2_adminpanel/cookies.txt | 5 - v2_adminpanel/create_users_table.sql | 20 - v2_adminpanel/fix_license_keys.sql | 13 - v2_adminpanel/mark_resources_as_test.sql | 5 - v2_adminpanel/migrate_device_limit.sql | 13 - v2_adminpanel/migrate_license_keys.sql | 54 - v2_adminpanel/migrate_users.py | 78 - v2_adminpanel/remove_duplicate_routes.py | 52 - .../__pycache__/__init__.cpython-312.pyc | Bin 183 -> 115 bytes .../__pycache__/admin_routes.cpython-312.pyc | Bin 0 -> 20902 bytes .../__pycache__/api_routes.cpython-312.pyc | Bin 0 -> 37347 bytes .../__pycache__/auth_routes.cpython-312.pyc | Bin 0 -> 17588 bytes .../__pycache__/batch_routes.cpython-312.pyc | Bin 0 -> 17130 bytes .../customer_routes.cpython-312.pyc | Bin 15807 -> 21113 bytes .../__pycache__/export_routes.cpython-312.pyc | Bin 0 -> 14620 bytes .../license_routes.cpython-312.pyc | Bin 0 -> 20453 bytes .../resource_routes.cpython-312.pyc | Bin 0 -> 29457 bytes .../session_routes.cpython-312.pyc | Bin 0 -> 17659 bytes v2_adminpanel/routes/api_routes.py | 34 +- v2_adminpanel/routes/api_routes.py.backup | 943 --- v2_adminpanel/routes/customer_routes.py | 31 +- v2_adminpanel/templates/base.html | 10 +- .../templates/customers_licenses_old.html | 488 -- v2_adminpanel/test_blueprint_routes.py | 188 - v2_adminpanel/test_blueprints.py | 21 - v2_adminpanel/utils/audit.py | 2 +- 49 files changed, 58 insertions(+), 39933 deletions(-) create mode 100644 backups/backup_v2docker_20250618_030000_encrypted.sql.gz.enc delete mode 100644 v2_adminpanel/BLUEPRINT_MIGRATION_COMPLETE.md delete mode 100644 v2_adminpanel/FEHLERSUCHE.md delete mode 100644 v2_adminpanel/MIGRATION_2FA.md delete mode 100644 v2_adminpanel/MIGRATION_DISCREPANCIES.md delete mode 100644 v2_adminpanel/ROUTING_ISSUES_REPORT.md delete mode 100644 v2_adminpanel/TEMPLATE_FIXES_NEEDED.md delete mode 100644 v2_adminpanel/__pycache__/app_no_duplicates.cpython-312.pyc delete mode 100644 v2_adminpanel/__pycache__/app_refactored.cpython-312.pyc delete mode 100644 v2_adminpanel/__pycache__/config.cpython-312.pyc delete mode 100644 v2_adminpanel/app.py.backup delete mode 100644 v2_adminpanel/app.py.backup_20250616_233145 delete mode 100644 v2_adminpanel/app.py.backup_before_blueprint_migration delete mode 100644 v2_adminpanel/app.py.backup_before_cleanup_20250616_223830 delete mode 100644 v2_adminpanel/app.py.backup_before_cleanup_20250616_223919 delete mode 100644 v2_adminpanel/app.py.old delete mode 100644 v2_adminpanel/app_before_blueprint.py delete mode 100644 v2_adminpanel/app_new.py delete mode 100644 v2_adminpanel/app_with_duplicates.py delete mode 100644 v2_adminpanel/cleanup_commented_routes.py delete mode 100644 v2_adminpanel/cleanup_commented_routes_auto.py delete mode 100644 v2_adminpanel/cookies.txt delete mode 100644 v2_adminpanel/create_users_table.sql delete mode 100644 v2_adminpanel/fix_license_keys.sql delete mode 100644 v2_adminpanel/mark_resources_as_test.sql delete mode 100644 v2_adminpanel/migrate_device_limit.sql delete mode 100644 v2_adminpanel/migrate_license_keys.sql delete mode 100644 v2_adminpanel/migrate_users.py delete mode 100644 v2_adminpanel/remove_duplicate_routes.py create mode 100644 v2_adminpanel/routes/__pycache__/admin_routes.cpython-312.pyc create mode 100644 v2_adminpanel/routes/__pycache__/api_routes.cpython-312.pyc create mode 100644 v2_adminpanel/routes/__pycache__/auth_routes.cpython-312.pyc create mode 100644 v2_adminpanel/routes/__pycache__/batch_routes.cpython-312.pyc create mode 100644 v2_adminpanel/routes/__pycache__/export_routes.cpython-312.pyc create mode 100644 v2_adminpanel/routes/__pycache__/license_routes.cpython-312.pyc create mode 100644 v2_adminpanel/routes/__pycache__/resource_routes.cpython-312.pyc create mode 100644 v2_adminpanel/routes/__pycache__/session_routes.cpython-312.pyc delete mode 100644 v2_adminpanel/routes/api_routes.py.backup delete mode 100644 v2_adminpanel/templates/customers_licenses_old.html delete mode 100644 v2_adminpanel/test_blueprint_routes.py delete mode 100644 v2_adminpanel/test_blueprints.py diff --git a/.claude/settings.local.json b/.claude/settings.local.json index feb28ae..087477f 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -70,7 +70,8 @@ "Bash(then echo \"- $template\")", "Bash(fi)", "Bash(done)", - "Bash(docker compose:*)" + "Bash(docker compose:*)", + "Bash(true)" ], "deny": [] } diff --git a/backups/backup_v2docker_20250618_030000_encrypted.sql.gz.enc b/backups/backup_v2docker_20250618_030000_encrypted.sql.gz.enc new file mode 100644 index 0000000..3e26d0a --- /dev/null +++ b/backups/backup_v2docker_20250618_030000_encrypted.sql.gz.enc @@ -0,0 +1 @@ +gAAAAABoUg-Q6j_Fx3e9SJc92fGYp4w5VGSbp6uq0fRNaMWSjXdpCbiVVGum04UWox-dJlgGOPRQUY8LZVViu_SdgoISHHwO-Kr18ZbchnmYioQ96TSc2jqIW2G32ZV12w84hGAFCJYFfqbSdVCKwyV9qO7xYYIhF8nLuI8cafF28EWgfvwMOGxpizGPH0qCBlOpEoEH8BzAsLQ_SeLrGx394rGWQaObAfBZ9PrAgLv6_0aBZPt-EminEYNytGieDeEsOLYKEqIrhQk3twwZ6rgD9eLKeaJ_b_wUQsOUPh8vTb5WhuWsTwC6u7vLFzXwZWm5Hvdc8BX5JosiNIsoRJbeHuiJbX4b5Y99er7Yui5tJRVBoCTlyK9SblfaUW22Kzfc_SFCtw3qsqKBps2PyPa1pohgAfADxwtPk6vUt--QHOjx3EO4xiM3dY7Ar8zO90NZbAcAZd6g1WZ07vPkwmSBvD3X-ZtASxstBAw3Vr8Mw8H5Fa-btrF0oZfxLR5VUXUy9B2O_xoAldyckCvmn9MpEUUFGK8fZLjPZ6wDr87W8SHyVNfilmRHb4snHlCs2wIUNvku19fPXw6prF34lRpcl2e200IRezJnrpUw3AfDIaKTog_gChnCzr8AIAGnfscUuNQLIe3PaNdzcN59KPMLIOAnhxIxlYRz8HMIk74c6jrovY472hdEIQm72U6w9ou788O9hZ_hIanAcdsbuAyyB0j5-Y2UGWRn0i6spnBVmJqucCH6fX1OUMYMQzhaY6nfRQpRH5ny2C4opSf770o2EStcfjXy0sdbJgIM0Mn43a_eZUJB8QmyxJXiQuso4FnXijvtPWv_ayW9-iPH_dKJWWklooiYLMrQSmAk3CwovYjk_D5bjv3pO5kiXz-EhWMAgSJKqMhgcWFI0KmyO0g_wYyRlIQ0eKinNaZ11_T1OqywUo06u9E2y_2Kd_uh5cJpXgLod_ED2W0Ol-W6md7WizwAVzZt4uKSaOmR5-YrEFPtz4IiNiNwLSzh_h-wt7sBdF7R6hlQAtrw4Dy5h1TJi7GSrF3ALd-sKXdR4k1FO4UYQKQX5lz681EK6wEkvWoUdfwO_d1r2IoTrtdU72-ZgnXLOHL_cqzS3VRKIC7NmB9Qo3biAI44LF34OR9To18BvH3t72exs2Nch9H1qy4N1DlwH9jW0BcZNlXU8B6hIi57yuskJ1VIHhJPRQnYAyY47gzPF5m7sW3X4Hb1MppDP2rDEqwGm2arNDB6fvgRO1A9fzVvVtxyOWUVcLqvC8L-B7XqL6nZ6LZ0Uiibr2sLPUwypMB8q8kL2ll1j_xd5cQAddPynupu8L1wRHvIzqYhKQuDz1chw4bLy_4sb3M2suUAZX4lxh-VBKqg8KwacScLivqvrPmUOYj8W-3hmQf7VNho6x3moOT0YtNDx3Jnkdv_YuR4pCJp7gPTuSsDPK6I_z8-e_mHTHQoJA2tfjsDIrL9l0V-_JRtkNiasThMb5Q3aNzP73VeITBe6yqlpVq1gl1tKFMQrz1v29itEo-QpV2XrVLd9ASXGUPv1jQqCh2a-oBiGgPpkXoqSAlwjzqPxtFyVdRU048iTZBgyiY-MPTfNSCS4ewKp_barlLDzBOTEsho5cGGCQhBJjP3B2LYhqZS5rL6wRAUDrRaQbBRKBCg2Fm9vsbuMETMRH4FIUSRN6x5--Lfi9YIZ3Ir3-Jjs9aq19GbQpGusn23-xcTbK4wrE68xsazuGTRr8LNAcez6U_qnggUvZi3vrmukZAZkoNbUS6pwxZVCW0AYmALX-KgVnAYPwU78X_3Aq9iIunBcmc7Ff1rtolXB98lBPCjvS9jDrvdQ1VV5DHRBsmk4FYfFd3Y9B8Oj5WraaOhg8cmZ29VaJYnqpVT91tN94D3Jq1skJYPEEywQg48uWrpFWJTg9KcFBR8NAOZuIgpB0_DgZFVbEB9B4i4LAe5DvDfaZyhXABmpGiMRrQh9lIa5Z7B2FfvEwFDyH9EzUWam7xHszEi0BFB-58g0AXyPRH1G1yJtPjIN6nAK5sCG_RWGI1gzOjPmkU-H9Hew2Yol2dSdnGGtIVAt_Pv5Cpnj3xEp-kqvBpGrCG6VBANQMWbecKnKSu6bbXmjRMJGacz6DwXRjYkkNSWiEJ3PSe-hVUhnx1MfHc6HnHkFQGcfWf9g4r97e7BaJQr6yG0V0X6a0wZ-1JWajsiAASEWl8G8KhVDz07zLAxPBbBZ2q1fpFiNKx887xLE2208HILLuAVB5a_bQU8bfZ9w_T5SBgrueBlxdXlPP3gwXjWa6HTIrL65Rku1PvOGgSCSwJREaA9d89QrrDtrbLCMqHhOzwIScInu-gjuhS9DI5TRb-Cy1ZzlaQg4mdSbDpfcQlxwdbV4GXUDiiWHzrJaqK8ggy0MyMyZVrY891Y2gER_iQJn432I6_hQMGtfZjzfzmxAJVJkZ3eLcmBlMb8rnqa-kq-eZ9chSr18FHvy0Rs_Tb2P-UlLi663ZZToOlEbWxkT6NNEywfe2C6H6HFPwCnH57KenSWo3gnBfncV1BXzGOcvjhml0cvVVyVMHc-J_mJOdBSpX3MM8JuAqRvMR_PQZ9mmRqc_JZLsPrtVnj3dEVW9nhNjtDXUKRtJ5doB3pplZt3-jO0AnTK0XAfiXmmzbBRsvv3pY9J6481ClO7MUWJjZGwCHRXewHQV6gKEyOWNVJlSZOSmXXcD7CoPmC8G5R5Ry2k0eu6s126LDdEvs7jhbdyA-znTjlFZrK01R-qwUJDzgZESi_ckrLlA9GkYVucS4o_gjHZxDbsTMM2lPWR1zvVkX-FM0GIi7glWS5Y2sk3K46CEmlAYHtQ3D-4R-0Jr0lBzFtguCzpuPvUg_IdD0HUfPcGcI_KdAoq3mK5EKkfC5L8fkCzjx_6-MpOweSpOCBkvf-D62XuEuyyQ2bMEInAWtuxRCt5aXB8BmyAKJKUWXIaOoNHIWB8tOV5td93pnn1eqHLfXQSdmFo7__RmwCCHNRKa9vHRYkTcy1n96GaG-gDIB5O2IxS910ZzRU-7qC8x-oROAQ3dvlCqfhNL9T51tdzMCs0veGPxg1ogKVctocmT5FPflbKe_crqXp6NX6xZ30WcluA6CWKN_5zsl9iSggqvZpPaLGrz8xVJwYxdQ_SlCRRCwgndL6ORiafTWxD2T4P72q2NN7yfFp2IwMojM2DiJO1KR2JuT2oXCEhv4pxsrq_rTgMqmMxqEy-d8464YIIQJN-FSaNNiklAZ9Pf47OUk-CE2S2nbjojH4FUISXbKY03EvmFZSUjtPmOrKgSbuD4abOJK1Mn724239CkYYhhuFoy8D7yH2MOC4dwAhfdWN8eml_AVOFGJvT_nxDV2nAq0pTpD7yCNsIl2HFUZzhpMvay9SMHQBCrxlsuw_Sjr4Ctpk0XVIb74iFxlnCTMcODYbB0nN6fP5ne32fPPunuH92GZKsYEnZrXQwOHox1i-OtylfcPM8vUfhC5hKvg10buh8qETiXf5hS61KbhVfJdcWnNrYHl58-zek7rK3tQhNqf5Et8RNOsBYOqEMEsTDuTstLyXr6BBqbOaE3xBbn7ojNGJHUKnoH3PkprSXzFPzwaNwD_Ed5AefJA_9GVOaCJrymprYiGYNk9W9DugseaZGCyoqG7U76FlFGh1UHlrg4r7vTi7mQ9fV2SgxWI1Y-JqkDtv_hAFwOfm1pmpMofvfyPqUa8qiG0YD-fr5kAi4LikNCWbrELUI7An6h60hZpUlcpbPS59qp00dNVv7ALHiVkdNrimWJIQOJCGKeKMysXE15QFVl2sVB-0NtGdoFpizseaZ4nJDP728QllBVYS652MmntSA-zbjwvjHpXihgNn0I8sAnxnkcOYKzFlfeta9UQxXRrVsfAjJfC9UDyslos3jAcrllTYuQLRy_-3lGuESVSj2hk-wUBQS0Lm4rmnP0cWP3TXPOKJ9849C5kSmh9LgEwn7FSDssne56_93WMhFnxgzn8wwug5VdSxpcLpG_g95CAdL_nhYGGciMB20nDAKYy5PVe1n3Ass0xwqQt1MpPfOV8--2_0cOO8bSMUOVkCuDt0M9fpvaJpR2N3A7ooyjaLrWtToZPcZuLopRci86yeNYwzaPr3FreFIaLPwTBgJQGPWcjsaw_0SAKj2xbkNQMqSyzNnm1I1HMdlpHhD8Ym7GypEnoizU5RZG7dDd1DJG3ViCDP5cLy9v1M2fepcTXN4un-XJUw4qjydd1LFcp-UfsUF2LcN0a4gZ9ERN3-oaqYFQL-6LKFMfY0gRkHe771FP2TPAuvPRmypfbigTsNPStPKkMlK8K-FVjK_v2NYEX71pZ4eAEpRdNnVkuCcaWTJvdiDAelx547moyLrOV-uYSnZM7AGVdBGvxWQvRqrlDxehmZEIfynlRSvNN2ofG01W_dKTdPFEv6-JCgUt3ZDAYWuagL_zxctlXmqhbwMQ2FgFr8yw_c_IgNeeR8x5HMRvU4leSlF47bkoZPrDndGKA7BMaS5IaFwV12znAtq0DREnCZ6F5M9d8mpbI0VmAiAGi8Sm9ast6c_86koPe2l3R2E1JWYBqzzrHb-lpfyU8gFk21EfvAQ4-a7mve7qkaeI4f4aoNdM5oBuYTuFUcHW_y_PxCpc1p3ClLs658jwBKe0JQxxzAwO4lNNMqKQx7bgRtI8EqvayEgrumSeIDoU5ipebtjAlPF67faA4rrXSyGKkzwsufTdUosoB2Jgfozg-yZwaOmfBARY0q8l22ipi9w6-mVhxyanPNaPF92SPD6cO7r4KVmazvh9jD5JTeFkSRtOVX3CQ7VY_xcsUxKyIM4t0EVfb29YRM0jZoHhm1LZ3gcwT-Y7rLccw7TtVgBE2Rd8uRyXIHk-K4Up3L-DzULAlYUOKxhbyEHMMCjFACiYsQoPKHvMUXTP8-Dh8jy8vQqFKKCUGPRTiC_68INBvkCf33oAjgnGq1yazhHEZoos4Fs4nYMOaRpts0IazblfHICEC2yXSB9y3J4hW9Yy7k2n9t8ndyj5fJj9acUWUdRLlm3rfYGyc8jHeXzGMhN92FAHrKO1X8WtWIqx4pZsrjunhSd9x-Q2ka2AMAZPNDdrKb4t2jHgMSnpmp6izpjWiP6YH81s0HNuHe8dh7iUhKwPzc8YMUBpKW6C6wLCpzQ1yH2Ckj_FS7UJvSlt1-Y9DApUo7tVeu5maMQpXDqk19sVQlKzy28ypVURhmzyKNWcrIq8jDiA9-IHvLHSh3zhiMtSqUH67bFd_SHXbBCXC-Ns9OfBQoMStizb9Y3p-xW28M8l-635voVivLwC7Cs7KT0uhOR6bnUBmHPhv8meemUyPqw_XBltqxZV3WQA8yxC2BOrVCzEKkqsrJAcXTdi8rkcI3r0s4I06SZj9pIfgNe3Jjmf1nu40AZzsEB4UeyBFo1bEH8f9zTrCEB2Z-QKCdcf1F0BH00SGBg_9lCPzT7oj4w_jsxJVfe0pR9TSPS7J2p2c9wiNzCO2eO-vMuQNwjADxX2ge5KnBUawwloqrVc90bBTtNucsI41cmmGAa1ndcb8rKRrlW6HzFAQ0jX2q3X58EQ4H_itgRdcXarjLH00e-9wVxJ1d6uXvRqEHCEgxrqfx8Qwck9MsOsqH4lG2Ne-fwe56bh-w492F3Fi1ZGKRiQVcaSS6T_z6Aw9rI4xjapfTDfM9jTm7_VAjbjKFoPWqAhR9QBtl-QLy8ce0v8kX1yrdXpW_IGHZ0ZyL0QM1DxI7pHNyRnD6beeqg27R1Zrxlyga0X437jHBXWH9yfoSxoTIbogv_j5S6gt6lHIv9jBw72JNyPzw4iYgUvBfuRH0c4cHzIoNUZSvCR1rcv7C_LRwkC9qjN9o_Xx3AyBWtT2CQ96476NU_Kl3l-vFSt2h11dd84gY-HuVWEzK1fGpQVGg8JmH2sdflqXubvRT3G3bq8Fn3Lhd50kXxd_kwK8DeCwI5221ERj4NeVHyTRpwsxOnIPrgyFc5qPEwzteT7coVFswwH_Ecv531GDTgHuL2QhLjGt06v0OZZxzj8OMVCgYT753-jyXMzluTsg1qky2zcJ7D0CkGM3usjKwLF-dMPI9qoBeVPl3f2Xh0qx8sSQZKDGDzYWJeKLke276Tn-wYlgWy_8exvMkWzjH_QW9F5zsuogxfmVempuZyL9SJh1YHZnk_rsAXEyJmhK-hk2ovOQpVsoMrmvffwrF9i3EgSjw2rxKqZaFxQ6OX2ZZVaMkwS0F5gamcgifezTIumIGx_cJz2dBhN1Pv5Tu38J8utQDNMDCC-yNoHu3gNKl7sb95fivZqcuzHOTyivwQm-E8cGh1c0-KkquPWy3jRx9T8tf4A1lYG8w96y0P160MxHt6xIFRlgE0KsF1_rdNPkrXpN26l-wW9aiXVfd9kve8R9Pz5I7m1_jygslNtaTeOaFYAHKda1OHMj9-27Hf2pEUNM7bWiXaOyCLlmEH2pNt3VPrLe_Rjp9XxJ3p2tkJSaXn-bVFMdM4yVM1m8OA5pDALOrdZpTnJT9V6FLkmrDzfrg5vaq0gKVPAHnPKHb-wHSM59Osf02nC7v2Rjea4eeN-XXTYeCk5wlK-HY0y323q3FQyjoWm7Ur1iF50nEAmw-0aAeyVTTQUmSWVwQYF7-kqff3FHRKVW3wyT6NxQ-HfP8b1qGnG6Jt5TVXRJyP0OcguEXMxmPqt4X3kPN6zwbVwVz8hbjepaNW051FVy_Tf39v9x7IDRrtdUqiSTmj0ss7ZTjStidWVNJFRn11GgnCu4E6ARkEBpcRJYv9AkNL-Hj-qbvDpVJa1tiiT0pmeMDkJGwpuRfIiqM90b1dc3NeELM1-3jrX8dciiZ-C4ALMpanLIHGFql339ERMEwtnF3fIvqNMXxshrIN8ifkwumrzNJPtymDp45yvOeB4csHFnEMAGJBKiMhIBk4MAjB9OgZy17LvP4HmxRUgrcSvs9epq9VLSgmECGxRDmZ3T-ksKUbCcGQKQWho_tLfp9FlQiDgvuN_PKudCMq-e12aonL3AEP8CrOnuRksW_HXkggkBf1uirD4oWt0ZUPZTreY4Of_Gg3y6qKhC6EmLDhdI0T8-UgFuCUyUeUMTn2OT0UG_ZJKUjKKI-awlo2TKr-Zpdv-qT0bqFq_5jRvpm0mmUP7J0j0Sprwb_5MYRz3im7KhxghWk9B8QFAoL7bsOjqXD9CN5KOLnN1hLPRN7m1d_E_gMjNkTY4yy6_fxlYgPo23J31mdzWIffeVm5wPLtjmUidbrLafuLE7r-0wNtEuqkspgPsfYGBxTqCgY5i5Q0jrSgCNSsN9t5OAVRvsZUENd8CWZ1KzNQhCDiSXcKDxVSGXB_CUO7DA9zBtKjQQES0y1wD_ARn591Qv7Cn1Ja4m81NbE2QUGYItXtcvHOTARTzJrq3Awn2GFJS1Mbr6fW6QI6idIrhHIvQIvNm2eJqOGHn7F9Gf9T9IP60j-ChLlxoOeT7CIILvKQgh5NTP0rXlH1NDMLkAx2YPjNfbs8fjkeHJLzJQ89rpOQL4kYtbuk4-hi2rdzmygwqASjqVUjBFSjt75KewqeGZp4KPQpO3bWsjOlnmYizm5X-B-idjIYxK6uGw_5O2EvLyEAUknFtb836kOpUHU8rvd5CBvrM8OmiNAtqPy3_7DyexmWODpGY83O89ARQ4EWonXKkf99xiQOjAYfxE-WFXrsoQ5DENqr4_Yt0v_lsmLh9JMclZqpXqxgDRwB_VWuOLfxfMXwjWnribMpHAHOyRIbjQJ25UCR9-lFYtf0G7SPEYzun8K3a6omW3mD05LjzHKehalKXjfWcR4-dkzG1wKYbq_bC0Nv1I4-kzjD-q72MrJfHqEDRn0tP5nkIRGoQaNV-TYWE3nNRH5EwcVYeUcBWO54K_15cfCkNQ2ombTFvo0itY7U2wfkquu5kJeskfDzJQP8kNP__l2ufwfzZXnWjTW5MUO0O59z6N9Njlp_4lsvocldw_ry4CAwY7fLopsNdNcR19KEqCHng1wysyhS2TECBP91eBuebuIZsFbYpq9ao242BKqCOu7M8Z1mQKhlWVz89AVzy5VJoCSy6Y6Jj7oOITrWbvSt6Ktgih1N3zlTbsB_R6P2I1dXFr4XoKdKzDeOXHXsw-iI2zIi7gzPFpq-bhcLZlRZTPDPQ9DMxSk0G9SN6_SlN13oBUGuMm_sqBSf3vmYE0D9KBr13kN83eazfhlQeZBvgyI2B-16TIBVSLA1ClJsZtd98QuhM0HeguzKN01gZKvKfWSGUaM0xLidc0Vn6dedTDbaihV6VbpSqQy-AAMrJs7Usk8u-CqZKkeRipNoM9obPJntL_maPa5FF2BxeIUcipWS3C5WNISPXenuAURDMznlYn1fubVx1wTsU-zIf8U_WKCf8fnhBhdGQItEft8zrbdEqIrVGP1W56u1Ksz0cKAy3_nuQxeU0LdZyaqqeWV7NRDtgiYVmuB4czczuEEo_ZuOMbv0QDwLy3tzHsplBNu29ffX8wpXQL-eDc81lEm_TC1nNHR1U6Tx_OBx1pimfKhQdcuyit2ia--_3cGTleQrrCX7dAcNuPtPf7QVUX20C_UudWOIoDoNUnZNIXSIMuem5soa15g1kBAnc-POzPq2ealjdKG_CAg6mE80bdqCQOebMhYhA00w-1_WH7sq2XurvZRMU09boa8c3AAqk7bSOuNVOOWr6qEw6ImtL3rIsKQIk_j8GfVmwFdlIoqztgMGeJAFbuS0tNXUMXJCfyGAm2DVFvPzGH7h5k3lXyeahHSRC48hxA6v2eneSbf4tJS6iais6Pqsm9eZQAIf8VnyKLmZkHggQJ-alQ38staltp22L38YOYxNh78GIQSC8L7YjiNL24Q-iPzfwE0oaQv3hVLIqwTgfhrQ4Gi68G_T1Ikz2gptXsv8j2LSD3xLVUhLSQVwyClYAk8dKL0CzXtmVOIy5QxQ7eZCNPdeKGfweGoudf-_8UbVjcsE2UUk2Bw2-7E9jcKfYPIPh3HyvvYZXWiDIQvrKv5ZQsSRC_o_-q5BCrPH1ZGd4deCw3Z4ynz7Amjqa_sk9x0YvNZ2fJuIiuJEW0LvM4tddmv0DiPEyTFvgSQtvHOi-mzkgeBj0Jg3gnhR45Z61IOuSGkVKYpvCaTxB14UR0Oj3gK66RUrfkjUuISfa9k1Wzh8s62CEXA2eZ_dpi48VMzTNMRYnjSpgFFPUVnqx-GXWPaI5nllGv6IWkeXdWPadV4y3kjZTP_8ji6LtJsw8VdKP3AuPzlRz3FNykElk-NJieyiBbw_TI9NV7Vu2jdrThzQUJhPP-ddW1q-odmkDCm8xxQ2oc4foxI1RMJd5XBVosrVIS6k2bkwxjrvz26TtJRXNWEUL3xM_t94nK87yiz-1xt-wzGRr4gV3zLxxcUIaHWK46yPKptIiz8YJpA02XLoGlb_5hXW0fkGKIIIKBRVJsm9HPaoMRvykNayYdn1qGM5IHRbKHjGQEmn0xxsXRAY30dxkX93WASnUhghB5WZQ6_CJ_61SOb73Tzat2WmyMvEAaC76QsInSyThtxcPJJv4vMfQb8PXfsBZf7j4xXoQ7T7UgPCULG6Gx0rQ1Mt0gGcBacwYUexLGaMvV5Tr4ltuYLBg8rspCiewKiSKWiYGHw7bfvxmWrM8UBv2BDJRTM8PhUYTl8-6AbnnQXXCGLvEzGFbNJmDt4d0NalbQz36x1qqY8n1pT2-hFedwUwTPkHsNsu7xxMoQp8sb_2NlafJE6Hh3bhfmpOo7NKgLROePpVY0WmJSJSYUzRSJR1FqX1SPOR6kGSuJ9MqDzx-ExUHVg2qCTC4jDHK57TfT-4fupkliYASDaKJ4TSEg5xg7zXc7d4AicqCEBCvH_oZjxyvcSrB6VYorcverUCI1XLdSl3NVbe3KGNuXjdATFCOjBuSMAaAJQZvJVUu-k1AHQCcRHucdJqaPUKqcyxpoFJU_xBs8gMNL27dS3poIzpj5xFvm0H8rOjMWCqs4C-mXYRYV6wbR-mfcoqVSsYrRhPdc0SH3SyrG1UAJvyaPoEwfA7r7IuJvBb12u0Jm5zd5OfiYlw6tbEtTHfRjjtWzKn_AgDRMTbPay0Jv7LkUaNinC856jSTE7N4F5y8kKBHbrN9TOu3qf52DZYe3fkTR_HzFmnctI0hIskrWxcY231Jais_j8ZlPUDlafo8fbMposd6MLBkG7FO0xU8raKooMEpt_Z9l-0fvfThysZ3CtKdwYaeN1C7TWV7pQJm8-dyNhlDLz0D2LEFu2DO_--lHGSjH9bppnC_rxIeXyPU4Xqah7PYLxrD5c6PFLFIFmKtxe5uqAH_07z2rseKKnqBLiJZsRGQh_uBAhAsWkmAS9sZ1YqjYQY2XzjRRdvzZb0gxGb1UwuLtloR2Agpe3JxWjcGA6H6RzpxJGk6KA25tonvr9q1PJHG_ssH5EbAg2OSuNXcUp1qIBMnY9_n0YF58nM4tfWOQMDxT-fS4CtV7ftgzDS9NYNxkQEWcejWrJvo5Cdan9YvEIK2WWX5h2NRVzsxdRAB81fqA7hNVSkq7_XEWmiVUHFTXRMND0SZ17dbVynvzCXc4_TojqVDO0uLNN7BRFgDiqYlKC5VD4H55PNmg7S2SHHIlR_P0hToIrKlHtA2spxWzPSr3sgn0Az8UUBzlrmc2rvVhHhxXwi2jPPiEtcLm_cydcnnHnzadhJDKoLnuGIj7-GoT-wNkMDBtnMQSNLIQ06rXaJy7r59Q2tiwsOXJTXsMtfkgjK6zYH-3tgNQpFvkJBNm89_HnorTVVjYMWcNWj-RfY0l59TIpPgeAWR2z2ITLsL3qr0X69iYKJWi4xHex4fQtAadQGjfXkabd2U9DbXraAhfFcwg0bS7NNIgHaCKF66pLIZboxalr60r1-Wy9hynkIx_KVCro8Ip8C_d_73UYzoSW6xqcu8aOYvFXwPZGJZFuLOZtaSXwVyPqqTlsPmYvz264d9feltJj0SJTXN-2u4xSI_LzOWo0c4i_68cJcRmI5R2baEMC5BdxPSWCToNTfARpAF5Y2pe07NEu848wq_0-i2m4J3wFSUVGxOwQ45rr8puCEJESCZWgd_rUbTjkMwz9JevjkOy92DGl1r0tKOSse4szlxUhBaFkIh0UViDqzDQZzpQRP4__2pd5F15Yt0gnqbM8xQlaXMVsRaTZSfPskA4r-m9pgZHRDvZhvxXH8ngMZLWL0XLCrS1pI5sbLM7bBN8KYyChJ4haGIohj8044rP9esvI9kuru8N2F3lhBghle7I7Sk-ZlkT4yrZ7k4yVdLUqw2VgY7vbE_dmX_qr1UyiPg7BDqmJ52Kn2gl2W4rFk-1j2ELUZqzi5hHPzEB7Ld3_yvi8HYhbPxC5Qtzl1jbfsUqGJgreCbv9HeNb9ztsta5tDhjSl8K7krIIro-vYyeTQ4S7Iq2Xvi8XS6hruK01RlbvVLn1p2QYNFWZxvzuNmPLOlT3u0gJE-muOd4JwdZZTY1geqI9zj5CRwo555GKXyMcfw7H9TDhdcgPu2EMM-Y5_Rtx_Lm6OWx1VuXbRS6POnO3Goh2ITQ3i-91tHtlyc39z01mCbSFCiQe9YQkApCZmvo0kZdOpOD_hHx9SfZXGyDzYLA_BHRNVZ4wl_gIV2rIquUZoGcRxGS-1A7K2o9gsy5QGUeut6zHNpXc7ks8PBWpOcercl3rqQUHAex2xERMXtc_KD7bw8mJIsqdvM7jYNK9_D7KOfrw7PKGJRZNHzUZ3H9aLBxLmHXz5rmYnzTU3KlMilNQ7O_bIejd6GuW2yghLcZ2XpjcYFibcYcTdpRvIDLJWIQPOV1Uy2WdPEVFVLhpZBrSQQOYSuB_tDSex6LdJ7d8AigR8eIj2w8v7roNbY1r-AyJ0PDRTsC4mfXbHUkaHpFomjTTNo9b3off6lKMBygcfTOGXlIlyrl8liW-s7kWGzK4EMfjiAMbav7xO-Gww2ae5zchaBSrj5ExX9pFNM9Gpvlyiw3l0f01hefhcEIraWnk4Z767OxJjJ-Hwy6sTnG8HO_zpBG2Hr0csFgiokvweiJ9iwG-iMlMNhUa7RcKlaNg3UWyFxOXSaQU6MTMaa-okSwruiWG_zaWOdWUF92e6oIcJc_WSq1MdfXaG1rSHs3j_F0uDWsQaE79w1dMiYm-6-G3RmbyKnJzP62wMdgaj8Gfyy9Ga3OjatSAx3vWN-78AeCdQOKUAOVxcG9pQZwYRUuESHfabKYlWe45kIHA-KxSNbTfdPqHJ_aoLWr3vqs51FLmfFVAWDKsDCA770N1gD-PwutSgqFlhukV_Pf9lGQubsf5v4WCR7nmmMpHRv9xOXuHLgDQZD2z9hOdU1Rb5Z8vLQMCKE1OCx3yo5HTta_wP2IFksfAVsKlBjXgJm9rmWa_y8GT7IG2R6qJlTbqzGIfGbOLoYRK0fKz91fDd_AIluH1-D2zZjEsXCPTrVCrjRE0uh8g7QSz5UmCg0rSG1jOF4RY2-x1DIQZ72YwFa3iB7_a6BzmJg-a00o1NN2LQGhlDHNLQelbIR9yWtkeycHllEnK2NMLQV7nBuaW13BHURuQ1iVbHUMJmcbSccU02-Sk_w2ScUe1VZ7Dv-I_vTTIrjg0mAQs1nUmNN-E69qor4dvkGxWsOVDqQU8wVqSHaLAj2DoY_wu2_pjCdaklWh5uWzFNtB64xybTtHDm8R15wQOqWs7ZgtM1b8HsSZ0_aZ7yJaeEWD7zRYBQg0SBKLO2enkMvecseyilYdDmcURoWj8dF9WwrswSXw4T9PngXs7pOXk2k4hi-DsOZE_CAyT9XPKj1oXDj-X-1zsk1z8ZPw62mVBn3qPBqpVlZ0Rfdd-CDX_OFUNaGRqQnbMJkYLZ-RSzv9f94d_aa-Dlx-jZXfKxZVmMoe3kc3t5hnhhFYXOtbAzJ61eoTNZLm0FVeS2yX4vUY7iXXkJPGMpj2gFokaKpVuB0uTcUnFbhiO7ss0K0EaZ9alCj71G5muXtyZKfpXTXa9iJKDFtKHlRJSPxnGHhIbd24ERak8H5AjkXBjLJGapyLUEjO7UKV16A52GcNpUjWU8QrOcONwpceI11frP9AyfiOGpUybymja1Y2JFXhPoDmDB0gUjzkvmJTTwktCPfIswWyRoq_smxoUJ5UTnD_RouZ27fjL4-zeHc2pZ9SUkCxpBRBjjkItHsOiNW00bDZgX_wRybWDZUM8F37hbagWH0GtRbr2ox_GTP-Jn1v8iEPlyhXhAzIaau9XbEU56f1gvMD1IB-zFKGgKB_KB5YmusWuAtXJxqUZUrXU1Lgvtk5NEb0N7HpC_MQHn2zCP-g4_OkfjMcpwXRFmCCi6Ohi3TypOMH8WYuYug575D3GbGrnjDMyoFm6baQEsvfwGTvFb_CHBQVCCTauorbqqVyfm-SolZEdFJKbl-jTmr1_fO5r6vzvNzdRUWUFSnYvbSYNr8wTj-OXXuwAKhZwUtFMEm2akrmLpVEhUKTzNiCy8cyT2KEYQASstIUtg-XqkATr-IStc2M_UOdYCRStxn7BhsbS7p2JzBmUMIxH7d9bmqzo0mD_OCl6SZIrLTO3cpcUPENY1YQoXkAbU-jL6ulVlrDtnyhVPWgcnQMMulGDCI8wOnix8qm-Z3Q9ChvX1PzfcHZ2w-aTHRiLmU8GUCitTa3xqNTJds1oTbs562YWq1ghx6uhFaF7enG0L6WqW_OexY2WTQHc37HWft7qaj--XqKQKds7jQiDECKYWz-HUfj6Jm4vLKhzfkWaTERp1noYxE6oDz8hBbnVMO-IoTTuncRot9d2sJottbmtwQrRj_TblT_w_7ldfQmB30weYYrqxd3XszdNA2dCOY5djSDlWyn8Z9i11wEFTgnZAjMRF_8j4OFMoBQgfaTkrHn1A7VaWuy3TAFlFraxC1gwjaZy6si59iWdZfiVH5lT4Ig6pvGKKHWIOVgYE2VZkRhnEJawqRqj3OuaYY-86ciOFEahpCi-_1Uey-QwTUDeHINbVDicZPBUSbpkZJ3aNql47qFbY5xS4hSwJOmNUnTkYOUkUVqy9CSXQIyp6pi4VRU1Yb7z4vWKSbLgMhv4PXnUQvUVkkBdaUHTAPLjJhVdp8DEjxz3KZiCKQx8jGhRWyDCnNHvF2K5hyudVVh4CY9te8UMjVQDq2eeWNR1buTza5M3r7hxxn9yUVibfMme-NnpsgZpC2Ues4W1e4AJEF4PZwir8JXygSYqkZwHfAcyq1w9AjllReaQ95UWqMszMUOQz31026UatiFKxkfjr9SEGY8HrU5bfuw8cxGDzpLgWj8eesAD3_fTmOmq5rZbPiTWP3jR38rOxou6Uesz-37rFCcdSqT8z9e-Gu11sKoxnFwXmSRK_0g1xbeqYLtvZzkubwu1Cyki32x3-GCW2SY449VZr2L5zMeV88-mg7NGVfWRWlzMt-ZbTQPDeVuan3H1KN-nrmE8CQPIiYiK5go0WhERkgoiXcIXvvUsMvD8jH-zhMP-bcdKd_PE2bI9pm1345dsoBt7MRe7MsJZjvLQgcGRxrrilit7jtehGzCWacsqI2x2haQWcO9-DoKiinIMer_yPL4BdmJD7OpHNKztJqXKXw61T5BhgbMT-JJ8SDtdHIduGMfwJqMgRjmKxQY8-LhigRCm-5w0PajLPGea5Q6eDR6vC1YqZXXOSsR3UWGKAOX331QEIQ1T-j0M7nr457R0r0bRdgr4d1nec_BSbu9VmEMa_DVU5d1OwGQIBd6_4E8yHCB5gvwqNRU5qf88QK_zmZS0QwAey_yLZAIDLGxQeZ_e6nUw7gS7s1wUz836g1BXfFUp-CH24HaoO94O0z3K3PZty-90OaYHuv7GFxStOZO-kPjBYVgf3ybz10tn-zRzh1etjjaENmcqGIcQqFvr3Rc1QH_UgQRn8A9Pg1YiHhQKywfpV9PJu0_QE9kvE2atzaI3Z5_8MiZumbbvnAU5a4HYx5sPWfkDbz2dSh_Ib-zxrEhm6W6iO2aMaAfBHzf1Shahn7Y1ZnHD-LLf-THPlukGRc8OopCPC-jmJ1udpvg1YIBoayd12jmo1cqQcM-v3k5jptIvts9w1jUGO4-9l58Pvwvg_i1C_qarYB_Y6nQVovuxuPhjjGummQUrSSerooW1vJkyWKFa64Gs_obiq7QJKXyZHs91bqKz6DG6Pr4ITEavOJEtYzsB-JTolkQwycZynAiDgSHAdZzTmFCxO3ylj9HraD8ulLCxtDdo5yy8sxP9iPQ5IujnEget3MLwKyem4iVNd8lrL_4LdWNKHUPx0K7wTiiL36csrxUmsQeXBWYo0vFLa8sxvVnfARoi7DMU7HIt8M42MfiuJ7PR30LwC8EgCHcV5iQ2O9C6_m-W6HmWn-be08SUKCKPEBvzNobiwJyh-xWzZ1OzgbPcpgAMOki9YfHkzN07vPUqLJ3CgO5dQxz2qV0DHREFSLOwRPId7Uk7xtFeOPoRlzXCTzes-LReUygyylzZnpIxdET2VvuJyK9XdeXcCz1SGAdsPECOD02B0vwXca8qOhXrP809iLaLk3QhgK8MeuPXruK48beFg6L2a4AM6ugxggukYkJv5UYsw_mUL9IVY8hxC4OXKfvpi_95zSY30DrniZ1RNHgJGqEe2_zhQoywd9dIEVmKa0FG5H_V7_Nq3-NFB1lZ1r4NQN9wrp0F0GjdT7FL9fH1wpOtvgQ8HtoJ-juKM3ytugI4LczWhY8dgtPvnEFgEsNJg-YRpQzuCQzzDwJC45h3t6p_xdmF3YJEJyuzVLrLL4N78y5ufkboxTu7oyWCpuuH9Zd_FI3SBO-_KxyME_A5uALJO8OS3fXEE8RAfazzfeClDIShPvbrVZTC6el-cKql6gAFO9t0HMeO8MyP7vOmOZV-j8H-7UQjroXwyIqudKa-PkeR9thFc125g4ewrv2gwjiKqyQIicGNxPAvHlfmPp0zzXBuqyPA77fQRpy2OOHFK7fXThlV_QvDlftYglqhQKVy-e2M1DztJbHTfdhTXpQXrsNLeyS2q2NKbiY11amxdFbgeve-BiBVztJFD-Fi-v6atwlvAGPJN-tEkcA5lIi6sC3Erwz0KNt8WvNIOrx2VMQ8rURlSx9AZ3U_wsOA9PNcEI1CYNtjX4SUpIXk987F0TH3y7XsHnO0ZnE0WigfzUzFJe_j-wkSA0n5sNSbZbCCDXfqC5klBb17ILcRMp_B5jR7hcjm0SLa5ooWOtext11b9ky88YFmnqNklFEEq0UfgolCspqNrecwAvXLw6pIILYs38hER2Y5XaR7iNl6p0tsxZwLsn-sBIS38FJK7nT32H1TaEemsLd-LDAqX4Gwc3t0oWsqaUcLkwjEM39hIQ-YCuxrL5zaOxhMn9ONdNREOyMW_YdW_YKyOTzIPPZ5YSH_rlko4zWDCyC1d2PSIpB7MSJODSLUCXf22HLkjqLiHw6GKPJGazUi2_IjekXsQKeVmIB20CM4cZnmi58XM-gj2OK7uGAScMRVZYM5Sljjswu8S-N8PMXaSICAymhJGGpLXopYzub6120V1K4XynXluTDpAHS7V7EiPVEzKHfP7cSeIK41LTJmTN_3D1xvCtgfBC-CKOT9CAVPcVg1-oVuLeDPlfNxzD3bFTUpYuxGFvxDcpyRF6iN-mODqHOcMWpGuz2tMkEz4ONo-opPZDwHiwYvqF0L1wfWIZnwx9M7EDFgp6cJ5gkUeuC7MtAzcn9YCtYXDjz0X7fuJ9D_5dOl3ZsnFqv4RKC_lBIhFDGNZtNMAiTtBsuvpYfLdMFZL-lp1OA6zBWvPOINIUXCNt3rRizsT9dTDvwLxfGyLuJtimL9ZS0ZgOMd8Me1XctMIL7VJ2jurDyMWvWW6KGDUucylVDF5BJTx0yiwVwJ0mFkAaFV4_JBKbpqgoDi4XHNmnN7FA5XqmxcQLdvwW91_m2btCKiZ2yjXTRvkjnkgEsbBb_FJxLGG9iefj_kQUX-pYr5Msc5P9UttPyfZd6H3nZ5-RNHzNVn6RZobTcoDxg6sFAA7YbNjZw2twVKvXOXRdeFak7vQv7ZIUguGdsa-WHUJwdrPVeD60DrqPEq5FVo2yusBJT19NIjL4mMOUL2gKyRu5Kf6lMZYlWZUkrCscaRE55SlpXMYFpPNMSyOTHukpuaWUPQdC-0bUfvaF0lrvXkpyilzL4lFha_lXfxDP7H-8IR6XGsN2ULfQDsyhPrBIq7EhBh3EG68Stbl2l9rHiQfYWEFCy4A2cK8d0u0O7Yyb-qpA4FPbC84UV_zpNV4LGYB85wCyQNXIS00Xxs0a2PUA9kf3h7sq_H7YBbkBpA9qchDNyf5gco3f01VAClaG2h5T9fF4pcpqHQ4eunqIrhuPkM6rMADpk0bApiiMAxW3WmIUdomKoWRIPh-fqOoWPJ5D6vhS1IxH9kj897nPeKXjRzZ0L9jO9ka2gUSsU9EPtwydt_2xqwNkR0rD-Gv3y1M0-Ihaq8h461lii-6XESXLTs09drNLBAjUN1PR6p6GMgKytqAUGdt03wze27QcvYueUzI7WYn8-JbPWjgjMXydqoeeLhVPWDONJDYz7FcyBi4yzkLLnkfo9IA2JFHL_Q-1S8yRwyZipMQBfVwhSVU64NZgrzVODOBpF1tFpTAX4ZwJzx7GvTiUDxAmZhSZGl--q0oH8gD_BZa2FuVhJZzPPi4JYbUqjQApkG3psylrFLxKn0XJs78KDcmrZCNww7KAu_shClf4mXuvKvkmIcWlC1U5E6750Vj4C2yBxMwruijPHrcfi_QHH7yVLUBwp8Ammv49S_fPE8F6ykRyZ4Z9iOCR6DIPYnMIyA8Y8ytoL_RWoXg_cX7tnPBYGyrYZx3GJPxsVTKL1T-9sYBNnl_mDsFYQdgx1uuoDmvAG6ERX6h8rRN22mxbKxbunolPARV4YX0kIMxkruzelLAYf2dG1t-PY11b-5W1WYUp3EJ1n6oTcgfj3Tmz4-t8_JjS48lnAmD1OnNrPdQFu6Lkf3iw2bfXkulse7PRjkKGh6nhVAp0umtwqu-78zGuK5z11DFbNcgLp_-O2SItPGaXl8m9NCs1BeKF3MlReI4C7tpHxphUbKAskFOX2zRFZ6ALLoL_HW6sJiPUe-vi8h_litkWSM03YqIb6wgsnLl07IUJpwDcsiL8_dS3JV8vf__IoH4YkQ25JJqNQjtIupnMGW96HVyFq9tjpQfYp3oXSvnd6as5aHhlA_1t7IC3l68USxUMxoQEbrRL8Qy9Og0HNi4DxEi17XBPSWTDL-KP2TxLlm3YAsE56N1xhlHNV7r2UpFVvi8My55iMG6jdgZZ7ApX62j0qAWY9R3P4Et4WWlr8Df2iZECK9Svc9sNEzHUhOly-dZ_fmJZAAR65hoD2tQnqy-dUNWziWbGhZ7A8xYDxVmsspaEdDLJgftchDt9zPikYh4lO2dwzdyNXrv6xq0XaJWcdES2gD-Jb5RDOWaSkeGm7DANZtHOE1EyaUoHf8r53ZsugfMvq2cSvo7_JJvT83cnLCbL_pLYNQjt9rHaXxSI9a1ZMjUdhGIh80WnJHYM0L0qyuCI6SJZa-WOYVroVQMc8PLxZ6BRAkCFs3gu8UZtVp1E4TJH1u0EFor5TXAS-LrIkQscYdSBOm1JD5pmIZ1F5LJZEIaozdsEqDT1TaFKitP74asqPvJSDDMwoVubyvN8MAod7_jxUybBRAg2BMlpMqa5FbK3OrM0hGYbSXgBWNbNB7whuBlX4W7Dks96Pe5JSLpY0aaRtuLbc5bivnpoJYBctwJSSdXRAgIZBCGQbSSfQ0E2rpN_fzyfhogQXsEReh__w8J9dD7ukm3kIKgN9ZQUpareHp_R1c2ZEiI4m9tztqp60P5Jx9_q3X0H9bKSMmNjbtD5yKRvquKCq739YHpFoPkbmvpWrf4Lq6DUt-mMjewu3ZcW0EGiTnovHAk_OWR__lwNJBpXlDDNQKcaFKNIFWFW8mtA_6biqCb51pUyL7kFkOqBjEOBfIuRE_mUqI-LGvJppg92RlicSY1M5SG7BG-0D6XZaKjYQPKgTI7jLQ-EkDeAlPlhV2acRx3v5T4FMoxqi82mnFRTNanLKLbnkMwhIABU766sbwkDk4_KHdDqu7YgWgq_eIHhqIBk1i37HCNvKrkEq7Y6tODRS5Cm1p2fJwnrE3fpWjVZplycUuPpz70EsEnvrp-qeOoMq_gTEpO4_-j1scQUyzu_jHlRrfjRXkdRs21rAMUwV7dQIUqiwfGktEhbMf7cL-EqdWMJh1ZsyT2g0K1TtcMHpNersT4iaL4BuberQq2ez7Bkv5IttiQUDYsxMCGrDv80yP8MDeaAx9Q4MR-9uvDmUdBfMtHMrtjdip5D35TmnltAXa11OWX0m5LSiRn_fAQp_fNywPXyiR2RMxZQLWZKcRFw0KzgleAOjjP_drU0XLdbfQIsr3Lr_nw8zd8mM12xBctLqDGmzPhkcD0EZfj3U4ih2NBf6z057jNINQ9lqRscNNCrw6tPPk3RSVQYEBrhAFedHJc7sghGBHAyWEvckybOEq1b4UfApxx_yDtGph25fpnjdQMFKeBWTFPDy8HWhM_YVnC_ruWSICcve_MmB0Psyz1Iy_jei5Fkdz4L3Bp6q6XonZ7wuZDLqgqEEyCyB6KF8LD1Bdu-uzeyGx65qPyjMVtpL_ZACko4Ed7KUQhnBmG53NFzadBppq5l8r9R1mi8rDgF1-hbcP8uBRyRsad-Lock-_JI6zG5NLXP6ZL0Z7Ht4JYGO1S6yvNQUiHqONvgwSEjMaifOsBzt6fxyo3TFxKsqrc8dC4Uo1_LVDBZY7QDIm_pFQmftahbaTUdZnFsGwHlQUVpU4MK7XlCfp9cO7iE1a05JEzDdEJeR264GCSFysHM-ed3haT1xshC5yLhVhfO3Kb64DoExkrGCko8RT8KQacoKM-kHT7KPBVFkc-zu99DStY0o5GUuoDImQZiaoqxkatApc1R8suUMOM7veMAdgVjOJWmWKkgy_Qhj06smxLTV4-DUEGp7NAU6qgnvhBjbwIO2p7EZZdCa08ABXuVQN3_DhQPtubwaRxcNd2ep6rr0jASOOjyLrPd_hx4AqZUGfhGgPeaG4fyQlyQ3_rg3fzFQDnfE6Jy91RfEbyUXIqGuh1Fu6m63v8GiWiXTNTaTQDNeW7D_08HR7XXP-JjeERLs2qLrDQ3dmbZIyaiJfQHwzKPMJ1Ip8nY00liNT0ema_EgDAgSPqlXUmZaKcs-bKL-1kzaW9jK397ql75dneli13ACuvFFKUf8CWqK8UUDE_G2wKPhvy2BAtNjk7hPUu3O_Q989Eu61axJbMZSAkxXUSnMQFV5gZy0yPwSDbIUBv_4HDjTAfuqd_B9lrCwFxP-TjTnvBUkk37iFO-SmOnD1bD2sb9b1p9MT_GmiX5t-n6T_TzWynMYmmvzW-xDbVbqqZgwSEadK9iQgn9r23S6AXlrVXu4JktWMI-sBNiQXDZrZf0qZ1kpkTJTH9-9gKol9ZC-UJq-PET9mhGeY0mAxZfPEG42VA6TXjGEmvfb2lazNV0BjPNivhzfkeF1TZyp-AG9HZTTeN-UvMRHZ0cUBczg1MTmHdK7QvFfPjgnxEPh34fZ1QRF1RG2TWYfJlT74Avl0P4GrLy9MkzItOc2CbNQj2WwUqmsuwIJdJ7nOFESlAE8bfDQ96XZJDRqVxdnmTWnGm1jSDUfYVkyvSWTJAmraAYmnbutNXWNGUMZ6Cb2cqu0o62wYBUbll6Zy96hCazEkxcUI1-z-wN9LIb9_7nZQkMmLClaJXxLJHn1fz6mZwtYdUPjS8p7zfKdH_Y7Q5lD7vYkFDSS3yzeHeIYqBsb9I5Jk2lK6ss53CvERZrVIvquTvkIzCJ1C-OspWPa6PiJjH_vdlUM05jvjiJDow8TUBzMirbZeC91HjCoimqBeEBDye5jJu4Eocbwtr_suPNqLxZhPRH_xzMTh2SFy3_DCduWXThBBZaFTKGZooObt3LCjIab37skZSr6hIihhuEBrB8srsDGnzrVIR1gQkzZ8VztGzpNLVqnIWVgiBMToG8-zGU5EZVgdczmPQWvabLtJOhw-tAvRrHuIHT4knUVlSOj0kcnzme-P_vcUNCzDVORjFrGDZEFB2b25DLrO97TKStazPUdCCyChQRCp2OqKC8abvolkfjfgOdQIRFB7aL2zxzX7aaf0xhpqMcqm4zeMWTyiF-qKbiZNSucyq3vohj9KVai_XOtjhEOe6H4tRKA7mrhPdlHCgDY3UXjwuUULV3JEItIE2lyrJpwkesT4r2FNTxsWETvMJ0AapvCBc5J0L15VUqsTAQPl3vkO3fL_NxlRGC6sSWPz_yqfqWNIdRPxgNRo1_Zv1Z_JsC4X_f9hB2YiyJbIatZhqDwpQFPJ2MbSa1LC8VMFLp8e21zOEx8cTr3bU5o8R-sSI3nTU5sal6woN3hJRMeR6xq0KBHCZQra3t4EZ5lrXGBpHYpsLUfMB1lP8rk-inljJYQUtm28J7t3AIvNXJ7aexBBCjb2mxEi7NgDGffQSHCBNcHyfRI9sZoNaavNwG4EMwHqHhfWEMFYi8-171dP6nwqAGyv7KAbCx79YxvW36WMx5hadX7e1IOXBrFbE_KcxIuxSk4SJXsQlt2fQy6zXNuGTNgdxhsJ5In5XkeJIp6aB-jg-AhraHetSokpAa4l_WAlsygpvkwvu27NBBmUi-_M-o1yZgaYzcUmoVMfY9esoPHSkPzonr46fOYdqg6YE-wd--_fBMpbAw4by5q1vKd5M1EY3vOnCZWtG2AUv8yz-esDbvClKfe6I-oJHfSHANtAya1hJF5X9TPdPxxIE3GgMskyR0ch2czDU2GciA_ReW36PzQOB3YZ2a93srVrdcplxn-L_NTsMpYL8dfwD5TLFgZ8BJKd9hTlDu8V42i_RTCAoL9Uo2OsJuVr7fg2gWbjRqZRGCa8r_Vn6Ekp9tHu-nbpzzUZOrzcYsIX-h5LQUi85y7dl-DiK-T6M-MbnDTScsm0tM_vwoaeOzjX7--h2XTkuJF1S6XnBb4wIRhfY_YRj314PrqxWUzBnqajsvOrYTS_ynUeZEzM_VgN9mYGv_lVKDjsMLgXrO1mLFeTY7Ku4vdSdmaWmWRJ-adlUeoVBCBi5tmkltk_vsg-WLi6NnI89WnVt5e2aM8on7n9_PgYT1y_KrpDgL25lqB9IHch_oSyeHFflrted_1tnb4Hjt2PPOVAlcathK2K5JKHw_o-HAdQs6L5LsKqO-ffwj4PnJc8gcgTZzo8bZNJ99UVG0t-fQH_gwCFLBqSboLdnAlgwjP7Z8mJA-RsJu2wbzcpvUwA2AT1BPfbu7qDGc7VkoWRfkrNq0zgAJjpQzQAosPV-zP4tGgvzUkpK3tAQKYlbkk2mbz1-a1BZHZldFu5Tjk1JNM9adM9N5y55IYMYJulRgV3gYmecSJUSrWfHFnki0_LHcQVlu6sW-r7FdVH714GkEBVSdlx-AgAnkVNuwKQ2lKo0gowEwrshZyFS4sBHW8FfuuqQD1CdNaKIfYIAySVHEqdst-CJfbsZpHaA3Q8I3TqRCvR0LVLEp_2wfJ23GCXnn0mfUB2vih5diPtpw2higiITujBZuJmdZsOvewvQKJs1gq1PrQCrR688YzwrReGI_WHYK9YrmqfBjshFTFWcz5Zk6YYsWvY3elIvVWarwMBDp3QuadjSdScDH7Eg2B5GtvrL8hUlQK5l44vC9-9bpYmctsSdYdKao8nPCfCIa7ljGxHRQy4Ao21axA4Qq1AnWE5EzMdNK5JftAGPFRpRhZeTvB6VQhi-LJNV7xr-CJbNnCe_DMIFozDAWSkUjfCLUWio1hWgyGOVzOD-utI8c72tNBQ95ZSlBzhJkHeu-gd9Xn6mAdLCAx8T3kPOq27dvdjWywKKtiKFaYIoGo0aHMz3L9VKCuiaQlKzj1aEr7_1-aBMAAfS9U4iq6v9kbFsETSNmO2uS96J10c8jFhN7oVYfNKtI1LNtz9maUi52cnRqP68L4OMw7au5U4VSpg4MkPt_VCpPXknwBPSpioMUSwjGjdOyVH0PkeTk33coj5A_3DD4oZAvhY6T8iRxgs5p5ktsWBJLOHUg2BrEnN0j2IjSXMfq2jHw1B0svvDogTFSdBRTn30pfKkoKV9EzWYTmo9BZZR5cal9c_k8wet1slnnWOmzxLoM5IngROyGYZbKw_MibSJu1HsQIgbg65tLY5SkM6ZfryRzg3ZAWcDQ_P7lua4dLOUV4dFQuLYt-o25ZcLo6YaaL-OC4QskZFhEi8D1qdDbO5MKEqGGIEJKNLKm2y2P78FFiJZ2l0Pe2GoQJbVQ0FpoLoSqlvn2CZKNNtQQJZHKheOufL3QeUxdMLFT8ILavcbWvLS-ZTyU3k6T32SzOyYjH9d8uvhmwXbDhb-L44IMZgSDsDoO0R5-Q0LKBsZt8MWnnJ43VdtAkRzBGVvdt_Kx37foBZfEEbFT2yKk3_5vtywGEj8wXqwJfUqsWaviScca0krnljSRYNSygxwO-OzzOu1buWPRMd6BG1AhYqAuM9gCEpPTptTQ9LOVm3CYbr916PGdwp_Q40xdbweM1m6ZpnUwv2vsICZOpCcYJD_k2MkLhA96kxAlRbG3aod7d8Qdy279658PTqyV1KlI-1HNtEPlVFouTRICfSJ6nhGbVTo2nEUg4I_q9RSIKuCchc80V9bLBf4znK3AQVbGaAGThOt-QSFnlCWbs5aUBJ5VG-BHos78ho531IQFbcYNhPelY23dvV6XE8aETvOIF23Es4sDMvUcbGs5xqNAhi-ks1bPukyjEQdlfvcJON0nyVlwBGpFXiSvFYDhR7EE-_4NBMIxldKHqzi2OgfGh9YyiBz_UiT2yJSJIEUJvCT-L1bCwPGsSRWdRkX2a6PRarmoImEDIF_dZ64UHAACC1QgI4iXLdrjU6g97scMuqtymqoUpc7nb0LgJyQ6fNwiHrvAG_kOG-UuXFMPYkfN0q8SLxqQ77Td8VfJFrjZ2xw6OMLxn5GN5lxLiXPImr8eCPmz2IN7Qwlh0zshUsXezrI72doyXIvXyUlk6_u90pYh_yKEoSP8IfSrBBooGxof0zG7i3wwoUF2PL_2kGgZNkhN0P3yz7IXhImZqytyv6aMgTbuHaHkqUNSAMU_-CyiZSR_sKAL8R_QnUJW93roHj3MxTsi0L3XVjTJVyjatyz-x7kci15SbVqZe1AJpCJeThiRQcQdSkKcLDHKTW6bmTeMJ2oKSJLfnVq972TY6VLusCXtQFB4miWvzlt5zViJQGYHsVOIx_Jwun6ZgIR19PCzDZkzEo2edI6xteLyUd-XDk0LXDxlloDwg7kIz4kenLrD04my87ibFvrt9X1ymVAWq0HBHgqoq9q0qYUjEFLbhV2j85yLpppRpYWWOz-CeuXP_1ID-xhGAZbfygNXqFEAzeZ33HgEPPyMSaPpnMyXoqhSxpxVYNWzWgNGDZkbIjoKHsZ0IiwpGERPl1xoMJomDHhfd8MeTn5mLJogbWRuOkT5fUn1dD21ZoVC8MSxB3q72LmDO8LwyGzj8c3kwMWvPWAwLOcR5ql-05LLLSCR1-65ObBkpn3kxuIU72NT7RlTyxDWmZCNBvRRLy9ccD5dmNXywIZRqgUMisWeU4ScSe6t2NANCnTmO5EQbX5QtKO-OWzYrCrxBk9WjHB89H-4gEaovB_c76zNFh9NDNIQvecdXE__JOQG4JfyOZZGg_607FPFuWxM7WOEAjKQO6qqvktaOWozMNjgUDN4ZPO6iQUujby696VBgGQb5vPuD8k2onfrBeONHEB_cb-jNA_ezl3p8rlyIRdMsa1tjNu88rk6ptYRFdT7lCDIcVPkXxGOYGz_hNKr2t5Dfq7IeMqOsj6OsdlvcJ-U497rWVT2LVgMNQgUtNe0kPUEo4rdIWEF26P2EOTy9gshjF64La_JEkMJSqHvILU32ROLpao-eOJILZH_suVI54GKOpyaFv56z5pfheT5yG83M9aa262gmYdrvaT2oX-C7aTn9PTan6kn5q0QFp-qH24i0pgUZr4GCXX-QQZSzGB-ULL40TWvxL_wUYDtdIzs4iFWxBplHXCDksXhfm5sSZuJtXo9UuLqmAY1YdyClk82N5EKd8R0amM7toIXdPNCG4twQBJvsSlOa6_sbzLBxpRoZRWMwXURcmRcAG6rK-hQGoyBl8EJFytY8nduIwMxn06273dMW1eyjYKgOGHfM1qzAVGv3wbzUTqdoaJKJzoGvh5rIhI9z360nNt3W13yTt5lx3AJyaDPUixBEWHkbfXq5AEPiInfcVT5tFPM-x6-KBGjraukgdVBlN5U9W5hTBvHDJkqIAASvne58aydtHq4cdU54cq2_Lhqwk9P00qYkarAjs8f8NkkMxuvhr6XlrciXMzqesngAsLnSXsjn6fmEMBPccIaUFMzvSdwXDenDlbkObSEcpEk3pExUqBRViExxtcg5j021-FJ-RCJMe1ffbnN5KmMUm6m7g8rEluuxkoOIY_HBaQBZG01tEQQ-Surnc0RwW3MlRIpwwhYMwZpLzrrzJ741UFSb1ABbwSPsa2iwqGluLW2lx4DNHZKyB0r94sUTL0h-Enjl3lCeNfLiOB5kxpU7pLo9IYU3fWC0aJNuMMqmNsPKrvfONUPRmIseDmzY75k-KUWfpsP3hEeJpk1-PBOLydXGGCDEEO8QYJQVpfNBRF6rcFi9ENCT9mgeE67sSUa2OV2gmh85LX5AjvBG7Sk1W-tm6r2rJPTTltwaf5dvnuBBrR3-JPbxjwNJUC9jdcK_S6Ahe4gZOg3ZrMHWOrGet1eHRHNWHGtOBgzqHZfQA-m0daUle_qW6VefIXlKXpSVzvE3KIjOoURtIASUtrdrzgi0S6zlP7oIYYda8cS8EY_f7dUIsflTGMpzPztmsnHrTButCVXzjvjIpMbhOEgTj6iq1SpvfJZk2RU50xVB0_vpFgM29TS3WaL50X_haLtVywWLpKKhOvDVGL9Zle04wLYDrWhiTHfhxKAlhDJjk8Nyya05VFoW0g5U-o_Z6JR-2rp2xsZBTiBWHxX1R_MGNjo6yBNDGBzMkUOkXPnzRjlfUk6zXviqUShPplM_f32KQTUhuJieHIBspys6YlxcJXnE4Br4i5g0Kz83JDWcKazecxQXe4e4TM7KgOHFvhZieGApNmDN7EiqfUZSPvoxfmrMnh8NGKNVseKuk0z_gAuK7QXrAHNI0cYjTKyIXLviPg9_1zRHjYBCqB08lDt6G0SmnuxPGhlFvsORc5kTt-JmArH_2LkhZ7J3cMZgkApEZsx4fQKB1Z7pwMIDQUdVStYLq_kyrZgQgDaBJqF5LPz_7MGsoDyGxf990WapcIyTZ7f8mMHPzfoHEXgpHjOHqIFPAaw1tgQJD9zngc43pB9sJLDXn2J3O__r81edFy17C2yg_JM3FYzQ2UCILIIslS2L3MvADh3qGB \ No newline at end of file diff --git a/v2_adminpanel/BLUEPRINT_MIGRATION_COMPLETE.md b/v2_adminpanel/BLUEPRINT_MIGRATION_COMPLETE.md deleted file mode 100644 index e1af8df..0000000 --- a/v2_adminpanel/BLUEPRINT_MIGRATION_COMPLETE.md +++ /dev/null @@ -1,42 +0,0 @@ -# Blueprint Migration Complete - -## Summary - -The blueprint migration has been successfully completed. All 60 routes that were previously in `app.py` have been moved to their respective blueprint files and the originals have been commented out in `app.py`. - -## Changes Made - -1. **Commented out all duplicate routes in app.py** - - 60 routes total were commented out - - Routes are preserved as comments for reference - -2. **Registered all blueprints** - - auth_bp (auth_routes.py) - 9 routes - - admin_bp (admin_routes.py) - 10 routes - - api_bp (api_routes.py) - 14 routes (with /api prefix) - - batch_bp (batch_routes.py) - 4 routes - - customer_bp (customer_routes.py) - 7 routes - - export_bp (export_routes.py) - 5 routes (with /export prefix) - - license_bp (license_routes.py) - 4 routes - - resource_bp (resource_routes.py) - 7 routes - - session_bp (session_routes.py) - 6 routes - -3. **Fixed route inconsistencies** - - Updated `/session/terminate/` to `/session/end/` in session_routes.py to match the original - -## Application Structure - -The application now follows a proper blueprint structure: -- `app.py` - Contains only Flask app initialization, configuration, and scheduler setup -- `routes/` - Contains all route blueprints organized by functionality -- All routes are properly organized and no duplicates exist - -## Next Steps - -1. Test the application to ensure all routes work correctly -2. Remove commented route code from app.py once verified working -3. Consider further refactoring of large blueprint files if needed - -## Backup - -A backup of the original app.py was created with timestamp before making changes. \ No newline at end of file diff --git a/v2_adminpanel/FEHLERSUCHE.md b/v2_adminpanel/FEHLERSUCHE.md deleted file mode 100644 index 4f4a5b3..0000000 --- a/v2_adminpanel/FEHLERSUCHE.md +++ /dev/null @@ -1,318 +0,0 @@ -# Fehlersuche - v2_adminpanel Refactoring - -## Aktueller Stand (18.06.2025 - 02:30 Uhr) -✅ **ALLE KRITISCHEN PROBLEME GELÖST** -- Resources Route funktioniert jetzt korrekt -- Customers-Licenses Route funktioniert jetzt korrekt -- Container startet ohne Fehler - -### Finale Fixes (18.06.2025 - 02:45 Uhr) - -#### Customers-Licenses Testdaten-Filter -- Problem: `/customers-licenses?show_test=false` zeigte trotzdem alle Kunden (auch Testdaten) -- Ursache: Die SQL-Query in `customers_licenses()` berücksichtigte den `show_test` Parameter nicht -- Lösung: - - `show_test` Parameter aus der URL auslesen - - WHERE-Klausel hinzugefügt: `WHERE (%s OR c.is_test = false)` - - `c.is_test` in SELECT und GROUP BY hinzugefügt - - `show_test` Parameter an Template weitergeben - - Standardverhalten: Nur Produktivdaten werden angezeigt (wenn show_test=false oder nicht gesetzt) - -### Bereits gelöste Probleme (18.06.2025 - 02:35 Uhr) - -#### Backups Route Fix -- Problem 1: 500 Error bei `/backups` - `url_for('admin.create_backup')` existiert nicht -- Lösung 1: - - `url_for('admin.create_backup')` → `url_for('admin.create_backup_route')` - - `url_for('admin.restore_backup', backup_id='')` → `/backup/restore/${backupId}` - - `url_for('admin.delete_backup', backup_id='')` → `/backup/delete/${backupId}` - -- Problem 2: "SyntaxError: Unexpected token '<'" beim Backup erstellen -- Ursache: Routes gaben HTML (redirect) statt JSON zurück -- Lösung 2: - - `create_backup_route()` und `restore_backup_route()` geben jetzt JSON zurück - - Entfernt: `return redirect(url_for('admin.backups'))` - - Hinzugefügt: `return jsonify({'success': True/False, 'message': '...'})` - -### Bereits gelöste Probleme (18.06.2025 - 02:30 Uhr) -1. **Customers-Licenses Template Fix**: - - Problem: `url_for('api.toggle_license', license_id='')` mit leerem String - - Lösung: Hardcodierte URL verwendet: `/api/license/${licenseId}/toggle` - -2. **Resources Route Fix**: - - Problem 1: `invalid literal for int() with base 10: ''` bei page Parameter - - Lösung 1: Try-except Block für sichere Konvertierung des page Parameters - - Problem 2: `url_for('resources.quarantine', resource_id='')` mit leerem String im Template - - Lösung 2: Hardcodierte URL verwendet: `/resources/quarantine/${resourceId}` - - Zusätzlich: Debug-Logging hinzugefügt für bessere Fehlerdiagnose - -### Wichtige Erkenntnisse: -- Flask's `url_for()` kann nicht mit leeren Parametern für Integer-Routen umgehen -- Bei JavaScript-generierten URLs ist es oft besser, hardcodierte URLs zu verwenden -- Container muss nach Template-Änderungen neu gestartet werden - -## Stand vom 17.06.2025 - 11:00 Uhr - -### Erfolgreiches Refactoring -- Die ursprüngliche 5000+ Zeilen große app.py wurde erfolgreich in Module aufgeteilt: - - 9 Blueprint-Module in `routes/` - - Separate Module für auth/, utils/, config.py, db.py, models.py - - Hauptdatei app.py nur noch 178 Zeilen - -### Funktionierende Teile -- ✅ Routing-System funktioniert (alle Routen sind registriert) -- ✅ Login-System funktioniert -- ✅ Einfache Test-Routen funktionieren (/simple-test) -- ✅ Blueprint-Registrierung funktioniert korrekt -- ✅ /test-db Route funktioniert nach Docker-Rebuild -- ✅ Kunden-Anzeige funktioniert mit Test-Daten-Filter -- ✅ Lizenzen-Anzeige funktioniert mit erweiterten Filtern -- ✅ Batch-Lizenzerstellung funktioniert -- ✅ Ressourcen-Pool funktioniert vollständig -- ✅ Ressourcen hinzufügen funktioniert - -### Gelöste Probleme - -#### 1. **Dict/Tuple Inkonsistenzen** ✅ GELÖST -**Problem**: Templates erwarteten Tuple-Zugriff (row[0], row[1]), aber models.py lieferte Dictionaries -**Lösung**: Alle betroffenen Templates wurden auf Dictionary-Zugriff umgestellt: -- customers.html: `customer[0]` → `customer.id`, `customer[1]` → `customer.name`, etc. -- customers_licenses.html: Komplett auf Dictionary-Zugriff umgestellt -- licenses.html, edit_license.html, sessions.html, audit_log.html, resources.html, backups.html: Alle konvertiert - -#### 2. **Fehlende /api/customers Route** ✅ GELÖST -**Problem**: Batch-Lizenzerstellung konnte keine Kunden laden (Select2 AJAX-Fehler) -**Lösung**: api_customers() Funktion zu api_routes.py hinzugefügt - -#### 3. **Doppelte api_customers Funktion** ✅ GELÖST -**Problem**: AssertionError beim Start - View function mapping is overwriting existing endpoint -**Lösung**: Doppelte Definition in api_routes.py entfernt (Zeilen 833-943) - -#### 4. **502 Bad Gateway Error** ✅ GELÖST -**Problem**: Admin-Panel war nicht erreichbar, nginx gab 502 zurück -**Ursache**: Container startete nicht wegen doppelter Route-Definition -**Lösung**: Doppelte api_customers Funktion entfernt, Container neu gebaut - -#### 5. **Test-Daten Filter** ✅ GELÖST -**Problem**: Test-Daten wurden immer angezeigt, Checkbox funktionierte nicht -**Lösung**: get_customers() in models.py unterstützt jetzt show_test Parameter - -## Debugging-Schritte - -### 1. Container komplett neu bauen -```bash -cd C:\Users\Administrator\Documents\GitHub\v2-Docker\v2 -docker-compose down -docker-compose build --no-cache -docker-compose up -d -``` - -### 2. Logs überprüfen -```bash -docker logs admin-panel --tail 100 -``` - -### 3. Test-Routen -- `/simple-test` - Sollte "Simple test works!" zeigen -- `/debug-routes` - Zeigt alle registrierten Routen -- `/test-db` - Testet Datenbankverbindung - -### 4. Login-Test -1. Gehe zu https://admin-panel-undso.z5m7q9dk3ah2v1plx6ju.com/ -2. Logge dich mit den Admin-Credentials ein -3. Versuche dann /customers-licenses aufzurufen - -## Code-Fixes bereits implementiert - -### 1. Datenbankverbindungen -- Alle kritischen Funktionen verwenden jetzt `conn = get_connection()` mit normalem Cursor -- Verhindert Dictionary/Tuple Konflikte - -### 2. Spaltennamen korrigiert -- `is_active` statt `active` für licenses Tabelle -- `is_active` statt `active` für sessions Tabelle -- `is_test` statt `is_test_license` -- Entfernt: phone, address, notes aus customers (existieren nicht) - -### 3. Blueprint-Referenzen -- Alle url_for() Aufrufe haben korrekte Blueprint-Präfixe -- z.B. `url_for('auth.login')` statt `url_for('login')` - -## Nächste Schritte - -1. **Container neu bauen** (siehe oben) -2. **Einloggen** und testen ob /customers-licenses funktioniert -3. **Falls weiterhin Fehler**: Docker Logs nach "CUSTOMERS-LICENSES ROUTE CALLED" durchsuchen -4. **Alternative**: Temporär auf die große app.py.backup zurückwechseln: - ```bash - cp app.py.backup app.py - docker-compose restart admin-panel - ``` - -## Bekannte funktionierende Routen (nach Login) -- `/` - Dashboard -- `/customers` - Kundenliste -- `/licenses` - Lizenzliste -- `/resources` - Ressourcen -- `/audit` - Audit Log -- `/sessions` - Sessions - -## Debug-Informationen in customer_routes.py -Die customers_licenses Funktion hat erweiterte Logging-Ausgaben: -- "=== CUSTOMERS-LICENSES ROUTE CALLED ===" -- "=== QUERY RETURNED X ROWS ===" -- Details über Datentypen der Ergebnisse - -Diese erscheinen in den Docker Logs und helfen bei der Fehlersuche. - -## Zusammenfassung der Fixes - -### Template-Konvertierungen (Dict statt Tuple) -Folgende Templates wurden von Tuple-Zugriff auf Dictionary-Zugriff umgestellt: -1. **customers.html**: customer[0] → customer.id, etc. -2. **customers_licenses.html**: Komplett umgestellt -3. **edit_customer.html**: customer[0] → customer.id, etc. -4. **licenses.html**: license[0] → license.id, etc. -5. **edit_license.html**: license[0] → license.id, etc. -6. **sessions.html**: session[0] → session.id, etc. -7. **audit_log.html**: log[0] → log.id, etc. -8. **resources.html**: resource[0] → resource.id, etc. -9. **backups.html**: backup[0] → backup.id, etc. - -### API-Fixes -1. **api_routes.py**: Fehlende /api/customers Route hinzugefügt -2. **api_routes.py**: Doppelte api_customers Funktion entfernt - -### Model-Fixes -1. **models.py**: get_customers() unterstützt jetzt show_test und search Parameter -2. **customer_routes.py**: customers() nutzt die neuen Parameter - -### Status -✅ **Alle bekannten Probleme wurden behoben** -✅ **Admin-Panel ist vollständig funktionsfähig** -✅ **Docker Container läuft stabil** - -## Weitere gelöste Probleme (17.06.2025 - 11:00 Uhr) - -### 1. **Test-Daten Checkbox funktioniert nicht** ✅ GELÖST -**Problem**: Die Checkbox zum Anzeigen von Test-Daten in Kunden- und Lizenzansicht funktionierte nicht -**Ursache**: Fehlende Blueprint-Präfixe in Template-URLs -**Lösung**: -- `customers.html`: Alle `url_for('customers')` → `url_for('customer.customers')` -- `licenses.html`: Alle `url_for('licenses')` → `url_for('license.licenses')` -- Formulare senden jetzt korrekt mit `show_test` Parameter - -### 2. **Lizenz-Filter erweitert** ✅ GELÖST -**Problem**: Filter für Test-/Live-Daten fehlte in Lizenzansicht -**Lösung**: `license_routes.py` erweitert mit: -- Typ-Filter: `full`, `test`, `test_data`, `live_data` -- Status-Filter: `active`, `expiring`, `expired`, `inactive` -- Suche über Lizenzschlüssel, Kundenname und E-Mail - -### 3. **Resource Pool Anzeige** ✅ GELÖST -**Problem**: Ressourcen-Pool Seite hatte fehlerhafte Links und Filter funktionierten nicht -**Lösung**: -- `resources.html`: Form-Action korrigiert zu `url_for('resources.resources')` -- JavaScript `toggleTestResources()` arbeitet jetzt mit URL-Parametern -- Alle Sortier- und Paginierungs-Links korrigiert - -### 4. **Ressourcen hinzufügen fehlte** ✅ GELÖST -**Problem**: Route `/resources/add` existierte nicht -**Lösung**: Komplette `add_resources()` Funktion in `resource_routes.py` implementiert: -- Validierung für Domains, IPv4-Adressen und Telefonnummern -- Duplikat-Prüfung -- Bulk-Import mit detailliertem Feedback -- Test/Produktion Unterscheidung - -### 5. **Navigation-Links** ✅ GELÖST -**Problem**: Sidebar-Links für Ressourcen verwendeten hardcodierte URLs -**Lösung**: `base.html` aktualisiert: -- Resource Pool Link: `href="{{ url_for('resources.resources') }}"` -- Add Resources Link: `href="{{ url_for('resources.add_resources') }}"` -- Active-Status Prüfung korrigiert für Blueprint-Endpunkte - -## Routing-Analyse (17.06.2025 - 11:30 Uhr) - -### Identifizierte Routing-Probleme - -Nach systematischer Analyse wurden folgende Routing-Probleme gefunden: - -#### 1. **Fehlende Blueprint-Präfixe** ⚠️ OFFEN -Viele `url_for()` Aufrufe fehlen Blueprint-Präfixe. Dies verursacht 500-Fehler: - -**Betroffene Templates:** -- `profile.html`: 3 fehlerhafte Aufrufe (`change_password`, `disable_2fa`, `setup_2fa`) -- `setup_2fa.html`: 2 fehlerhafte Aufrufe (`profile`, `enable_2fa`) -- `backup_codes.html`: 1 fehlerhafter Aufruf (`profile`) -- `resource_history.html`: 2 fehlerhafte Aufrufe (`resources`, `edit_license`) -- `resource_metrics.html`: 2 fehlerhafte Aufrufe (`resources`, `resources_report`) -- `resource_report.html`: 2 fehlerhafte Aufrufe -- `sessions.html`: Mehrere fehlerhafte Aufrufe -- `audit_log.html`: Mehrere fehlerhafte Aufrufe - -#### 2. **Hardcodierte URLs** ⚠️ OFFEN -Über 50 hardcodierte URLs gefunden, die mit `url_for()` ersetzt werden sollten: - -**Hauptprobleme in `base.html`:** -- `href="/"` → `href="{{ url_for('admin.dashboard') }}"` -- `href="/profile"` → `href="{{ url_for('auth.profile') }}"` -- `href="/logout"` → `href="{{ url_for('auth.logout') }}"` -- `href="/customers-licenses"` → `href="{{ url_for('customer.customers_licenses') }}"` -- `href="/customer/create"` → `href="{{ url_for('customer.create_customer') }}"` -- `href="/create"` → `href="{{ url_for('license.create_license') }}"` -- `href="/batch"` → `href="{{ url_for('batch.batch_create') }}"` -- `href="/audit"` → `href="{{ url_for('admin.audit_log') }}"` -- `href="/sessions"` → `href="{{ url_for('session.sessions') }}"` -- `href="/backups"` → `href="{{ url_for('admin.backups') }}"` - -#### 3. **Doppelte Route-Definitionen** ✅ GELÖST -- Entfernt: Doppelte `add_resource` Funktion in `resource_routes.py` - -#### 4. **Route-Namenskonsistenz** ⚠️ OFFEN -- `resource_report` vs `resources_report` - inkonsistente Benennung - -### Prioritäten für Fixes - -1. **KRITISCH**: Fehlende Blueprint-Präfixe (verursachen 500-Fehler) -2. **HOCH**: Hardcodierte URLs in Navigation (`base.html`) -3. **MITTEL**: Andere hardcodierte URLs -4. **NIEDRIG**: Namenskonsistenz - -### Vollständiger Report -Ein detaillierter Report wurde erstellt: `ROUTING_ISSUES_REPORT.md` - -## Aktuelle Probleme (18.06.2025 - 01:30 Uhr) - -### 1. **Resources Route funktioniert nicht** ✅ GELÖST (18.06.2025 - 02:00 Uhr) -**Problem**: `/resources` Route leitete auf Dashboard um mit Fehlermeldung "Fehler beim Laden der Ressourcen!" -**Fehlermeldungen im Log**: -1. Ursprünglich: `FEHLER: Spalte l.customer_name existiert nicht` -2. Nach Fix: `'dict object' has no attribute 'total'` - -**Gelöst durch**: -1. Stats Dictionary korrekt initialisiert mit allen erforderlichen Feldern inkl. `available_percent` -2. Fehlende Template-Variablen hinzugefügt: `total`, `page`, `total_pages`, `sort_by`, `sort_order`, `recent_activities`, `datetime` -3. Template-Variable `search_query` → `search` korrigiert -4. Route-Namen korrigiert: `quarantine_resource` → `quarantine`, `release_resources` → `release` -5. Export-Route korrigiert: `resource_report` → `resources_report` - -### 2. **URL-Generierungsfehler** ✅ GELÖST -**Problem**: Mehrere `url_for()` Aufrufe mit falschen Endpunkt-Namen -**Gelöste Fehler**: -- `api.generate_license_key` → `api.api_generate_key` -- `api.customers` → `api.api_customers` -- `export.customers` → `export.export_customers` -- `export.licenses` → `export.export_licenses` -- `url_for()` mit leeren Parametern durch hardcodierte URLs ersetzt - -### 3. **Customers-Licenses Route** ✅ GELÖST (18.06.2025 - 02:00 Uhr) -**Problem**: `/customers-licenses` Route leitete auf Dashboard um -**Fehlermeldung im Log**: `ValueError: invalid literal for int() with base 10: ''` -**Ursache**: Template versuchte Server-seitiges Rendering von Daten, die per AJAX geladen werden sollten - -**Gelöst durch**: -1. Entfernt: Server-seitiges Rendering von `selected_customer` und `licenses` im Template -2. Template zeigt jetzt nur "Wählen Sie einen Kunden aus" bis AJAX-Daten geladen sind -3. Korrigiert: `selected_customer_id` Variable entfernt -4. Export-Links funktionieren jetzt ohne `customer_id` Parameter -5. API-Endpunkt korrekt referenziert mit `url_for('customers.api_customer_licenses')` \ No newline at end of file diff --git a/v2_adminpanel/MIGRATION_2FA.md b/v2_adminpanel/MIGRATION_2FA.md deleted file mode 100644 index d60855b..0000000 --- a/v2_adminpanel/MIGRATION_2FA.md +++ /dev/null @@ -1,43 +0,0 @@ -# Migration zu Passwort-Änderung und 2FA - -## Übersicht -Das Admin Panel unterstützt jetzt Passwort-Änderungen und Zwei-Faktor-Authentifizierung (2FA). Um diese Features zu nutzen, müssen bestehende Benutzer migriert werden. - -## Migration durchführen - -1. **Container neu bauen** (für neue Dependencies): - ```bash - docker-compose down - docker-compose build adminpanel - docker-compose up -d - ``` - -2. **Migration ausführen**: - ```bash - docker exec -it v2_adminpanel python migrate_users.py - ``` - - Dies erstellt Datenbankeinträge für die in der .env konfigurierten Admin-Benutzer. - -## Nach der Migration - -### Passwort ändern -1. Einloggen mit bisherigem Passwort -2. Klick auf "👤 Profil" in der Navigation -3. Neues Passwort eingeben (min. 8 Zeichen) - -### 2FA aktivieren -1. Im Profil auf "2FA einrichten" klicken -2. QR-Code mit Google Authenticator oder Authy scannen -3. 6-stelligen Code eingeben -4. Backup-Codes sicher aufbewahren! - -## Wichtige Hinweise -- Backup-Codes unbedingt speichern (Drucker, USB-Stick, etc.) -- Jeder Backup-Code kann nur einmal verwendet werden -- Bei Verlust des 2FA-Geräts können nur Backup-Codes helfen - -## Rückwärtskompatibilität -- Benutzer aus .env funktionieren weiterhin -- Diese haben aber keinen Zugriff auf Profil-Features -- Migration ist erforderlich für neue Features \ No newline at end of file diff --git a/v2_adminpanel/MIGRATION_DISCREPANCIES.md b/v2_adminpanel/MIGRATION_DISCREPANCIES.md deleted file mode 100644 index 7aa7632..0000000 --- a/v2_adminpanel/MIGRATION_DISCREPANCIES.md +++ /dev/null @@ -1,156 +0,0 @@ -# Migration Discrepancies - Backup vs Current Blueprint Structure - -## 1. Missing Routes - -### Authentication/Profile Routes (Not in any blueprint) -- `/profile` - User profile page -- `/profile/change-password` - Change password endpoint -- `/profile/setup-2fa` - Setup 2FA page -- `/profile/enable-2fa` - Enable 2FA endpoint -- `/profile/disable-2fa` - Disable 2FA endpoint -- `/heartbeat` - Session heartbeat endpoint - -### Customer API Routes (Missing from api_routes.py) -- `/api/customer//licenses` - Get licenses for a customer -- `/api/customer//quick-stats` - Get quick stats for a customer - -### Resource Routes (Missing from resource_routes.py) -- `/resources` - Main resources page -- `/resources/add` - Add new resources page -- `/resources/quarantine/` - Quarantine a resource -- `/resources/release` - Release resources from quarantine -- `/resources/history/` - View resource history -- `/resources/metrics` - Resource metrics page -- `/resources/report` - Resource report page - -### Main Dashboard Route (Missing) -- `/` - Main dashboard (currently in backup shows dashboard with stats) - -## 2. Database Column Discrepancies - -### Column Name Differences -- **created_by** - Used in backup_history table but not consistently referenced -- **is_test_license** vs **is_test** - The database uses `is_test` but some code might reference `is_test_license` - -### Session Table Aliases -The sessions table has multiple column aliases that need to be handled: -- `login_time` (alias for `started_at`) -- `last_activity` (alias for `last_heartbeat`) -- `logout_time` (alias for `ended_at`) -- `active` (alias for `is_active`) - -## 3. Template Name Mismatches - -### Templates Referenced in Backup -- `login.html` - Login page -- `verify_2fa.html` - 2FA verification -- `profile.html` - User profile -- `setup_2fa.html` - 2FA setup -- `backup_codes.html` - 2FA backup codes -- `dashboard.html` - Main dashboard -- `index.html` - Create license form -- `batch_result.html` - Batch operation results -- `batch_form.html` - Batch form -- `edit_license.html` - Edit license -- `edit_customer.html` - Edit customer -- `create_customer.html` - Create customer -- `customers_licenses.html` - Customer-license overview -- `sessions.html` - Sessions list -- `audit_log.html` - Audit log -- `backups.html` - Backup management -- `blocked_ips.html` - Blocked IPs -- `resources.html` - Resources list -- `add_resources.html` - Add resources form -- `resource_history.html` - Resource history -- `resource_metrics.html` - Resource metrics -- `resource_report.html` - Resource report - -## 4. URL_FOR References That Need Blueprint Prefixes - -### In Templates and Redirects -- `url_for('login')` → `url_for('auth.login')` -- `url_for('logout')` → `url_for('auth.logout')` -- `url_for('verify_2fa')` → `url_for('auth.verify_2fa')` -- `url_for('profile')` → `url_for('auth.profile')` (needs implementation) -- `url_for('index')` → `url_for('main.index')` or appropriate blueprint -- `url_for('blocked_ips')` → `url_for('admin.blocked_ips')` -- `url_for('audit_log')` → `url_for('admin.audit_log')` -- `url_for('backups')` → `url_for('admin.backups')` - -## 5. Missing Functions/Middleware - -### Authentication Decorators -- `@login_required` decorator implementation needs to be verified -- `@require_2fa` decorator (if used) - -### Helper Functions -- `get_connection()` - Database connection helper -- `log_audit()` - Audit logging function -- `create_backup()` - Backup creation function -- Rate limiting functions for login attempts - -### Session Management -- Session timeout handling -- Heartbeat mechanism for active sessions - -## 6. API Endpoint Inconsistencies - -### URL Prefix Issues -- API routes in backup don't use `/api` prefix consistently -- Some use `/api/...` while others are at root level - -### Missing API Endpoints -- `/api/generate-license-key` - Generate license key -- `/api/global-search` - Global search functionality - -## 7. Export Routes Organization - -### Current vs Expected -- Export routes might need different URL structure -- Check if all export types are covered: - - `/export/licenses` - - `/export/audit` - - `/export/customers` - - `/export/sessions` - - `/export/resources` - -## 8. Special Configurations - -### Missing Configurations -- TOTP/2FA configuration -- Backup encryption settings -- Rate limiting configuration -- Session timeout settings - -### Environment Variables -- Check if all required environment variables are properly loaded -- Database connection parameters -- Secret keys and encryption keys - -## 9. JavaScript/AJAX Endpoints - -### API calls that might be broken -- Device management endpoints -- Quick edit functionality -- Bulk operations -- Resource allocation checks - -## 10. Permission/Access Control - -### Missing or Incorrect Access Control -- All routes need `@login_required` decorator -- Some routes might need additional permission checks -- API routes need proper authentication - -## Action Items - -1. **Implement missing profile/auth routes** in auth_routes.py -2. **Add missing customer API routes** to api_routes.py -3. **Create complete resource management blueprint** with all routes -4. **Fix main dashboard route** - decide which blueprint should handle "/" -5. **Update all url_for() calls** in templates to use blueprint prefixes -6. **Verify database column names** are consistent throughout -7. **Check template names** match between routes and actual files -8. **Implement heartbeat mechanism** for session management -9. **Add missing helper functions** to appropriate modules -10. **Test all export routes** work correctly with new structure \ No newline at end of file diff --git a/v2_adminpanel/ROUTING_ISSUES_REPORT.md b/v2_adminpanel/ROUTING_ISSUES_REPORT.md deleted file mode 100644 index 98c4b07..0000000 --- a/v2_adminpanel/ROUTING_ISSUES_REPORT.md +++ /dev/null @@ -1,118 +0,0 @@ -# V2 Admin Panel - Routing Issues Report - -Generated: 2025-06-17 - -## Summary of Findings - -After systematically analyzing the v2_adminpanel application, I've identified several routing issues that need to be addressed: - -### 1. Missing Blueprint Prefixes in url_for() Calls - -The following templates have `url_for()` calls that are missing the required blueprint prefix: - -#### In `profile.html`: -- `url_for('change_password')` → Should be `url_for('auth.change_password')` -- `url_for('disable_2fa')` → Should be `url_for('auth.disable_2fa')` -- `url_for('setup_2fa')` → Should be `url_for('auth.setup_2fa')` - -#### In `setup_2fa.html`: -- `url_for('profile')` → Should be `url_for('auth.profile')` -- `url_for('enable_2fa')` → Should be `url_for('auth.enable_2fa')` - -#### In `backup_codes.html`: -- `url_for('profile')` → Should be `url_for('auth.profile')` - -#### In `resource_history.html`: -- `url_for('resources')` → Should be `url_for('resources.resources')` -- `url_for('edit_license', license_id=...)` → Should be `url_for('licenses.edit_license', license_id=...)` - -#### In `resource_metrics.html`: -- `url_for('resources')` → Should be `url_for('resources.resources')` -- `url_for('resources_report')` → Should be `url_for('resources.resource_report')` - -#### In `resource_report.html`: -- `url_for('resources')` → Should be `url_for('resources.resources')` -- `url_for('resources_report')` → Should be `url_for('resources.resource_report')` - -#### In `sessions.html`: -- `url_for('sessions', ...)` → Should be `url_for('sessions.sessions', ...)` - -#### In `audit_log.html`: -- `url_for('audit_log', ...)` → Should be `url_for('admin.audit_log', ...)` - -#### In `licenses.html`: -- `url_for('licenses', ...)` → Should be `url_for('licenses.licenses', ...)` - -#### In `customers.html`: -- `url_for('customers', ...)` → Should be `url_for('customers.customers', ...)` - -#### In `resources.html`: -- Several instances of incorrect references: - - `url_for('customers.customers_licenses', ...)` → Should be `url_for('customers.customers_licenses', ...)` - - `url_for('licenses.edit_license', ...)` → Correct - - `url_for('resource_history', ...)` → Should be `url_for('resources.resource_history', ...)` - - `url_for('edit_license', ...)` → Should be `url_for('licenses.edit_license', ...)` - - `url_for('customers_licenses', ...)` → Should be `url_for('customers.customers_licenses', ...)` - -### 2. Hardcoded URLs That Need Replacement - -Many templates contain hardcoded URLs that should be replaced with `url_for()` calls: - -#### In `base.html`: -- `href="/"` → Should be `href="{{ url_for('admin.index') }}"` -- `href="/profile"` → Should be `href="{{ url_for('auth.profile') }}"` -- `href="/logout"` → Should be `href="{{ url_for('auth.logout') }}"` -- `href="/customers-licenses"` → Should be `href="{{ url_for('customers.customers_licenses') }}"` -- `href="/customer/create"` → Should be `href="{{ url_for('customers.create_customer') }}"` -- `href="/create"` → Should be `href="{{ url_for('licenses.create_license') }}"` -- `href="/batch"` → Should be `href="{{ url_for('batch.batch_licenses') }}"` -- `href="/audit"` → Should be `href="{{ url_for('admin.audit_log') }}"` -- `href="/sessions"` → Should be `href="{{ url_for('sessions.sessions') }}"` -- `href="/backups"` → Should be `href="{{ url_for('admin.backups') }}"` -- `href="/security/blocked-ips"` → Should be `href="{{ url_for('admin.blocked_ips') }}"` - -#### In `customers_licenses.html` and `customers_licenses_old.html`: -- Multiple hardcoded URLs for editing, creating, and exporting that need to be replaced with proper `url_for()` calls - -#### In `edit_license.html`, `create_customer.html`, `index.html`: -- `href="/customers-licenses"` → Should use `url_for()` - -#### In `dashboard.html`: -- Multiple hardcoded URLs that should use `url_for()` - -#### In error pages (`404.html`, `500.html`): -- `href="/"` → Should be `href="{{ url_for('admin.index') }}"` - -### 3. Blueprint Configuration - -Current blueprint configuration: -- `export_bp` has `url_prefix='/export'` -- `api_bp` has `url_prefix='/api'` -- All other blueprints have no url_prefix - -### 4. Route Naming Inconsistencies - -Some routes have inconsistent naming between the route definition and the function name: -- Route `/resources/report` has function name `resource_report` (note the singular vs plural) -- This causes confusion with `url_for()` calls - -### 5. Duplicate Route Risk Areas - -While no exact duplicates were found, there are potential conflicts: -- Both `admin_bp` and `customer_bp` might handle customer-related routes -- API routes in `api_bp` overlap with functionality in other blueprints - -## Recommendations - -1. **Fix all `url_for()` calls** to include the correct blueprint prefix -2. **Replace all hardcoded URLs** with `url_for()` calls -3. **Standardize route naming** to match function names -4. **Add url_prefix to blueprints** where appropriate to avoid conflicts -5. **Create a route mapping document** for developers to reference - -## Priority Actions - -1. **High Priority**: Fix missing blueprint prefixes in `url_for()` calls - these will cause runtime errors -2. **High Priority**: Replace hardcoded URLs in navigation (base.html) - affects site-wide navigation -3. **Medium Priority**: Fix other hardcoded URLs in individual templates -4. **Low Priority**: Refactor route naming for consistency \ No newline at end of file diff --git a/v2_adminpanel/TEMPLATE_FIXES_NEEDED.md b/v2_adminpanel/TEMPLATE_FIXES_NEEDED.md deleted file mode 100644 index 74d75da..0000000 --- a/v2_adminpanel/TEMPLATE_FIXES_NEEDED.md +++ /dev/null @@ -1,73 +0,0 @@ -# Template Fixes Needed - Tuple to Dictionary Migration - -## Problem -Die models.py Funktionen geben Dictionaries zurück, aber viele Templates erwarten noch Tupel mit numerischen Indizes. - -## Betroffene Templates und Routes: - -### 1. ✅ FIXED: customers.html -- Route: `/customers` -- Funktion: `get_customers()` -- Status: Bereits gefixt - -### 2. ✅ FIXED: customers_licenses.html -- Route: `/customers-licenses` -- Status: Teilweise gefixt (customers list) -- TODO: selected_customer wird per JavaScript geladen - -### 3. ✅ FIXED: edit_customer.html -- Route: `/customer/edit/` -- Funktion: `get_customer_by_id()` -- Status: Bereits gefixt - -### 4. ❌ licenses.html -- Route: `/licenses` -- Funktion: `get_licenses()` -- Problem: Nutzt license[0], license[1], etc. -- Lösung: Ändern zu license.id, license.license_key, etc. - -### 5. ❌ edit_license.html -- Route: `/license/edit/` -- Funktion: `get_license_by_id()` -- Problem: Nutzt license[x] Syntax - -### 6. ❌ sessions.html -- Route: `/sessions` -- Funktion: `get_active_sessions()` -- Problem: Nutzt session[x] Syntax - -### 7. ❌ audit_log.html -- Route: `/audit` -- Problem: Nutzt entry[x] Syntax - -### 8. ❌ resources.html -- Route: `/resources` -- Problem: Nutzt resource[x] Syntax - -### 9. ❌ backups.html -- Route: `/backups` -- Problem: Nutzt backup[x] Syntax - -### 10. ✅ FIXED: batch_form.html -- Route: `/batch` -- Problem: Fehlende /api/customers Route -- Status: API Route hinzugefügt - -### 11. ❌ dashboard.html (index.html) -- Route: `/` -- Problem: Möglicherweise nutzt auch numerische Indizes - -## Batch License Problem -- batch_create.html existiert nicht, stattdessen batch_form.html -- Template mismatch in batch_routes.py Zeile 118 - -## Empfohlene Lösung -1. Alle Templates systematisch durchgehen und von Tupel auf Dictionary-Zugriff umstellen -2. Alternativ: Models.py ändern um Tupel statt Dictionaries zurückzugeben (nicht empfohlen) -3. Batch template name fix: batch_create.html → batch_form.html - -## Quick Fix für Batch -Zeile 118 in batch_routes.py ändern: -```python -return render_template("batch_form.html", customers=customers) -``` \ No newline at end of file diff --git a/v2_adminpanel/__pycache__/app.cpython-312.pyc b/v2_adminpanel/__pycache__/app.cpython-312.pyc index 446fde5ebe8e3bf6c42423f322746674727acf34..37a2c18bc59aa6ea8d88487fd67f5fc3529d2ba5 100644 GIT binary patch delta 4277 zcmZ`+drVu`89(=BUqA4Jx1ohgNC<}T2;|Wanv_>)(gp&i>ykCHjPK>)7@MAZodgTw zq^ee;W=fbR!IV{@Y9~=8QnO`$Oqw*UYNbt@)|xsI^cJQ{-Ts)SNp8D8BKpt1b1pVc zre4Y4J@@>+^PTUU@9Ce`Kkjz?wV=R?;Q8ZcLlXrh3yyO3cjn*Jf5I`AW@DZn^=DM8rkvE~JgNP#tAV%V@YX#1MZkYf*1PxT&7R8YTG^Ic)t-mH zl*RWP$l(X`@RyZR`Ot-Wgm5u%-X`+Bhvnz)9l6V9^`2p7m|CQC>_&M1xuev8)j&Iy zqsfe|)B#6j!q1<39;TiT%_enwg~Xt3Wuf9a7>xTlMzjoCa0#OSW{$4jRk{>s*c6N;hCCvd3Tm&n?q7Cj^Yj}Uf!Z9 zs@4>YsakYO!D>@@GN?pUMGZb}7Y-)AY1>vp2&_{+ zhh$D8>*+I#vKGIM-eH3**o2=$SCG$mC%_arlL+yuUaDU9MkcihxSAd7#LIHG?9bkE z^Mp1P_AZON*?G#>Shd*`D+m6D*=8cnE_< zG>+zwhLS1!Gi*p84qn}h&hJBLj+sTF?eGT1+%vl{RFb$2)|zEPG#BO&Isj{MS<5i! zMl+9OHdOE=5BbuZ*|{A{X4mw3 zJ|{_r`Rw!(4c}LB2OvKtEU&%>l$Z=TTvtzQxQZ5S3$`^^-KwiD?W!jxJ3P;?i;kNG z*9(@4?>zgHhFs?|E zs3wg?V-eW^85#z-2^bBxH?GYBY<`37G(eXKTmuBIjOT<#@baa2+S#zulTw^5DY4~o zhGm4tFPsfqst9bbOq_vjUgGxmR&3J*?CzQV@U>LkZhV|NroXaB+K}+dEPvL$<4K&6t0y;j+gqovoxbIL$Ul=| zIiZ`$Guw{Ux|wx%>wV9CTgut_`(Vo1^|{!!rIH){gS|^cABmQ(A}$D@xyOtid}M!y zVO~xCy`b7l>kX|ThU*kgMnX_W{Nv&1Xdvvr7{Ds_!`io#wT`|7tu^)*6>4c%X(Dy$ z3DIN^auH9|!a^VAmI(KDEi^XW1Dts~+YG6G!iNKZEy6EX=c);(55+~JD9fWyY-|Bm zy74Y>2~yR-Gg1^PZ5%^c*a6NA4v*;k z!2|7Wx-G}u+nvZ`dBxW^th;mInJ%C2)xqIjub}f2AW-K?SErlg(MVtlDoYGP54H-Z zDzsg4yvo=2a$nDg)HC?Xz({?AS2{5~cv{MJ1M!;PtoqwsS`X>=5~SUO1H~d5)Hzp< zhsHC^SV-mToW{zrxT&*+yuEhVRGkS%r^nlL^Y&HfY!qtXxS}bMi@GTo!%*;avvOGp zLR+YdV~Q4>h(;7$2!^AeHYqkVuiyl!5O^4f7fK_%be@RRIgkO|M6aDJq>_FDQV%>v z2%AX+`Q=d@2jDjR)PDjY<&~(@XZ5PHdc$3`=vnYA9bY+>b|2pPN}Nc$ z+c%0z7LP0(SvsFCYRo}tcjHD$+2YxSv#IK~bV+;0hFsEj$mFul^BD(n7TvsX{leQ* z^W3^_OBEk@C>~sg3TGTW;9aMf*+bb?XiLy1VWE!TWnt z&W@DW@%TJ#yjZGj`MSI2{o3Wgdv(j@E1pz)|NSFt?ft9m{pt3TskT#psY^MB9*INW zX9U8(rB=aN-{>joDMcTbT6!Ja$JM)gEgbwqgz+a90cbMps3^xF;KLVycn9$7@PY<% zM#Y`UJC45zct1=$c-=X|3<>n#gJBz*_^D}(>`x(5BlN;-i{#J!;n0Phj-B?Jw0*%Z zBz0HiE2Ju8QWtbL7*nUy9}}p5^yg8 zp<==4TUYXY(eVV&G?KZp3=1Q=V2yFFGVWWoX{PiW*35L%{QxJ!OesiQV@g*I%(T7K z%$O)bG@U<|W-5rH5&ETD`3@?zh#B6NI?IxArUCKx`S|CYXPq~{)qk!3mS?&6 zYX2j?F2f2cl(p$0-~1(Cyv~qbU9UG`YpVkP2W!$HAb%TZ6XN6?^KTYjM&Hw-a delta 1147 zcmZ8fO>7%Q6rR~#CoWFhCUMf(rj3o8)LT2=2m(STNlQcsO4SIdaH%RRXJ=A7v3IvK z>rm&Av`|qbEk3oP$m2yD7^@5PNAy6PipcR%X!G#0J5J0FW-mH@-Wu*D$&HLW( zy!m(fopkuC?(QcLEJM6nBHizY2bH;w&(dG2;&-*8(c?>8SXvTuv7seXTpB*UXm$&? zH5mEU4}9x?d>jl&&%BS9eOwEi%#)Kh(WN|1Exq}8D0!oKGDH#tktBJFr0$*C>O2&@ zE|LR%@!|c1WaK8=+6!@M5|X$1(+7O|5I+U{n(wb4(w?IOj_X*@H6C{Uh8yR?GkD!QGIPzOj$5`ZFF+ZynWu7>>53OxD4NSPr?6PAP!Cfs zVfSNlZm)%n%n_}F-^ za1>m@gSq2U+hnHYkK<|iUyM;x&W)b!R+%HSI7vMVjB^0}p-wBO;qHc#W z+gfH9wCLBrz}MUxzEq$N*na*uK)8)ujKq6=afM)!3=OXDj|C%lZ>R_nWnW9wMdc#W zwfWWXMaotvOZZQlPpk%?9UE(xKM7%VjSa)WU}lKv5@a_l4{*4bE_iDQQt*SYv%roQ zrHS-_ezef73pXxo%zyOE=G70M+e~lwW*%tSpZ?b-n-G)XXydKEevGHZoygG0UBo6J zLi@dX%hnP#-NKcMy-=(aR*HHg`SEv6-t+dy38BztlM! z7CW)81D*<#ZG0Sm4fppldIdfPRJ~BCQHPZ+myL?K6Y=wM9DWmCk7-*jz2+7iW}B3A zoB1g{mDMaZC(RD(dla0g+VZDNv8zDHLGjO(#{Cm(cKG<1GcGRuCzHA8^mNxMw4^g9rXllmi;1R+soR5luyq h*7fe{cWU%Ut@GCGjoFRpX5{+p_u5EHMOw_S@ITYlC=vhw diff --git a/v2_adminpanel/__pycache__/app_no_duplicates.cpython-312.pyc b/v2_adminpanel/__pycache__/app_no_duplicates.cpython-312.pyc deleted file mode 100644 index d47d7f32a81f57909f7ffddff16f560e9b82d074..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 138120 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%0A5XkuIALx@^U0;3up8w<;Sq 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 G|Nj6Q=J9O+ diff --git a/v2_adminpanel/__pycache__/app_refactored.cpython-312.pyc b/v2_adminpanel/__pycache__/app_refactored.cpython-312.pyc deleted file mode 100644 index 9c9153c5b59b3ea191a8fadb3fe9e438dbd7ea4c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 172148 zcmeFad2}2{dM8*X>P8jr8%TfvaRUTzf|o$zAV?4(36K&YN>CA5AOKbYWLAMksUT5p zxf|?OTVP9LffhAF+dVd9@y%ey{btc_?U-)$KFfA{oyCHTQa!ZIp4FRu`eS#Y7B%Bx z@4UC)7nzZnRh0!E>ceYKk;o$=zPR&?FTVK27k}+=ST*qbALovZYQL`2{2%y2e`fLF zi^WWh<}D4YVYOb(pm5g?YRS89P)F|iK|Q$}1`XtH95j-9+F%;Fn+8pA*Llr8%b-Pz zap=8PpLNjcvkltFyTNPsIR+i%+2~F8IR~BOInC?xWejHcG6yq#S%X==?7?hb&R~u& zcQDtNH<;(kAI$d^3>J{ECU2pyXt0Ppo4v)plED)4Z1I-*$_C5Gv)0QDmP7hhZ-sBe z;0DpHacMJX&0vkMcCeQC+r4$Z`oVhg?C>`D8V4JFO@mG3J>A>v z+cdbz*D~1R+dR10w`FjPZ|mSz-?qVR#NX-N?%OfA!?$yAr*GHbE??_lt8e$K-9)oOGaPll)WeXY=fgMFUL@p;;;^4-%nen@_!Pi)Q} zcm#^yr!Mk61x2n%@jW>teM&)*CsO<&4JY4IP~?jg3vx*Ml!Br_r1)bRPQIt0C=@Af z%OUAg3W_3;;@{G6@;wDbu}HBfhony_C`v?%|3JgZ_Y@SRNCADI$kRvr0O=DY5dQlz zHGN{sgnzf0_l<&^r*CMG`a(Qq?~5;k&w8F6{{i)p@6?~9q96ZL*@r%P^y44UXZe1+ zT+Sa`@v3HU2;%>9*`GdnhS&|zBY#BSy{{8uU|SSaBPpmh(==rq^ zl-juzbXy@Ut(+E6Z&RTjO+md~k?IZ=y0H{=JCjn~r9wTPg1R+{dbbMo`4rT9lBoBp zP3VoLf z{lygYhZOnlR-xlk&>dFL9Z{k4r=aUm$?s7W`alZ$-lR6}Q=y(pL48b->*FePmr~H3 zP|0<_3jO62^aCpNCspXLq@X{QRO+WysIR7=enwI1(<*eq6m)|Mx@T4BUQ9uEMnU(S z3SB4#-SbJM98#g4PC@;GB2~8v-AgIx&MN4JRp?$$LB}TL)}umwEd}+6BGq#$bg!hK z8&%MasnC5b1>LxU?z{@!Z>6B~D(HMFbg!nMn^4eAs?hy*3c3plx{E4wUr#~DDd_wv zbl*rp7f5QwDHZCO6x5fJs4uHfUr#}OMUm&LDszM&}R zcU0(pHwE1rDkI~YD)euqp#N7%x&B=h>bVruZzWOBsZifcL47lc`u9|*e=h~~+ez}~ zEfwmwQ&7K?ME$Q-sBfj9epk_2^D1=jq@eq*g6{WK=>ByIx<5$D?e|ou-}Use|K@y+ zM#JTJ`ewD`ZFH6<-`PK8|LFDtoqcIFSG8BQweNj~0eWk-G1Gyo0gu1C?>$}2%(?@f zz?jbyv*4BWcmr+-VIG{E@N`d%Ov1C_m^(09Yl#^9>cn3MBNupVwG;PGAX0&&d5 zc`iH-PkkGV){3a416NTqPAHO5FehP`8+iNMg295kh$;hPkU3u*on|sL?Fy zab?)!9r9ecFv$gCh0tq!ZfH=57<(r6BI;Lkf%9B&Ljp7#0u6?B$BL=v@D$|22R&8k zE0KylG|sbtl9FmrdO`g_l>~;S{1D&StK>f6hOfrj-*NCn$H35$j?*!Fe@B0RcVF+& z!0BThF~{M4cx>y3{|CFfV=jE{?mRtowEJiW2~c>ft;Pko?IL$>ICkj&}5S4|K$`62kX_N357Sp6KX2(b0cs zsH5%Rp`jBU$4_?j4}69?Quw@#SICEJx-xWun+#0G(yk1RPU2UN4xSP6g&Mt{OCImA z%y={!1H98dr^GT+{_A1G8GnDYcq_sA&WJl#bQkHYm84u{Le04Cp#P z{9u#|FrKjq=9~wZ&jfHW<^cJi826MvSlAzMa{*71XH!!XFd&hIu<|1Z4A3TiX@RR3 zpf!b_$U+nMLccb_CjtE7T+A>AtdOgREWD@r0t@gU!+qfb@Qo4oF!pq|>B7~ROG-if z5V&Iy@-Y1QQR{Y1^Qpo1jh9J7L({4uWA6Oo`M)z9;CjI~W*Fvxhtb@OPEG;4 z60_zTn*gR2v$ODVUlrpXfEF^4P-&vKZj6n&;Jfg;hdt1B$NT}v`LI;|kcJ8VNYezN zDWGZKRrskj&9pY8_2I)%gPSa8e(pEEn=VK~phwqpYa54ACuHZvzY{tJ^Z z<}kjo0Mj)&dCu#ZYKIh0bclg~Us9&aOpFbW2AK1c6C-2irZ^H|BSRy*JjSuihaa5v zaQ+M2)C3H|1HtCeK;VMEwYmB7<;zXyNbF6+lfGscLERSu!=vtI_l2=$|5yN68;<=j z8m5VZJP0^K&OSq41s(=6;+SxJnw&zhXKHvDM48}5xjd(E$b@V`>dc5|)O*eYY{Kh? zth6#gn}_2jxki*1{GGuX1(j4kYIME94JSbcxs1#%;Nh4~h!<{WJ!5i3LhNbHWRQ>P zyE+CyitFnicn@SPpC>Rn$@*)Zu{02#pw?p9Y8A^Jfg;9Ckj4aZKqxv9^87Kw1snwo zD8$9|Feb*V9vHXDcn$*12_aYqb&45D5o3CP0E9rsS&x$;EXFaeiyJn;Jq1nD;W*p zjD~B6KTp%xb63sI*PYj$eE##x=Ao!FYb`C$ct9J?>723N%VnbZ%;!eE%Q|DYZ`U|- zUO#&M=v>{x)}@SP`|cTSG$Zq^+#9)Xi>4ELJV$E|$%nj^wn3ooyeR+kUZTMA8TTI;fdHZrgRBQuCwA zlJ-{phuZRk)%p*s?fAT9Py06gkDImd{Nrs#xW*0eHXtRV$oGKn#SQS_k@%Ciw>APD8flRTn;y0BtO$9)<(-ZZF&M~yF_h%0&0guZ3z_99F3RKC;2{Y z4W%hUFtQJQI)4VHvVR-0a(@%D_+L;6j5L7aPFp9W!S1%;xA;qokbT@p%>H&uHbH=sr`mZ zmHkUBRlQUyN#2^avpLfa_)8Djxx7H56v7;`v$-hpQi*`u%(|u>;F^c7)9Dj+A(Ntp zn<%AxzXUBBGOr&dWWv~pB1DJrD|&z&k@WP->p39@w;W36HbWUU(dbzM#()wQV4f82 z1VF*M6fOiH5!ga01Ka2mTTJ^4y@Twepd6BTfYOyEV2HT9*bwXhZDOSPnE+^#A_oKv z@vGykMJ~!Pmx0|hjLb9P+uTcCS%xiVD?(b7dv9-`ycR;J)n6s8H+qM! zaX*a-<|QWDAE%wFyxNs=Jj$5P3}tW|6#1$`5C!Iotx@z+6~oIe6rf0TSbMy}(y?n822|31t@ z@Yk=YZRmaA=$PUrFL;^{csTFa1cpl43smylf$D- zP~X%t5_B>B0nk2rCKw`XPc#LsM5JyS4fwnullQ5q+hdI?~*7gT;=+vd-L}%ZL_Kp)h-3Jeq55!F3xqIdfnPl!grC z*E1DhK%9oT%&2FgsWaxFc^^V;Wz0_0o}LN!SFet59qK=M@F3{c zVg?KrEbHJ#CcWo44@~_$OxuLd<7IK0bZK&e=|0xVK=V4?EGRNT#pw1z^dplo3k!Ka zJLv|Ut{CTmp1y-eI@)7qn0CO#F>v*P9=bu$&Rm}2SPwJd#$J%coT4=WS|vX;ET|^a z1TAZ=p8FaUm-{Weyb70ECT75?Ah!WsD)EBz#F&0!@-l~lEUL*-G5}eFq$ej> zKL?VDhO5Sl4TGf#bF`QNb)eihy0hq>shFsNa*rq-bLpJ8t#hE`#8BJ7K*!Nz1N||h z-|O*Qi0OUqE3wR@ZO;q|HA(%+{D|9%Q5R#>wzl@8P|+v*J5Ka-sD|O}c!94&6SF|E zeeMZRKF2I${z*YG%OS7hKzP+~C^yw+!Tb%iZ$nrEF)Qe%p$VXW`{CIy>8Ty!Co?oP z5g7Bv(uro5^Z49jWR__Wz966foYav)Qx0LdmocGhZ~^7FFii*ToAB|Fx{sMWUiSsR zhviaO(XD|tmWlVT3Q+2tJR4g6x3oRz)Sv%xM$zGgq zDsRHv(@4_dz7JtS@aO+WnCakzGv}>>8wGC^T|0a~O_N#nMj3f1Z&@siFnhv=l52-& z9iNyoqJ@CP`*#iRHGKcd9cN_6u}IzVmAcd6y3@Sd!`GdT)QyA-M^*}^!Ua>2g3EmH zrEtMZGwIRXnuXFOJzukj-_yfqADywRI!ot5i@QE5Sa$aF=6*;sr+PjZ$=-bJ=%=Ri zZ@l!%OLMsqQ`u@(;ahud>{-d$5YE~#e>jrWJY!aME2?;R&&@q6MfKsLdcNU6r05`T z&WBh_D_2UkhfB8e=EA5g|5oQp@vd<3uDiBf;eE$G$=dK~Vd=ZIH*4RnU$BM?H_w`) zS%q``-`TsGo&VP18;9ro^H1NLUe0b@&D%I1iq;>9Zr&cf<=+WMHX|gN%%B{;;+a52Grynm7SLOVnh_i9V5X~={ zaYVBVW-LFmIX{K!y|R$KFd44hx9r>>Wh!SK(PC!4G+eyth6U9C`M0*O6g7t7zq4_% z=7W)yeaE0m_~WO;`v&iB9wZb{e_45N)!wLmqki5R&a9g;MQxe0{omZXYInVU`1;{l z|J>6zrkCwiKeOfk%$a|$e#@JtIcL~a6Rm4n2)tJZZ|PxIHN1&Wt9fPLGcDL7%(h70 zcHXreGE!ChrfJz#9<8p2Ft)I(GP)5%Tf?pmpKRGNKd?~w`-4jZ;9?28Dpu>a{JTBx z?fJlbr!2Dbc%=TsO8sECevm&q!q*Q*>d&pzUkuk@jMV!fJmzB+YG-iSwQ1E=FxR}W zJz9VGqt=fa_}Ztx)N3#%i)oiqYYbEn>R1Ee1BwRdtZ2aAAe#vynQ&* z%&u;2UD~x;0r5*{_voGwYqMh z<@U%*Q&+gD>!ZeS)6M#x%okP0OYzp92FHW|=(9vT*A36C7s z{1{;^j}g}T7-4O3VWrgug;q$?amX`*T1PJ#OhDwAI1g-oB9;atW8cXEVc@W|ofX9i zZWh{s`xaha!^=1DGJ}`vctLhwnn~n^ajtd#YQ))c?a->hF*7=swpjbIVecv|8y$J&$gFK{VA)VX z->h@(%Z76LW|`Z%Y+&e{b3S|7u#vu{&l{EvRS9pltT(&nx+2+?5nB~+s1o|Etyxf` zHiC4K5J51XkUg4*B{ER{Y zWsw(@;3GvwL4FUZBQ#Jgegl1aJ+Q{N#MWvY=tS zoVv^Rkdej;HM)ViQ#HhNS}1KC#nyzM3`k@{QtK#PSr@8XLRvW-n?YQ33YScD3AE6t z#y8U^`L0k`Wzn+IyXyC(xt%Tf?~vo6Pqd7RT;z~ki1i3c`kgead}mV)YieOr4OMDk zuDc4TyA5%5Cn;H3Nd*gP zx2j%QQWErG#=zux&qPe~K+DMDRIpM+|1p5mLKI#TbIj{;!}33wS2ON&pd4r-3a_B^ zBieRgs_R?-*c|-ppR6%FjG~sN+Nj zGscp)0YS(k8U`ZiU4=-wAYQ%=mtaQ^f&>{+moPHrg{VQDB}U6&s#2CI%#Q)Jf--== zi5tPJx&$rE=FYaE18oP7oIFOP&R`)ph{e4;78q4h3S#JYkl3aK9A?NdiQ2JXwj7I` z+2Glv`Vc{aAmmZahaW0J&9$CsX<&#(PWDx)!3Ry<69gIvD69Y|4FhAtZdAU-*G+)C zDmJFF8ams$dpg>KnVkf=$Y2pf6=slvFCn8u;abZm#BE7EMO0?o+t^&U@NyF`$hnDj zE2j5Ck28(=r!JuKZVcRGps;a+(yao+py+_e0^cNvzPLaOs0aZpY7)VsF(W(WyWj`+ zNgn`GV#Z5N%=BV9R`c_x0VgFD=_Pt~&E>ndVKA z{F;cfmN(bNDScddZ`p6yS6ock#mwg|R76~jE3R!}*S5vsrOgr7z8NFxfOg;5J@>Mp z5y~uhtNup)+(aa^amMsBXWq@rtM&{&bLUd^vi-nnR?)4l`L0NDLnNz_w>N(3+#sq< zK5=G|a2w{IUbZ(ThJ%^A-8nP$t=74WZ|$F(dUyKf^gA!#+kfy*@7?|Vx3<39db4$& zUDy}i*t%S_n?L>ROwX#be7g3B%6iLo!^Ri)t`?P%NWGDwEsIa{g?sOV24Vj_S2p^;>A--N z8CUmiV_%T{(O%phiZQ zU1OjS;R_oUM)*T#KX$Q#swYmj;wqZkI`0S>zyQL9l8R*s0GhaV{{rtClGy166_PXu5jnBRUeFgQBZ|s}j!DrS(J6Rg z1npwA5Y7sU3gk$j&)A9qi2k3jP?^hG(PJ4dN^OQojD-6bJPv`#?nhDJng+jg1{9Ol zFta6M+j_126VRYyn~m}3FD{!ofmzui`3<0H<;{(&w!*o6i-u*}Hr}vJXt#XYZp{G6 z1LR&KU4@EkN33_!j-7CQ7^HPvYCWJ5e==|gX8Y7p6|1aDE$J=*%=GxwdfGFlb&4K@ zyr2A7LtGEk%e@C?#sHorzG4r8zKN|S(4_$=R&7?!o*-p|&&>Zx`+%Tj&4>L4Q z8I$EnVp3aN2cQ$=C@yZ>6U8m-CM3uh=nSMeFO`}JTo@}T;V1c{2B0Xx6JA++NTGpH z12oYXsPc%k5Ujlz=i}*cvbit$Jg~&ES3Z8mgazU0r=J+#0%{$_k&C} z>+$=8t{)imqe3FYG+Wa=;Ay|4)pk-)iX3WowO~CdP zSc?t_f;{0D!J=Lel4&SE!%h;(9yWapk2Z04Fs_DU;zk_C2b(zn;0m%KgOh^*vy%b7 zz`;T>O8MT&iE~ZCo+L?^Mo#1_BDjj;tmGju7ZsUSSju)DYwPcS8W!S*0Gr&~)gg%M zB~rEoa#k@p@FHN?f--R6DlBMoH%OhVOVGp(L7>_!L6RjxEYT=$1y_L=7voa`L;eeN z!yZh;R_?Fy=_hzWE++`3|2sbYEnZL_1)S{)K1cEL30{!r$IRHWxU$cM(d{l=lJT`p zp`dbm{}h6qK!$$?E`;Idm%SCb5n9Qs4(C<#HLa1n-Mk??erd5clC|Yp&uU(&O2|)b zxRNCe7&w9y@kR9u1B=b!#skZ?gR3}xG%doIzf%}KERp;=0YnR8n>m}$tG;WhCZXyV zo?bR@OAPg?$vU$qV#=RuxoavW;r1+@TQ+wlhJ&PCC39yY&N|S;+X^DKqHFC@Yx?W; z*XzI4c4-t^ zN~wl>c0(kfhRxMo;2Put9{#Rv|4-3&uZblwwUd65Tdaof^nS7C|=Rz7E+9*5DKo zSc=;V+sW9gjEF=5jR417g>5s8dze55L}3RdlbDMjQWVqYU^=NsL<9&^OC9$i1~iGg z_h7~*e|-rb9_pt#L|_SXJ3aY~2sw8TT>aP%Fn1Hydtl!qv=^qs{S4f%;3tEUf{KiE z&t#kFy=y8WU32Hs#$|JxO4rP)j%3zcI})vEe6@?Wmxc{x&|jTpbAkE8;j-q%qDcDQ zYh6SPu8&r2ik8>hH|p$lYXBLq6S`8C)RlyqOX^FKO!ZeVVn3NoB`;o%OMM6mE&iaF zNxE^6x}=4>ad8Zz;qWd?(PTpsBg;Unk(K0U<*29ZNwkmBJM>j-O!C8xO0@rh^kk#% zpkI8Fc%{`C=^KDmo_5Xa`eEHU*beg?^nTb9B}*@o>~}-=n$rO59^m`SlVU9J{YmR6 zYEq26QX#LQu~y^ZBHe7Oti~tc|s^j@#*!Lx5kchlaziIttO>IW6poRHQ5SIHhy!-=Pf(^(|$X5zivTHd>5h-h~h#Hc7$=v^j#M4%) zrHPY0Bv({FD%g%Ff)E$VxArmvCr)-WsAwsrSOj$?Dl`#HNFpJA!a}wU3NX~s+jao- zXo7r_k42*K%c?-{RiaD`W+)Y+0{hbvWmIiWq6l&ms|gu*qV!RQZVX<^@q(H&g$ROb zHtuimB8wnDz*nLd!>J?)BSa;JTvt$pf%Xe_P1{it0Zbs#<*p}@ScUQt&iY>840#%e5mv zv%!`i7@CzLfjmD&X(Vs<#rck9TOA1|>K=&BEiPDgZ6vGiT2FMx!N1FDUkEH`SI!T7 zoL$FfwO{Lj3r19!LZVpH7&6RjW{oZP&A?m18^QU8NM7^z^Og$3E$zIeuplCmNMW=xTZSpGmenJJ zq91qMlvlB=59~k^CS-OGOkGTw-eE9MW!mEslO`D@7m2CE0=wGsH)~2P!-yo@z_d>{ zju}t*(Kx|huQnA``0Lj9msvdwUH~SzZBwvDN$f(VN$c*>>!>!pm*6fHGrX*BI-8Sb zZ^~)i*Q}4o6Dfz<%XGH&^aBfw=ajuCdwP=CB04fs0;w2d%#J*4zoUSBKqULr2=C3(i)w*okO?Ve7 z=RxI0c$YkfdMIbQ51c7;wk~Wf;VoszjLILviySi(b0dor9~(MWaeA|5@$|A`zxwpX z+C1t3?C@EzA~6iblYWvYx&}_lX^q;Bb;UGBQVr2>T~!R#-Ij!~aY+!7(w5XR0UWQ$ zk2T;#hBeAAL&!eKPv$vnn(QyA@MJEgwnJRr!Oog*Njbu4&(vOsx?_U9FgZ2>OP(Ko zz%g{K^ay~v8{52Nmpn`(YIvt$o3WqlevSjsb5|jTUU@NdAf}rt0w)`yM_a8;O~HlPv7p29&i6-H4XrC_eS#r4NHxq?Y9VZ+QP3?6Mws zfG%s*Fg5RnZia3jS{#ZrbVSNJSITYXP}0m9N+|x~pqpBGkb~JS9r|?g zoQK8=8(#I}HX4U~m;4Q2TmeiVu!i%fM{QL%mH?{?y10SIpoYnzar(6rx$60Ybkc&* z;))ctuVI?f>?t{iWEfl5Umk7&zhlN(K5Mz9yKPkrR?^VR+Q2}BmbEKC2r#vPOqKk= zyuohRTq_N$YWOnNArFkHOen}RGQp6+ElH!#W{l{F8G}qTu>f1~$X#K^AZw}>veI^} zn~^@zAgCX@&^(a1;U6rOfQ1s+Q3JzE&sfki5yZ{^AAaEXd%Uodp4c2Q0HVN4w)K35 zjOIaOe@9Qp!2za)={(VQlo5L`#Vk;ZH_(~MJb>E|G%`Owv z8Ph{xGT;rsmK7Yzxc`b5^a~a%YzG|iTy0e3zLn_=mMXkqWr;}$@}5qJ&j(R`b|1QT zZ>=y&#>{9f2WEsR3atllXcBFBmU30T-AqJO{U! zotB?$vLeL@V;Iz0#>{l@6L$$-la-h*Fzhfy*#d5b;{FGOTSX4F5iZv>YvyXB3!u2d zm>O}G-*r~cpMVw1yrQ?R+_*BIwvYxQ`HUULN?7vV6G`7Rqlaf#(z+%nB(rm7%+d7R zxy*0+qIT!&N3I{?^SeIU@KNrv{a7?B7Z*F<+!uA_tY*Sah)CwvnS)V#_MG>F%ilaH(!0rK(>VY&( z<8ZB+^e)$z8ok8@wgZ6vfxi;!>}v;{2QxK4%G?jv4>K+8x%v-_HnnHye{9pj^N%x( za1EvtHcYKB`Qvym_H;e`eX(l5fgN}+ya+$Fq5WZeR&98vT<(e1^*|3Q3-q^*ltWEx z*)-5q(yTuEI-z2E0I>lF#~)~XY{HYg2J{GKaP-{QQy8W(hxBpW-?$#Xo=yX_ttpho zH3Rxonh4SS03I=owIF+iJ6VT>jjXVhpS7VIJlo$fP#@|FcY^z6({D*yVi*;S>rJS@ z->|;FtQ8BHaHIa1md%j4G^|^~2DB9Q0qb8O%xl&=dijZrD+b&E8`1?~{2ozgyeA4% z#FU*xC225838isJu0N^&nMpmH+cnR@>M9_QAGNk~XuXOxPwEz)bKilrOQB6@2=$tO zZI=OSrzAz0^5i;^pOmQOiHsDK`Bai2WnX8^7kbdN1>%uL_T(CR+&+Mk1GZeqg0fIz ze*h^#!kQB)!757uYMM~CAW%;86tdEi#T#0Z``|eZTgVp4{f{jcrG%1c>q}OU63AU4 zC6rDzLhNx;0ukiV>d_Ihw-^+3qk^rYehxEp5A>7j2M2TT>G>KT4&x=8;{7L>@C;Cnv zV-B1qu_cu-E;x~>f;y(=Gvu}pw6&k%cmq49pN1QVUY;vp#E9(8Hj?UrEu*5PLsIaV z!3TETI$P@kGf^9SH>o8=?j!J%x>{lWLO=K9k6HNGg}#L?`_PUqAa~lOG2E%EE3Li4-k@+;tscg`jgqg`d0xRjQ%^-Tgn8D4R^OM;Q?7a^FJ};&R ztxdwDhkJnBCk>3BfjL}Z-h)#faW3SBiJ;o#$A%W@+}!Xer-8I%W8F-`3>`2B%^L6V^cU|QzT>aOd3om6za*e1?~6syt6Fg+zQjF zESMXu=9awWz2RNS-5Ac@I3M`_20nLVBzOCa?Net-B1{x5w&jVls5OnLzy%YdtkT)i z`%aUid@WOxUxa{?H(r`K6wS?_>H5T(7X^rI`i=BxVa0sQ&H89r^@8^1i*vq(%?qQ8 z7vDR-@Z3^4U%V$;TseRI=8ou!d$619p*~ePT2kHaDHyrq-RUnv_dwsH95JnBeT!U z9iMHSZ<#+ee`uj?zGTt7wDnH;4|d+U5^n9|Gmd?kV|AR+uH|EDFi%|5n1R%#$ts!~ zm~XrJ%tC&+xP{Nyd_TkN*osl+ztwZ2=Z)SO+;$FCX^Q5T|6F6TIHCApfUfs?Z#1`b z?&6K}(V`7Nzk9B6-oLPOvF*Lx3yq81Qq7%~AJi|E+!^QdkAt1N`HY*kXhHdW`OTte zdF?{^cZ;9`iqpT;q&b{xdWiM&Y>horDwP4UW>zf~pV9oK#o%aJ1B-d29DM%HW&5ss zxdqtHZ%oV|59e0T*zP5PdBv$~={;BOT=u;AX4%4#aQ==py{;(zbE6R_qeh&^8gU+* zo-~i$^!=>Gkwm?9o&3GWf}la?YLY2WT6 z#si2X)q0jtw2v4M=sZ>9kqpJbvYfOK0b!9hC;Vt1qCExXR_bv|Gnq%sn>9r|l99nj zjK`KD9%(E7BgSJ-5szeeki@f24S~Tq^_oeib@$L1(o@9XjEg~9#+I9kw$ijSMGUS- zjY0g*ceeJnW zp1%~#>WN|213iEqtcKFVGnm+$&UdGb>+SG4z#3J&B!(Vo`GX% z*7qONuni&I8(Ow8q=#!$$N<;oP#Rn}g-md537O%#Ib<0}2vx$5-6A6+A=K6sp|-_^ z+8(k(*c~AoTz7`-aNQMhz_m4$4%giwBV6}{oN(P6a=~?9DB}%nC^M8D$_eGN`)?Wk zjdr?F)`784g$kja@?@(Cs#>!)`nBCYK+nj8CF_H|!L930yXf9qKO7(eFn$sHA!uv1 zsFI3$8M*=n*143F!I!Oc?y^3$6ik?RlKrZJ88T+yUItE4IDscIkgdaQz3qT77Oc=A z+ztVHjOeILn?#wSz#^7Vk!xbqjz$+8lk)S#+1{g?>*Nf+@MK9TYk&ZBQdhxGo-Bp+ z+CYjC>s=Wzq15$EcP|*eJJr_1RBzb?8|AM0tK(>u#lVJ4^h~a=q#i{zgShC}z)1QH zDW>>^TFF`HXbJsQ@|E;A%Gil-aP#_IA~9YpRZ#ugk>>D8pB*9EshSWdJOM`!r$}6w zob)O>uvk+_Ib{cKFBL_M1y#tfzov;62v%5S9j&)X(TGX9=!0$j9Wtzoe$a4-vVtbL zN!f@(UmQS>YSu${HwNTZPbOC+%_Q_6As+YHb6`JkYQ!_~gwd#VLq>?(`{DPdMqu-j z+siWUv#RlT0Q`-qCPhi|?|}XfYIy;xBUO2Pm-9;QMDs4U`Y@{A<8*zgh`8e9~4Ap_(IWci6rTI&* z5oM=I93iAskvqlbHKBj0b&LdRW#>-fP><9yimXa;3$4?#i8v^= za_L3CxV$BM3y44=7snAz4C8cODFZTHC{_hlw+*DrH{yD9IsfQrmgmlDE`H zXi8$B)O?1lhH`qSu%NAqZL0DyRfk%uj&eqDAz(*>b^HuaB^vGwTwqK4AL27g@PHmt zvYb2v)Qn?f9|ZCD~AQM5c5FS%EX)` z9eFtgtR55N&vae`#;VcWwRD;toT4LILZau}`C}Qd@SG3`4l@LENoZHwKM66;l_R8Qk)r5E+i*f z96AGv*~n)nj1#d8%5TOl&`5Jc<3f}}z`=?zyu$%!xngS6LiZ3f#{{gz!{$xcppF}? zNdv{Q;@@F@2#fo83ZWpz;uqu(rb`yU$(APp@C?V?5nt{p_;B&~G?7H?079Tl)kQo5 zJ2o8PGLa`_Ah8)1{YHrSZ&0KttpeS|rof|)CfJx!FhfKDj|4r9KsbV}t}T)m@VI)6 zwE-`U7Ba4Z|+z7c`!6n26s2ySR zYtlX_SXeTWjnllVbJ@@pjaxao)(5Ly)VpKZ&>4kwuroK#yz%^eTR3OqwO+{^*5WeE z_rV;m1tQEVRIEP5#X*uNE_=7cpckMUr zZ>P^+4CmMKh8#txG|W&MWGG!)y_;_dXIEW2st9DnKt`hlz?wM)b6LX1@B|Q3G$tm(t)}hnEdUqAC5?G!D#z12S4H)tsWz)-*Qsw}F4Y zh88!zq_hyQ3P@`WD~z^mT-(qXR4Po-f+bAKCQ{_4l;I79(Tr?dl76G~=NhxoHe-yM zOIOTQVRKcquxws?vnE=C%fZh?i*dR0&`NP5H2Q)s0t>-yop&z&DE}i5KXCFdN0(2# zS5AAvr#;If6a3^$k&&0bfV0H3ZMy%4w{9KqR=fcq>$vVD-tdhCIi>r#M$@M~rJFm6 z39Xbgg-ejXTrZAo{R zX@!MZu=QSA6E+t`EroN(?^;UviXBT8cPsWp3o6j78ocfaj~jV&k<`>{8Y?!Hl{8gu z{2$4Ld(N&L2A-DL%oQ3+@#Z&mF&+8x?PbC3EhZ8{sRQ%eZkWTE012+puu_z1+o$ zKQ6lY%pLznEq{KAKi2=F&<|?)(`TWi71o(_uy5XS<5JjM3d`7F3hd(b?Xw5IwHwUF zN2_)(!6{}x==`&uAN2gNm*0Klquu<|gZ#5+!cRZPpL(8uew^QM9;}z$0|REm3mX>P z?`=e*`R_OJwY!!w1zI=(t+GArg8$4~ITmlN5;;b^0fse0o+B*^^}K&g4^Q{Z`EyNc zMtFj%gDT#?X2NH)#!|!=H>_Fk#j3Fs^CeAdHhi&bEctUKYYuz{cs4w*6x4+a>K3*y zwnPfHtvS)drQ(r+9+^TKS@@i-v6S)5*0mgb$rWGn@FiaeRe;ZhV&X;kQVbq^(WbQ$ zd@0pfa>3}uS{c4nh`~1COQpub@a5ars_XYedT` zqcz*3b*-xvwSQFq-TH;`NX0JL+hyO4+i7-lxUJ`bsacr8#VvxTgAZ8*f8c!hPEb}$ zj0dN2f2x2^qlljHQ(N)|RX2<@_=^K_t56HOL)Y8M6=$SLEd~N~jstkBg(8NR0K6?^ z3$EiTp;XHvEgP{WtPwC=BrUT&L2R(slS0X*Ix;>%JXSbK4u)p@O{d{5QE!?rZQA5B zO`Ej<^k&n&W}ju+qSffoP;s^pQh5bKL{2FN1c|$_betn(1qwKI70eTfZHQJir)?9Z z5GGR*1^~iv&QN?9JE2A&$$+px9kOAptVIzkNKtqeoa(}#v{*ZBm$wVao3UV0BAJ0= zbJ$#34p@pWp=Fc}PlFj?F_-kNG=K60b)T=&hK{e)D+*L%O#ezT7OKSP{7Nwv!C7Zw zkE8WC?UI+E=yKb1Mkr%^18oKQF8OC#U_p*8A>2jFH9tZ9mrChpJppx@L=DTy{-+c@ ziFy2<1k34FavA9pEh{WNW~}cITOqQ!^AJllrIqh&c_=$>Ye?oeZpljc(X?POI*032 zl%^t-BS85P7XKS5nR=-oA*hE9XbIUst80~9Xa2K{X31^5ZDL04GMM@Gvg_6`&$eEPpmXK4VBq5S0 z9dMLFJLGdFMGb6)@;j8q!{*ap41vQiYFS2Hw52+d0njLcf zi>#T9@m;iM%XiwtK!#KD_TA8*wE%h9L%x7qwv5~bUItu@-FwTdCAT1i(}*OROR3$O({7X>^+L zM@>T~Td=s@MN2$g5;#bsVhDNpby;KC;@+-9;Z1yAZ$eaD3bgeXxP`X2p zP&%~mL4M===#zYB+tkXOur*PtANGK;zUn2`pCB-eo8Ib2i%^bgqRgq22ExL4gf$N+5XWJfc$uYf>hawMSRX zm>R`*19c|u)`C4} zyHG{d1oWu32A_b2gc2c4OQ7<{FM$i05w2e)OW012n<1q66=jEXmB-BwAxhp1`Xx?r zdc|*Q0@l!Be83(`){@A$*7)j4wP3FHCG5sa;;?&_E{N$1IDrJV^{Awf@ zFr4C4#c|90;`l$%2C+0v@k7P);Cv!3ud(zYnB+;)hm00o5hY!^Z#ehlMHU zdN3mv|0o;~PvBIB05|1%;M^~{>_rDo3&DccF*uIr3N6Hq>y6D?jCAaWG;6a^ONEcoJHlyKirX9;g9O+bG%-a+w?%+5$uY-V_zzm>bW9X6O|_*QP6>r(#i<}^LDF6vo*TjS<3%czka;U`a4s-}<%^j?03c5s zo|nx%kC!35ynq+d_>#c`S@Z_7D==XKRGea7H8wFasdz=`Tg*xYYcQOE<6F$8;Ag}# zLd*_l5yE+d!-1hG&da?Ff&8UV5N6$<0C{>2^N5${VU67Xlv#v4nVsKw>6Mq}awDd) zc=HF-k=$)FHqmW`S96LH_B7uR&S{>pq800g z8x1R&72(W^`CV{a+hTS&bK8swHq~e6y?I$aXk)>;*am0BN3wQ+?S=d@getA%*M#$H z7Rr{YBl!nr9RH_z(@)Ji@8xWqeP$(RBVelLgK%7X&b}GzJzM@<=UrPxG_U---HSVy zEBA!+_FO*{brjBx-gRt5=c7yc%asShc?Yf^x<_8k%av{6yf(0A2RSLeIks?oxwJW) z)eKhga!XfotHBt+f;E!6WyXg4|10N@EIT)mv*VuO2i?o|v*hqNzH)1%cv~cEJ8$0( z!Sai4?LcVLN>P0njt1#nYKs)@i{$OcTFJkahlr__g4%FF?LxznHc|kY-wjoiTX3uT z-G-YDD}{C8!n%dMOXZQmJ(1kKShIzxB2=+Lv1XXmSqkns3+Fr$=Y~&}r@fAxfim&cG#F`VdC38pLZEG*#*(; zJT!93Gei8@bG&R)N-2-+pQK&?k9itNA57(|+ffkKBL$?46^0=?Q+|+2#B* zt7X-(A~kK1vIA>DLvjLZmkk;KO2G+q0P-k7Y*t>;TnC)HmQxSVkBmmZyFg0b zo8Gr47LJEYn`dp&+Qud0N7Z5G#H<6FA)|1vX8!P^E!x=w_$05AR7K0;j^*s#2~}}IEvNkkP2G;gi{aW` zP{b4c;r4+rb8^;!U8;OO<1XNaN-Ji&;FVg=FoJmsE7%5^dvT#>sqW7CXlCiWb>YZT z@twQUcHa|@4g_NpP%lAuaFu^FI06pJ=%xt9fCs2h9c8}4Q`trk}D)u;HUhxt=1&vLv!#81Bz_P-36FF&Bh z;MS%)fVYhY5hhmDj?X#mx~LdO-QBDP62}?-xpVxPQGWCi1h^uQ9?%^`z}Xcop}MT? zK;jNvCyB3%#CM3qcSwq_{%%$yiSIf7`7!>vaen*?&;+#yFV53O8OF~5f|*aW>LXERh&eUkvi0!&%X^V*x~ zb4AhoqIYdK(P&3iF?Ofg+trJ8;kvy`FY+ZvpfU@NViN%Vj|9>~pfm`yQ2+hQOTqAt z!x+e<$3WJ^Ky?ePVxXTK{p&uy`%_`ZoZ%y^V{-ID!)wt5JeL;uN?(8;K-dFowqEs-mBRXt=qAb7OvYLt>3wn z7p`xM)^3Ydw?x4M9xCfU&#;u|eW|xP^6!I@o6Om+xg&FX`I>!v=Kebc%l0GC7vKYq zH?#viBEK9MI~;Mk=lY%%TT$3nGbT)})4A%(d2@KK{Ed;hbIY!cZ_~n!FFds2 zOpYeHcOSg0=2)KB>Q_s5KDS5z(_Ae)|1{44?myjQgy+B4>f!nC4VIxC{ogzG4!QLI zU`-p!*ZhOa2rmDSqsQm`ilJ70Fs=D49>+EeiAz5T`u9x6BSZg8+&#rI6sq(SeyU4d zj|u$)c%NiFW&P9=PF7S}bXCclaMI#03GnxbIaIBWUmf7j=raP~FHHjcnS5pm@Rym; zRPg#w2Jn}YoHu|Xg5Zrm0Q`NG+6KrIUHX(_bbO^4b5&wY|4K0efF~)B&aV_B*aS(6 z(S?@U&*Sb5urDuII^h`ECMv6d^TCokuBwxtVgbE`)*!vZva|%Rq>H@-?x$iEL-H5k zex)MZuXg=!T=ff*0eepZ_hV@8rvXy|tox1hja;$pSpaw;5H{$uxp4qLhhRk+>=K+ZrlO0NSq8@8E0`8>C|)z$iIIJjF!4XK#DC6pt8#-mQSP zCIDO4Nl*OR%0fzbry4KW4k;wUJJCXT!jFyv3V0`zBIi3oWq~G2&h8}rm`Q+Xs^w%? zs4T=JQa%D*YlulsQHF!joUaS-RLjq9sF_4~=f8}aQNlabYGx1AOd`DVUq;Oojc=hn zTfRfTErqjz_R41iX@S4IwizJA4+9fQFlTljyC0+pJY(poTY9!Fqyr9m;2r(#gR(q8 zq>Uov2V~fnC&rm0+1vMjYQw{p35_al2FUm!N;LKmB&tu3Kq}FY^#u%ag}V zlGkq7h;jIRaT5(lX8UO@>=8NNw|m|f!I{82)|Doq=GOHGd0kGSkCuS|I-jB`1LX0= z>2iqaAa#-Nv}BOe4(dAINuT69do<8Z-$Layodn3EEL-(HzO+C9pdHiYFO`4wI$=pX zN&x6rjZ*?v9-BfG%K_BBC=X>~e4bGbPjMQ1yPmUvv|QB2db-b#1hK}@C|Nkn+Uo` zy^L?l?`MLS$KdQM$YTowh&iTZ(Sc>v~9E}|-lqKP6t{M=cLAJ>BgJRgfs9=wd; z}FG6jSUq8BLEhThd~G z5Fv(e1YF1*`l6XE#Sx;23lYw@_$^HN2)+{3$OJqA0EPfY2v&qNH>oQG_@o4kaId28 zZ{y{0KqB1Nkzxiev24K&x!FXUXp3h=;BWc{B2B>5er!b=CNOZa~(07=Xm1(4*^xN|f_AW1x8 zBr8_{g3KQd=QT&NHUR+SpCgFm*N6^*#YubitoxgL;Be*P-`hvl@miN)Nv}N#wxS9o z>Cyj^fh6q&0-;<%1t3Wqtl$5#fFy9Za?+yY+C}4I0!e;V;E`Un-1QkX`BiZ0NM7}< zAqr1!06WbZ5!4Wm9jV;3SQFmR%G;S}dHup@xE#<%rO}GUMRT}fCvPviCmi1iU}pNsgD1G;XKz~RIM2GtUHR!*ShuCs+C(98Fo<$H(u;fp-? zA|IL#b1(51G3uvu1lp2EkS*=HBP5-k#B>JK(m8I>R8&GGujUla?R;bC3ynwX$ePvO zD=MBvbl806cYLcA+ZRKTiuTzf5)!J9Kk*!Yd}ulQ1<``lt$oq_l4y4Ey!OV?Sv)m0 zuWUZ^4IfzFq41_OfbrNhc@^`!7wt>u?gaV%=cBn5h+SGb19+g^Bij28Q$~*#NJ`7! zJ#+KS+s`kwfyt&>E1+EdC-&u6jn(=8751f4ld)mNRU3BIMhh#WMH{2VHE_&sedd4F z7~rU=gL>@4YrXmegv`YJ7{cVP@W-DEGtWn>_b!bt4c@uRR~`S-sB`pd5mZ(lEhvo^ zH-Vk6Xjxsfq;~EZ?25N*7WYT$4n|7aS4w)qB|ZGWGki%;q~!EU$#A%2I8x$4^I_?) zbxTk-?Oy2-23%EE6~Er&k7A^O`4zWH^TFOHt6B`KbtDf*6M=} z0oBqh$oIn<#m@oHdieX|K3tWDJEMBxIy9!7&nuBVe*rg=2_`* zrT0O+!yl;FP(mg@wS!ft3T+yXk8;wG@|~3|TfpM6dWbZzmSZXa5>R%U--$maN-ahU zgttCMc-#7iwm@ve{t7Lz+M%$lbimIe<}n>&b&7d(!E=U`--p*n`~gmCHJ~^cN1=d) zBZV;)Hk-CFEYrXe%o$L}PV1)i0FTKb{<&m_zK+d<15on8riyB-5Or<=(2&DrpwtB+ z11Y;?EljI|6P^}hGPqvsfuq86Y0_uh2nASYRUkb|ZGP*M#Xv-+A zq8FA^H~9`_*dV{rC)zjFcGR;HY=Wi^aES_b1LTSHMOAx6m24HQTgYpbqF;e!vBaOe zjL2@JY0$fBAFHOm5O>Wx#@n@2=?_Pk)CF5Q$h;q?_6#1=8=Ufb0zusD3uo1%D`+u= z>A0|Q#52LY0pD=2;RtGBMXz?)!~klEDcEa5V}tiglN{_$W2x4YOzplze)IqQ@XvxK zapN^n8a&WigJxlyHf(D|rA08ki)Mgv`@r!#;P@b&Six?pIxQH&oSl(xKf^5i30|%um80r(m9L5&sybA4w5tDsp$Vwo)5pv{P~71D z$e6>TdzBZQ_+7&j|#A`fay0OWaxgX4%Pn_%Siy1G2 zabXYHodR~1!FIuze-gDU?f_Aj#4H}z4C(<(`JR};@9~@`J7>7RzvkD3vvLPjHaw>3PD!|=@)*`B}m>G&hOd0!eSRvX1 z;dD;BFm4SIYVJP*K|6%-=RzZZWnHrd_JkDOD4KgdlF@MO@F%vMRkMRn-?MaX+1v?W z*;^)_*#uh?BF@dcc{7aq>8{tGz5eXCp1al=HCVsV^GeTb{<5JE=1E2ydD}79IzJvR zYT*kvFIMmw+m;R6X~3OxhvzSci<`kF<)V(y*s^TcO1<`duV$rUC(NLh3hx{V?>ZK# zIKEOb7_I=Dp64SKUcTJN`zHB}3(JO!lw|LG;X+Tid>7BOE>+#JezZ6I)M(4bhL$8STstfFCb(ez$M& z(%s6vH}>Ay3;R#Z>95ZfYNPQP;nvr! z*R8YL-`am;e&0PF(zT)4s4*`XUi{QrQK7@_S4 zv$NoMN;inNyk~XyEk=79%vz0UTrYH>2bN~~VGYBp^ux}Ie_tS5P)a6rFxKD#zztK$uIByg~lW~mvr zS_PW-z=64~V$`tfbssMONWXM=BKf=paVqmk#~vaVVTgpE8ERy0%D zf=J3)2~7j9w9kNCBteW>2Me+$LZ7i7;qJn_LMqe?vM0??oR1B|QXinr6yqRL8N8F@ z_1K(Srue*~%kA9vaR7iH(Ay*+n%7drK1zCX#HhSCvu-HOvnnn zs`el$hG~1q&K8K4t88o`0ca`nRGb_mH^>$#(o_j?39K!#PFCY}PCEds;TUIGV)KSI(A%Y!IuCEd|-V1P=1F5xagxp>#F*mz56m1gHW`FyR}q1*&KsLee3s z0NPdOEoG0zTL6%~emg&`AB0?fMgC+79p<3bG}mA=)Fp%Y#*zOf{MZWGqi=7ZGYOEO zD+QZS!QZngmpH^lcgo($aw~m7;f-3fL3xWlPXTPfOAOCY>o(FVtkN`1{DC;}2SKTu zZb;mhEGz&*&VGlwBO;GX?V7s)c^iJ z_TB|Pj^n%&9AIWJPhe&+gEv47-T?8S_-00<+2*UNN`G#&@9|{}S4gV>x#09u0ID&+_)DJ?(D1$>ktfz9{tF z{qFx&cXjs+8sI~A>^R#5yL-B;s;jH2zpC&5y#|}~Ev*)uJovO&HxD0|P3Lq({1dc9 z*hgz6E^5@pGD(-akHqbDIXWwU7xm|tXs4O_U!$J`v}4lYAJ7>w*0N>)59y3d`C;##o9v|Of zPXV*ldn)1Rh2x#RUPKmSFBc-VP3b%Bz_}t_vxwhEArMcQLa)hO5tii(;*ET0%Tn#} zDJ;F~5-e8DIc~5%o`UhGVS?%?1`ohPh%ifaNLCTeo|)+1!!lo%%kq?gqSy9b-a8kl zj0P&Fo{9xl&U8fs8^;}s8t^Y+o37T?in=;YFi77rT{!d9t-5!QNRJkIeY4KXbdHdg_IlmDgX4Rjj*RSu^dL zY5q!9ta3dhVADOE`QMO5%?^_7XoL7gRkGuUZ+eXpC2`h?m#LgEjcc?)@|; z+Yy+rH96d`Ixab0amDR!VufCzM$K;=ccU7RJtsHCdx6fc9{H1Fe{^haZEJLG>#ftV zwVmURxGON3@t$j$&eW5bD?dy@-*XjBWqq|~Zq@cE{({^8qdO-aD4E#)TKna82;aWC zb8bybbWKYv&^q4wBTpb+Q9a!dtyp(;$ITrRj<~mYYU5P>^s$>&e_sE!`ddAJ+9Yjk zmx8;a-rW=K4>fC2;(%E=nt2oAw*aqp<&2knl9^%6hqwrFGzKpXzC3hsXWU{-2{G}3 zE1Qf`T`^CkWUu_tg^&$RH*Mdq`C!rt^jqUE4bsd*1$7LQUsPaJYY}hx_w=@?- z25pQBB4xdYTzs#m|HCM_cpsfw=cNWlc$P+f^9o;k#E^lew+uGGCyAxBYz}-G7 z2@@G|8lx#4N>xlRg=trkz74qeZ=nI$^a58rxws^Lk9OLGq0JC%av+Ab&XP*=-Ml%- z<4TB_1R~NhdCrnYw>l+6@xZ?#0{teEEfx?;`vMVH2&9gEfi5e)KsVf*ytd`?mMf25 z+v5H+b`_P<@;ZA(k4U}36mtR<(&jO`OI$JYSIpcw%D#dXu2o^H*sr7#jTu() z&xt}Zb>0+i^%ULsChZIuPVpVO@on0@ja~S0^-;<^Pdr|dM_|o=vFiSIN$vs@#OUPrzy?x3U_8SC2HPalXUZjN9b7b?m01 znN29a946DNTR6Bd))(hFQx+4%Y0CU0QzzD#O1G>MtTB~tVk&uEG_P+mQu8tu!w`n= z(HR>ZRJ#x@R%rb3OCcT(Ahm@btdc^)f45Jk2kPaS`4m>k!?~a30j9iXfd6BU> z-aLx5H`x~TRE%40d-Gm%Uv^I(iF&KXt&1{!DraW_XJJa=!@X4D3>I zDcsT(HC^5@vjhB_Juta(vVQ8=O#MuWl)o)%ho3fM_A1?HzvIjqU-k*4ZUuO6uU`&Q zcyZT{T>dyX^JG>s?paQvIfXYjzI$}`*^_h6o{l~XMa}19&z_rgotG@<3bg5L{SYwDL!-+KUo4RzgP)^y5c(419glCvr)%Lkp6g zh8lDlO&NlXW9Zfb;|O8;Qn@pAvV>mEQcJEe3!(~H2GnI2 ze?IhdT#>XS-qy0z>+b@ZD}xIAgSR`iKHhjY?o{uS?V>GfqJP9T#V|iEjI+8kPYb@8P}%^ek^eV ze1U;cLSGNtVsd%Jzs5sGLZ<-B$LF3gP6ECo3RJgFUU#V*laXpx+mQshBa86B7c1KH^ zWJ;_JC}fd#RrnQkea4q1dh@l;;STerj-@B@$k>RP5! zz5Zt2thEL zt{-{wm{G^7nYA-D(#DQ=&r3(2`0fkuo{@5oNze4ndQaTWtCniEe&6;(*LPh~*E6wQ z-LrYm&aYdjt3AJ(uE7^1L?*C{$uRlzCau@b%N}Z{&q==Zke_631ZanFv9~~poP-3>V_*4=oqxXPYkS^x#pSb*Ui-GDwcskMsd~-$QxTzO1XzDAz~5pwH2#?l`z&j7P%#Gv{Q=+j%(>y;VP?rCcjwntskycNPe+K znZ$PNKitt(yS&a&P@o$|Y$lWEfcG$T)Jae`^%&2C3YHMA&qcgM#iQZ;-rg z?-sqAHETIS)@P7bUg?-x84Xlj+;_(on0W3zTRw5>$y}+<53MRWl)TNi_S|}O*3zk# zb8(;U?%JEKnHOg*ZAk(PsE?Asf^qg>>7yl$@nw*~!lCU)Rx`;eEMVf@#yRpE=%1$+hAQUcPJ<&55>SO9YJ%5M`>Vj9HPU>%3ZZZ9ulq-zvRvIq8n(L#-5esYBNEk(GZy9Vv(zi}#hJA{2ad zYjDTm&d%13uI?7ZnG1GN&XvK|ju!SjIXjnlrUWor_&`1Wx@F0Nq5qG-6smfSrsBl_ zU{3Nv!CtjB_O`Zl1)21jzkYBqxW9unJKk@wiD$y1f@KQlD*>p`F>)|JBx`Hld#E+s zsg}C4bN}H3!R=4Ty@$O#VW{d?y-ixrsF_r}Sx(Nb%Va$bgQSB^L!()+m{W7`<+#zf z;rFPMlY3OVqX*X)6iUfb)WjA^qv`4`_8?skE|x@9-ZM_X2B6{dsh$QSlXZza@M?s< zp3{hsNndKsy~@y4HE1;0YI=@?wd)x@(Kqy2%BrV0k*%FzNP{)29|=;Z&Kjk*gKxh% zdID^5&!7_kB4nyZrBO`J;d2d`>UL_thClnkM7vd1C1V(jux^6k-qIi~w&!C71 zMsnygSt=!-B|R6zwpXYTvhYVIw+3$L8-%+{VZ!ok(_q~V5uVFjT zc(2j7!o5C#U&A(#9h2!BLHCU>7>P*&*5KfR`?9DtX*R{znWBSDUxhjeBCQ%CH%L-f zreuuJmpDx-KAgv==#aXiki9-xnc`5ww;-%oBCy~RzBAdvlU8VTu2hLFlf;r*aKbt` zd=84gOaY8ZWtpBZN&`t){ zvKPLQ_@9yX4~R``#0l6myMf*=5FTGXiBlCot!VmW%)4&RyCv$~67xQKt2*l4b+LWk z;>xsNZ2hPZ26=HF&V_WE(_9g72qRhIoDli7;fz^7zE;)Io~3K9PYiV`(OTjpVESq&Y2^xqNYCj=13C5 zbkaos1j{uuq*M2J#?vq23ATt$L1=7ph!6Z`8mD4_pBXcRZ((fG2K6PVcHwz;q-QLj z;9|;7Igt!mxyy**jp)1%ywa-X%v5tSDdRnJ9#?aEC@0v=(|9VgNxNs>i(o{}MydMY zKY_RVl{CohEb@Ed+>djnmy7cN&Us5IXZLO?mwOgR39I3 zGMx6xezaV=5&O=^FM}Wq*n9214kv7gm^CX%4aN`}HP^;q!<$Ce;nkZDv^Ta631^34 zBrC^hZ{FQ}w6XcPIC?@C#S&~dNzu57Wi$)KRGmnSIH-l3lg18_I+B{12)A~N8PufQ zTZ}_W;+j(nRF`eZH;g%<&%yyIc|Q~9bMG=f1IM!{r?UiyvlM58o?V2aS%Q;Ewrfi6 z6XW&YFP?L`KaRz?cAQ<{Tz~ozop$RO7{$QKc-c-95eYAojps}hz*mYCx48yM3#b4 zD}d3DU3Afpi>aWJlJtc+*Fkcor=fA4O!;^ZrDWEpcX5`m;gQ|q@VP`Le|9%B zM>1eQbHFR9>clEnE&hKfCLN6z`faY?vCwu@72?gCf-p@2C+8v!)aJAxR8a8(-fVD) zFcO3+$jdMO#I(}t8aKyt;2!kyeuUj@Z&CGzgX|^f9FrJea&&`J_ zM2>UZfxDb3{Mh7nY#QHrC%0s>=kh?jAUNf_S{UEn{%+Z~mQS9WdhTj?u5?wjbk)qJ z+0sX)&MwmV%^Kf_61;g6&s_>n9(~VK6^9E^I5Di8^VUbb_0#*__il>&s9uGW&%ftm zw`-!_a@o`2bmRNp4gB`Gn6H|0*G9c?gt$EFMJ(m_y&LBXP^kxtKS$UEMalX|`zHt>g19N&ruS`IG+1 zgI9CsGwC;!LUJY$z4P*kc@O>eYESghZ@5Y&-M>+9!>=y$OxGdSg+>b#yeUqZi^nKH|!DyEKKubD5U z-$ANh!F(Cc|8JAe;hJww!+4lSAOiz?v+TG(4?azA`vMb3uauw*0~tRtS==_n-nGK^ z9^XQ^{$4!(bEEyeryB_nUxdxSi(-C}Z2r>hH-i9vUaAl2kPe%PUBi(%%OSb3@|&@! zg6$dut4BX_ShJlUS?xrF>_mg?oCe7_qrzM*Fj-$AQy@|TO~M)JqbC&cCw8cj zJRAXXm^3e|Mk**AXIYHM80fOmq9xuM3KpkaukbYo_ z0yN`c+}Ieg8k$C`#*Dy;GK^A?vI~)~w=hg-Lpp8;kFvMmieO*z392>45>H_IXh~0C zSAZzm*%O>gK0)PnMQUX5!IX*#-v%V zn z_p7)h3&N#ZcfbOOAig%PWna)PNXwi1pk=* zMf)LmqNSr|OHfxh+K7rQoQ~m|l%`{@{@=L0uSY!I*FQ2kbTT}k5`BUtmAP1h67_DA zk^}i9i3f@Ph|uI+A)tk@JBV!BT!`%b{p08U@wkkKT*kMESZKMH55k7t~@ z9OlKVhZ{*0$XK|rkHb=mOu8x*UYGPn^j1r{n37++D;Q312!`tyxY zT6v5M?O^;XI5fsfk~1us)BGl#5!coweu{nF0u#@Fpc1rckFjb#WJ*{DfKzLlo8p_4 zkg;>aboLumFw@L;q&SffpQiidjv`?jJqy>UeZ*wVClx4cU-xXl~M9&&JIbya31UcU<|CEm2oc&D1#SZqhQ1MGM)5+J@<; zqqUonX)Nlly5lOC?25X|)J#pY?iE_57o&yjLS5tZP_%9R*A@i}FA;PlGsf0~(**?>jN0vpv2 zJ-L&PsWnnwZOl_A+3P<2&=vR?Oi^*d}P6?h%pr}7e+!xZK8%_^Hw#dTg{nCbI(0< z9#?Z_sRB^NIp;m|k^uDQC&u$)iRRN5g=|7rb%rq^?_dJyC53{Ru$R2A6B=e~o<*?4 z0imP~hLzf)G@wklIb#_wW?;f)N8N;*JKBOQ3nyHmZ=|Px5MdeN;yl>d7m{_eh8o-9 z&WF5TLnlpPiFx=*@k1iy|AlsJGEo^Q@yB$H5K!^I(-~v0{!cn1pi}%i+7Zqv{$I2s zl(UPoRfOWT$&)KhIvsV+R2FMdl#tCWY+A9UPAgQZxQBNCFYVYYLfu^OPBadVz_9mvblAz2{@M7l3;5?e#HV z`BI=>s;2-@;dowAyr3Mm2>H&Rn9N@CndfkQ`iT>IHc*0L^mw6d#G+)_Mmg?=s?ZhJ zw)=OiF@5WiW-|_NuW_`R%x`bq(6ZV5&e}Abzq8qjJ=|D_FfvI!vKM*OjqqtkXYVdX zXMe%A4P*CkFKtLk!Ax={CCOnlg!ZYMVScNiGC4`Er0+MsP4|2&DYFQ0tSo@R60)vk zz`lWB0aFADmjRWT^hn0@X>g01K9){a1MEBl$~tgq%+ASr!>XQJLDvk}Yp_XB(IF}i zRxJQklqF~%c?No2W$hSi;z?~A&0#ZReT<%%3kwH^53(m(@A1UE2Y8|b@90~+MyRoA z>H8Wz7xI~lYG9h=5lUl92S`CHWfKZG?Y~j@mU181W>E)(OubM8?*ld_jwG@MdqN}K zr@%Um9PjHHNw}4p!?5KQ6BZcb_9mQt;AaFmyN|$`b65h>_we`c9ng$4zL&EM5 zbPT$C1Odb{M85!AVe$|Vg(2(>HKjb;d!~PA6bpI?WaL`#n4H9oAO^$PgiXmWX5lMN z*m=6JGt{Kk%PWk^`qAA=ar#n3ih>~K(Jq^IouJroM{Dj0^@?6x==n`tP`M?AZ^TQ$ z@ee956Bg*nkrpPO2bfPD`SmH0_P!0tyd-9kP}3+ZUb=f=<~+g*R9l07CAUU0hKkIs zN4q+kcXZXZ9@xKQmuzoW+p+&hZC$W&LFT%;73&@$t@qRxRbP#9wwzK1HQjE#)D^lA zBx^;@`g{9^LVdCpHYo(D#jxaDfe;oYls;9zm27v^3^L=1X5zk-74>vAfhKwVbM$)H z%5L<$^^5HmZCFxA@S(J@1C9DXOV2t&D@yHz)B&l!>#tHL$d)2%)d+Iez1gEdKGZaW zja6%cr-nzxkY;wF_FXtvEog81zV@!*+DF)ha3&venqvjHjNXXeO?bpUjIxn#of5lYCOk2#q`QqN&&}=TgTs-G1i~7o@8fKg^-{y}w8hr!l2*6Ro6>ftS?7HYpZyb)!|K2zxf|t+`*r0wP)@eV zUy0DP*()U9BQt3;^>eFsL|5&It!kB4wgG9_iE{idDhDo&A&b?@F_LH$PHv0WZJFQ}ij0zUGwu1$61#ZOP% z8C_`za+(Tq&nSsnP!bZ!baV8%$+D6q64&B#V5bx&di{1kEn{nFT;ZerBx>axVLYeGC0Or#goXdzFZS znvy-9scy7JlZOvF*Kqj!f;kkUl{gV zvsk`JDxJ#wB4Q)G4LB@f2pbZdM(b`3eF~$_%&wepFqd*>C@8{eT;;RU$jHF``)S~X zmh``hAUUdIuL1H3oU&o1Z-ccph9_-f4qa+hm?Ph#eZ(Ox;8*=J}2-L{?+Np(xL7>JGVOhj! z6aHD@e_f55XMoczEPuddg2~fyD+6t%WHtPUC;09p1zS^(~c4Mz-H|i zKjU>8*vhYHM}(AAz8-!Q>V)MH7Z8DZv~UBjC7cgx?``m8{){oV?pu0Pdk#Ot`#0Wb z;=R7sG?s~WYUX#fVqaKf5eZsJ-}0F#|RV8ch~VNue@Y2IZOik$8)0{A7Hehw^l2o zVNLRAsDKj26Q)0DZcX(f!U&ZX0}H2iyIwS2v9ZAZf0Kh!%oD)b?2BaFEJ zH;gtp+6_|yO2g05)t~`Rg`MDHYi5CC3^)*r&$(|F#FBy!$lmAlcVw>^+8RxHfQDf( zYaLa^>fYgQP$k*x!vd7T*e(YT#5yn4ZHC@CLCZay6DZmB;&Ma31kFqPCAfDnh6-rR zb4ShId%Gl%ZFHK+&w|DlE6tr`rlWRpvWmf=di{Zr6(|hw9>D^ewQD##6^DTmflaZK zN4!^J@WWYpOJle}aVFB+TYgHisnqDN2`lr-cQ-@!UYIaCq_vq7j13L(PKEgx9 z)pVf&w-V{QdlHro*sy&iy{k=Zq)QpH_$guSJKx(kC|;+u1R28lB8#mX?17dD7fC@v z1-~;vaRLbv-ogHG-w^(~p}Y36r~oo%_F`_*bD*eSIVB zlNC488#BomV&|FeHus!83&4$u(^fn`Bs5V`=g$nvFBxhaK5+tYoG{#r7_&noO`)?e z5Eoc+X9kd0w)~jhQ-sY&WQ}UDhcPAp7W^k z(8-Zg;$C_U3tnsxrWL}#Xo#?IR=-dH6;lXB(n01~J<8J+BsL^IiIPqd-}*Xu#zpv6 zU`vQAr3aCA!YSgMe|yxwJ?3wDHzVrr0HlO?z&iZtt05`7JeIu~_>pWO5B>H0Ldg<{ zXL>HSec;NA+w#c!U z{=tjeFOEemIk)o*?=K-3eV>0~?PbTDw=C){o8Ee>F}TdJCo|6V0z=edx)*?aq32_odwv&%gH4<(Fc4wR3qZqIoN3T(P_@ zG51ygfbqg*@scXUbrz(;dV~=6I^kWw;rzsF$_~6%c)9RO@p#7VOwX&&TzY1*4Q3db z>*h@!M`zm2>NtvqW6#Nq=}k8ekMEgv??7`Gl)jO9H52ip^H+|y#|taw3hSZf^s&jA z*_k$x5ieOb;fx2Xr=Gj+mty#*528Hd%co6D{-UmYkB?Ma!;*rq+CU)AdbX zdMQ@4VrKb!MO$vyHQYEf6Zn(DKPr5yJ65-Ka`&h6HstxlZ7Q#tdJ=Jco|);4m2H^F zitji$f!>yFU83Gi?|UEnD2E>Y_b#gSe7VWt(KQbgpyD}4)i&q@N~*QNr}Lh)oRS~q z7vKH+B9@uGxGcN=-lk^9{u1+FS+?!Z&G>=22IoJ_P2XSW_+h@4&P%NOt8L*-4iNQ> zl4>+4g1Qlgf-l|llmGRXgbqEV{95(x_fYa{xOm{J^@oyQ>*e28!8Vow#0fHLu@J5h z>u3lVwJ!S^{{*W*SAX@PvsrSfS;`rs}sEWL~zE%Ka*>Np?i;S@qC+fdtw zLKO4XKBot#0+yeVjA&7mMP>y|tP>DS_e(Y<6TI|7JV_z>cVBaV-VZTCnq(AKs3;}B3 z4l9Xk! zQhsmL{}wh~)8UY>ybj(qhZT+809k>gY!OuzA4mZe#5N;G{3EI>ePJ`NFFzW2aTK=j zyJMNc)<`D9ZL%~)Z1mHkKvK*2GqoefE-?1Dja2aKT1|2NvDYm(nsr!&RoKqZpx+*; zOP)9$rh}Me6f-d1D^k*jUGFs2%aH zdvEOHBOKU}7g)tc?LI#PCek8w+}N+{5AVPxepfpR9W=nkd?PCEh4KtADgGw-?q4*S zj$x5GmObVlY33zG+z}R%d|(H^q8&$6)QOieuv5Fs&$OTjYAUsKo<`Hx(TzbrPtRLL zSW9ToSU^LVA_3t5zZ>xl?AFTTXCwRgStMInP5F7L)IQxc{O&6k4e_D}7;WgmgUV>= zOdbsj5TXVJAv)9{zKRMkwMf~tY#1Cfn7sax3*s8$M%H3?_b;5BLEv@`&_^NO45|lb z&Nc)|$3*tDNm?|+LnM5oBD!UiNBKcYENVeYfm%>z#JTD_rvzw5Xe(8RM=qUfut2eg zF--7~M>f=IaSYR7YnblA;gjlRS=6_3>q>SdoXao>UfW>%-u6AMWC4+hZ}+a3-IQN| z*-PBbVOMmo!Y;!Rsj79LwgGdGCQ`hDgq;VEV0H}&CpQx58(|KoN6AuPXW!u4{|l^1 zMiSP&`**f?Bpl5{XCVBCA&K}W)FC&qOSqa3ckS;+g8hfP!mj4gk>SR@!zYJFM;PFc z$dIuH@e`yCXYN5%Z)o}SiKHwmzKWWJv$j*nV#G%P(>FLclE~QJykpPd17Xkhp5D`= zXVn|xJFJ9NZOx5ZHNHjHJakP?-MC{I`dNQLSF`CV1yVQ(U9D4tq+BFkVaZXw{-MV0 zgTuY2sTv7eXX~M^{hh4|-;w@4WCz!aJghci0y##ru{!`41YlxEQ2n>x1m1z_{~uIZ zCLR-19f*p5&7MHlbZ$kNZKP&4a@WUw9R#a(ZK~S0sp^o7a}=XJ#A7HyyhXc@v4fZI zDh%#~>q%JfpiXDbg8R2`?}I{E7>Z~agd`0QjgpYGl}7X1Z;p(*bUzx~TkbmB4>UG2 zRkOZ?^U2W=l+0*Y>M$LjIw(2dlW?e)?z)3=AF@at49p55ddGBQzimpCl&kwJ)ZEIgj4OwkPO?HL40Iw z$KfGB?&n9ugOuZUD90f>Bj`r#qO-$PfVp2d57=*bP&|U4AzHj~N}F_)AsM#<$*`x2 zzkwX@6CQKQvJ@VZr0cV8dQ~jD393Fx(Z7#Qi?P6}i~H{Hs*D=UXIROZA=DAFyZ~tk zl7UCVx~LtIL=-q?GHtScvSg}9%B`KX*L`3Mup|{xdxe^0;>@hQlHaL~+G~^Vz%`yWMVgJ03&f&nJ zNGfia_tOv9IOj;YHS;<21Fjo_Q>F8HbPl%-fUs817tjy*Y`~8-^F{On4jYQ4lBW3* z`T=hZxl-Qp`BM4;R}IUg($({2^rM`rvTeSC&MVm?8$YVTkH4=rxw0-*jQ38IT{jyo?FJxavhX7HWW zw?^LHc&q;1r=*f62!?tp4c%qWNky3M08MgVa!(wcwJ+15P1CDy<`LS|d|zmjEta<- z=H3|3X;hJxg=ka3m4byxlhYySk*2A>n=V3{S~R5TbTog}c>C?*s;T2&9=JXbD_#jS zDN{(hY@aYs9J*ZsMY!CF(B+-4bzJUvb9F3GGZ(0j2I_C@hy@;*3#^F-*2DtqZU^!v z%~y8)#A;rajiPgMKeCyZ_+Mf)(Zh#$bpJn1RAg1{7{A_kcvKBR;%6+XiT*ld>Ve zf#rq@u#9EsQWG|xvJrqpECbD)v>7q@MmE%f%JfqSOxXmG5|uPy5C~W{w$LfHAg!XH zqCx`f@odMLOR&YJrR}>PLO~@9szR9KXhYa&Rh5VgKi~({Hc%$t)9-nGn1YI5V^M{G z#?}o~>T}5FTI@R^hqo_38n$UJzsrCQ^0u0%D5%gnhiwD({7gIY`T^+5FoF@Fc9c($ zmKO3DE$bLi0R^ojp_tbIfP>2Hk7!RffEG}afvO9y(}1exqI-&J(!rm`l!NR597@4r z26ceLl27K(fKr+EMQqTX!_V;kWjD%suLFc|qMZzsY5CZEwH}7T5)IRoO{icX zge&4Qf*uqF6}M0fMHuYaH}P0X5%>uO7q_CI;(>yS3Pg;w^0a_*3=~^4qA&vp9>`CS zY-YsW0vHEGSoqv~DWPZ~Uce&s8%AQGcVZ}sI!)`MvCz#2j>tlpXnT;LE4off>Sh&D zNiyB(iTt9615+N-6op7jimS7v2G3b4SdZKg$`DH_Q|@t;dY>_SWY<&5NM5WmKlfpD zUr+rd3@#bLQ@?E99!Z1e7dHSoMr7$Ay^C6m#=9;K?*)6#FlC^yhilmYzzX%883}us z5)$_k18tvxrX!|Z6<44*k(@AyuM;Xjibdi|+O47;>E&q0aSOuv3S=a!7%4$% zH3g%r)65qPN1HS#iXlll^&)WtJpunsVu~)21{5VS(q2`hYq_)y9!Q z9ESZEfH>mg^Z>$dn6iy{l+K>OE<{Tmg)(Rd8Orf0P>xEB#Y@O>5ZG|&FLCmj)N4Kh zn;e+8IDGC)*7C%R!z zY{NddvXM&m69{k+x+tY@+Q00+?!Iw!W;oW^77Om23m%9D4@d`}jRkw8(&Lh+_g7gL zWy8(2gb*}q2*Ign{)%5~U6gn8n;m;Lo4;GWZO@vFzbvc4`S;eO@7dt^-a0FtZ?^8W z*fe7$ZsOI8??>G{IAb`%d;hf5QFQJU5N$r4afwsF4P5G*sV73RQvrfnQZ|*3VunmC zz+(vfNd`E3=A(~A5V3_pL0AM3TNs!N)K2KPgM8`HZ}3!xo&Zq>bC`o8LkDnl5l4ih zTRF;Za8wRXtDKOf@;DiShw)KMry`!g8*yOoi#V~*ex2Td33_ql{_0fZ0!rN^??^^+-g!d{x8mSox z%lKX5)-j?=NiE`aGt?e5&jueEmM^+&#PriN9a;YFHx9`07Yo^hi}hdP*r z>IYIq0e5bwic-UCj~31}LXMzzQA^58H|ja&f|On(3`xWL9zYev`@pIw%Xy9YQD_hv zBObI-6R?04yq1QlC|=ztF;YeG3Xj}a$q@~pKt8m|Mym>H5QGq>2(@ zbZjhJr;3tkga`3A(W#>Nq3qGDJ})!EDAO-N6(zvy##;sEp{Ukaj!qRNM_A48!eGh( z0n(|W_@PvY{Jd16*!NULd4SP|(Yi(%4QrD}!=kDv>pH}F)Iv02veNjT9`fYn)K<@` zXHD5die^uw^NC_$qLk!GC8;>2XuY49`|ylHxr{t>aF-SO;_B))ZGsC6s+H=NBh*K# z|HkM~vO^cW1DB+VGc1R%lyvS?sA>=BQbo<8awGTQR)sRj%z3hF6$*5jR8bY$N%c3A z98ypY6A{4>_%Q*wGGn@p&KTQi#W;|oOAOEeIffhI^iyy)dM21i3yJe|`xY+NIqo_S z$zF4ypdnUM;u`Gk`n0RT+OEFdQ*{XkOoK!yd%%G!tycE-jfCN5jxp~{-6LUb=|K!Z zaR~Vnw(SVv01W|^yI0B!B|J<=1>TJmFJL`Bq)%#sB(FZdurbRW9xSksqpmU<}>Iw`oMG!&3a0^z&6Vz*>g{8jIotU7|53UdAr$+ulbH!pKxdobR}DNgZ<<%#*OI z(9d=HD99yDSk*Inet3v%@OVi~???>Mvy7CD#1XnPO1tN=Gt?y#&(ZDs*BsISA*Msb z5FiHrX!y`SmgbOno~q95xh~L|sx~AN$0S2Txf(Q-a4TOpQ)`rUfv9z5)gKGpWZu0J zR_3HAVN+aT!H1Yk?nP3vN_e;(S2yhFPV}E&T180w96jOlv|}nlw5X=xv~Wu{+fu3; z60afa*9ccyy`-8@lG#?<^sbnHEey3%)P%U|5Adal^Y7V8Rp*k<9QP+s5XgkO3-=*8 zZU?~O%^tTPcF4yjufsFm0zZxL=zk6|Ndv`GmS~`IyfvOzI#qf3rRh~LVH)2V&s{c^ zefiAz&O2@|S?!-@6j!d;rch1uS13P2p*%w#c|Oqy6}qmOTJ<`Oox8IW@2Qr`FGAuKlh7!)s|iF z`o6Vuat*z}Tv2_rs9tK=He2+VbnwtazH=SDbK|(j&~og-6nwJFBm4eO&528S6eJJ<~7sk4Pn>1ooZ#C!+D=G_)=IwbJ-m4~-wy zXiU}kQ6176BXwc>FGu6&pSt0g5jMyX3c*Hr!9f@IgzBbjf+u1D zir{%2uCo`Ooz$0R6TC`EhNfq5uxjUTjJhs~Yylr#(s>f{n+8l=8bV0O9Q2f`c&XZvj}DCI<(iI}R#RNBhjCkj4#CP08WluQ)2OP~O5|s} zWME@HLlm%y*BK)>%SeL^=ow5Og_Xe1urj0#DSbw6mEb5!?S~OnuS}OW3ZS;G*>-rlT_}2nNF&k~7 z8m4I}VWPD{*mz@;u0OJkkV1i(VJ4ZWWf?h0;%}lu6a&EQG`+E`i0od>$ZTyFuNN<6 zU=P2d9Y<7sE;W@}x|WK!lWq)ZdX8WaXcLMVqL`~g33FlmsoahD2Rh_*`u)g3{tJe1 zg$oeHI(~N%v2xYBiz$j3zc@5@lKeMo zxgP{=OflqZWN1w-3#qH>MlUj_jCQy;R4n3&VcRXpqJfgN%Oa#K?TSPdxGVH%=b!5y^t= zWZ${|zEIx~vjsaiiYP-vFgzRTLnNe18n;!ntEL?RqKS-_;WIsOLStz^@cf#Dt*dXa z@5JyB60^V-ET#nuXTfk(`jTZZRYgn1kEkfJhLers7`A!~*AU`@jN_PZq=cgr&<6Z2 z_x6cRbWugG!fshTsIe8b7)&^1MJO~bQ)>!mYVSkJQWxkke?Zmt@rDg0V^u691|h#; zmoD1JLqk)n;;h~wLzL=X-iipIf;H@UxRDYxUq!!EW4HT(tnze_6}N4p0k0af$zz@1 ztRAuVRQGehxG%_Lk?A;nKx2aWBVzsWG`cNeMvo<2az)P~>KGCKJw`+ezLn6HB_XT_v2;svb-io)c+9^NaF;ia%$DZ7!KgPlRWyAv=3O`E z-4gX~iFqG|dmZnti|va+Vh&~v7hu+KP{4BMFk`pJqV~tet)!6UXTKYw_6E(wjocN{ zJvnVv)Ly0CBf~hlCmYQ@8?`^H-kbDIZkcSFPJ@lubkB@?`o)_Ef&JWCdF$A%4yp5y z)Z8WI9-g&7&aCIkqn7do)^lDzz2}uI*kW01zt#S^_Al&-TkOBJ`*XX$uosY*Y(1C% zu_>Rd=k63##qn2IgFr7ZHG?l=15+~xnVMyp@=D_YM2lXAq&Z6@&4y8(Ba^(`aH}+b zF^@6>JG5ZMRP*(W-*-=!D*>v1=h&neFD9_A?TdS-j#JXkt9f56fmzy)wp-7AYn>K{ z8AffhMcdvLVh~#oEbt;UH2G8BFaSYU6`o}-fLDe>54d5#32V1@@#^Sox7k! zhC9TajyZ+wF&AJWH}ePO68sFja=|Z(5eyb!ib#P4{-6LIEtPM9KPZ=wyJUKuKPVwD z!qiHHe8vi90unP&)(aK46R^bAzIL;)GdC^C{h!;#dz$2&)sb-Rq?6D9N#Qf4fGcKvL|l$uIgNyLW;SS&hIB!H)3KMLVCh8*o7+?$F+C=k=D*y?w+L%QgpaykuO$- zWIjslraaGLcQ;pI(Pe9?<#kGcqOiA)DF6+RBC^X!&vE!+MQYPsr;Y*pbGqPHr1Vvx z;QqEY3L*h^FiZ95z{17AJ*@>U9YW#@^jNY+631vq%|cE}N8pF%Ec~B@Sa5dnkLcci zpdGz#oo{j52oJTZmv)*bjl0xD%&F-#6~X+XS_b>!x0%VuiCGj0^0HsHL>h<#x5-80vn=%4Y9x`V_z2f+pcVK z^Eu}#in@v>+oqat?2ueVG1nt-`UTlqfd>41Y za|9Nxfo1XR!gzLRJge{{S4I)S+T_74gxxLqtE0B+xTg@3aGQH#f7DhH_vVgU@AxXG zx}v`27k6`48d;oqP?&{vQTsY=(f^s9X<%XRub(~~&0Zy~-5Jf^c@b-V8$}YCa7{iw zYpJC0x_HBvJ0@2~-SCi#^dA4j!AqwmYonRv7u(_%$9Q;h`DF3b!Ku+Nzi|D9e={~U zEUn!oWwu8x?Pv>&^SAbWZr{X)$;YMa%2`Vl36eDzt8rHrcdSY^$-(12ehQacGj6_} zQ!rU^<%w}}$l(ag*$bog!pTkV+p8%iZVmW~K*8h&3QE)y_18@A!VtK)XQ7z-Lsuap z7{`~@QKX%kHT1WvoX$%t>7=wWDe6v+lcMf8dDI>IisQ^F(jv|a9>s_Ba4SL3ddzsK zo1g|uXEP{F8d5iPHcDTlgycC78Xl>eS^_yVP$<1V4Zn{X7{<2M?(2f9n=#jLH5)@M9w+Y3~yMoXb8c4@FIL+P{> zidGkUU=K|J>Z6q!6IRT%d*m~@&Pt^SF@bWaf)hira9Hey548{`kOf$;RtcEQDh+Y` z0=%x#j241RBTR2VjiFd13hGa_J_@k1K;M+&B*B-sc;PH?XEn7!!>RWcYZaCt>{dP+ zU2HXT!lqmlH=&8-nb4t|37G;Io9k$XOPSl4ukbpHhG5Bse3PaY8cOtH3?>rk65CZ^ zXovmD&L=|YC)(*qJTEd{KC7N!s! zC(`lBI>RNZwJZGJ2ha2jjlz>$Ur70WS?4A)=yK1XNb;(#aLIP=T<*m1;7PHsU*~~t zQxNXPx{(aJaq_rt3+E~6PbB%1+Y}T(MLLWGTJf+^A=c9w(Hr2~Y&l6KiKEyhN89qt z?cFU8!)_4{^WeA+?bAjhWfzm^GqK z9~f0;!JIT!w0jg(S9edavQYxUeu%mB3(^=-x)Wjyc=8Lv;a?(4p*ZlS*9|rOF7X}e z3%P%-BKpK6UZdRr?HXvuI@ywNsEs4Oi!5y9h~LIpGAT#|lon0y@xMgEqtxU5D3bMf zZpmxME+3oAsfy-QN!9COIqNTWe3(}?)jzW=miJiH5`H47(O>NINYPe@l)R?IgB5DjEoqNGOV5$yoDRIjiq4*rxYohdO!U>!}z5y6~>Q- zf|0Tce+pZvsDnYPitfHSly(6Fh!G34IKZ(#^Dki1)-V3zsf`{?Dm^l*`Uw^6Q> zL7IfUW&e?mz5AP65*hNy3Fj*%@;5lyH`pWe4S`yc`Zfiu{|e2i|BjwuJm)f2l3)Q6 zY+;cUl8x?ZL5Em}-=)tiigOmVBH@xB%UrE8|Ah$_go4^71WQsj8}k^49zN8SuTXps zWu3+V2u&g7B6G9CBFvkVD2IXZ;!m*T&w~R#ZGv7n0e|4ijeF|v6jVknxfgehpSlf3 zW!K9u!s^@Qe|6iXZL_Z8xM!v8TW(;ocB-As$?Y>?sbo9pmA0hemOE$S=ycbu{8`s- z$+BA}(aY4&7J}4~1r(b3Z|z0fsv9*V`*N3I|8QTf!}N3dcFmzj^kf{!q(vj+M5^%| z^?Ov`z{I~h_H{3L{}9yu)Gu58f>XY1l`~lQ1ksBxU;Szq-bW464X3hGjb-T^*~bp zW$AFojJK3g$Ch%sS_+;M>AmSsz?gb0<3&WU|ExVMmvb)doQcO4`>bX1lIncS+3#N2 z@3C&F5I(I^VOrha2PLLtOHyM>u#h6NY~uHc#L)_b(c$o(q=&b(?rrUAeb~YoeGq?u zve~ODv>G&8erHal!*=CP`6!{l<-v2KqRQm|hduR1R|f7?CZoMu`Ex3q=h_KDit{=7;1l}DLriaWq(3Y_h~w13uJLgHLf4pMt%&D0trLxB`t2nmPtU@e7QM{mRs&=YS zs^2B$wd>Z9@Vc2=y-z>!VJ3n@P^*`!j37p@co^dWzGCw?cHBz))3%!%q`VegDe8Lj z6XGq(FP{*0k77-(1NE~%R=dFLaapB8%~J# z3@N+_+@%A|Af6#mOyyzmOl9Mw6rvj}h!$JWf{RHf`44e3%F#kcBjTy~U8IBuvq(Bx?lHI_Yim}L(hLS|C9kl%rL>ND!R zYvD$PEWm>F;%qb0&(;F}-tg*{>OKw&l0Pf*paoCfA~ddo?FT}D1$!O?=}>93GC8Qf ziSp<|1teYXLCCfmM}8#N}4qBLFl7 zg9J%{pIRR_kwKu+Kg1s^^AGfD@*I}*;GmIRstVF2Cv9mxw1Yi|Nx;Ny=o(r7kNF$Z zEUGWyMu$jZO2tJ!njD_bNPH*K7e$I=x&{+VXqK3!yMKnWL?&x9{`v_g`^jE5?Bx{# zozP)~>EJA60@95eGDNsw?qcYmW+O_7h%V6IRr<-88C5Q}06R?R9}h)bNYY5|n2a8X z37XDm_cqN`WNg03Orm+otKuc3x^!4M zyz$J{XQYNLv68LvV)Dh^`bNjq4yk@~ta!`96b0m*JLk>(FBe}gzHw^irC8I>SlO<* zvIEhw1JcoBQrUr6+0)U2r}-n~2f{;lnc|__j2g+_zmnCwn6RWVUbQw!C}K?~1T&o@ zm;sI8j}EaO1Htb_%d49TUF=4?eMX!LGlMv+VO5-#Gw?%QuB%U085hh2hRa^UM^o4a zm1$X63S&N&U|ty8vE+RnwFV;r+|>l-g^btD04-=n#0JnpWn455C<|#j01QXC06`~Y zS|}qJTBHf~VTMJOf)J%Re4wQn3)e;H2&EtaH~;7u2?JEIz_?vmG^@c^G&qBbNH`C7 zY~QkcvhmgS<{HvLp9P3@zkqpHlB2^llrK*(Z7Vq<&8H z>OY6isYLsnBC4|&3`6yE64D7LU_;f4p_-u^s%TJo(-tX$?Ja!fwDBW`s(q9?j`MYF z7T?Rlz(BJdQnpilLFl)Od=RrvO=RuZ+uGdO-Q3mHy6-^OAvTysNx8=;XZwNRD1CXs z@TmCqH+oNFjX<6{bYskxI>xGzTxM&u@s!3M9ikUdxRor{6Y8`pet>b5fefL8NVC*9 zvKLQ2Icu+F;{dTsr*_9HHr?EGv*FgTRQ5QHgQJU%gUS^>XZu$uMj^5YK>5)P8NiB> z;gcr^`;ssw>MORS{yHPEa6ql}0T!&NaO##sHdSGfmP&v*9v1;Xmrz|qtN9?|T?z$; z(|e2I5*X+)2(DT-2&Q={oEM`|C8r9?n-)p$PXpGQE~X1Mm<^JXIK)H5qE>kilxevK zf@EI$zD9sk9o-)?0j~-0b^=~g0sLAe*Fw6s)YT9#ET~l}ypW|JF`hB&i`GLhV5Osm zq||xCm0hfNgGz@770B;l`J;CM!?QBie7Vb7A?+C*@1kVl{?#wB>-Z8ZTK+`SS zjDZjqoQlP)7GJ=4LQo=#AkKm|xpeWBV2DBWk}EDwAZ6ERF&gQREJ#nrEO_)LhOUtK zuA|N^f0_S)AF_PMi25_a&!zwdRSZ+&A?yH1U&0fae+||w8Z<5A4;e_r-ck9Wv*pi+ z+gx?yQxpFSzL8w|sBa_jMbo^!(7GcHEcTqIH0mjhdCJH`MgD8MF7KLrYPt$BH^vb|o1-4bc}5$VWrsjT;> z)^tZ-8gPyj_WTw1ho0Q2HPV{SXx^cir%SSTeR|s!_}G*tS9xmfR}bCv{qf^bgvRx3 zy%~}`+uyfu|Jghs?HvfsL6)wc-(J7H$@Hxz$Bshtx7O$H$T7d|NyGWuIach$MSAKJ zT5)`I@N^>^g^-IRt?TS4ixHiV(E+cS?wjZkcW+2^PMTDr17TLks}UY~3etT@Osc3& zQ7U0tWSytpC&Fd0!UI7`OIin-jd*R4we4(GV=z-hT7c;0VK7 ze@v7k9DaD3M!wUNa_gvCbH@|4&<|xQq}=pxYp|_(?;$Nq!nOVI-aXy&GFiNg+H`b5 z7lHU!@n=*wXoEt%Xb6A0kaJXPCa3;NW-1+Hlq#8+CG7AJ#?S^GtjfrmO3}WOAv+6l zX>)!^BWj7vo)ahfddVb|e2p2?xLo>ZyZJ}UNnJ?%2y|{8(YgNz-9dD2*+M#(Tlm_k z%cmwsr;f*R>o}k~H7ad994&f0=074maZK_blibJdivkvoKSSiN1mv%v_zm|}_c+$d z3_s;4Y2#GkbXRYV%BgMAs#dA2jR@eb#R=fd*vzohdQ@8TL^SWonCB_U z{?r4|!Jky10Y1Nehtu@+?H=skb~<*fGr#T6-?7sCc6}P1ue4$xE-@g6nEI4q|3Ap# z1Cm1!Lcyt!Ll?-Q`xinEGZ{HV`Txk|u(CxR8H*D{@xS3CY!m+)ds)QvW1NLwNFrRE zQ@^jJTrv?$SDXn=bns+Sbd0MhR{Q$&A{FoZKN>5o9 zGI>JZ^XUCh5|@X@lNVh783iOSxYPyCKb97d8OyU2gdlZ!#)Tnl*<`ffvn7LX5ZOQx z(TH#4ZwHahAVfA9#@a#LEjNxV2pW#eS-Ikr24SLPQHS!|jo-{!IdhyXyDj&ig;O@o zZ8^A25At|Pg2D2zGb0P11$lgw2YIvUs5~EW>XSq*LlCvxV|fWt%#ykhx*o})z;Qa> zH1`Xph_yupMBl=yg*GAYnyQJtfV?w~k{y_8f+D8IaCqo=-|3zqD9nYF zevy4oIMf@mUtT6uV!l?6p=V`xzVm3-yJ?LOCLO`GgiF_y3BU|Q23b=kys1r@aBA(A zaH#Djuh(tg_( zNQ0g;DKjT*N^xQj8ZO~xYtcSgf;n1-1bR)7=Hue%38n}l^6zC+aJ2N^sn|5Xq_Rkg z=#6-oggG>NMphEpgP-CV+A)yEE`u~GPM>hAZPNq)%Je2;8u~}vLubz;AA9wnvdM}g zeuFj@t;qEvwc@`*PGV_RTAu-QvjD``fivkj?mJoe0P!)&7vp+<(QA>*kt;8byFc(0 zL3@OlodeOLgE4=n)OADx%i}(BClCGxF26KR8XrJpnEpow^grAt4~YXZGfGR6rz0$2x-PU+z&2-xJw)ZMG zPM_w!3VPwU^u!y(SBIshM`xFjuY&EZw|c%+t@l;1b++WucaO(Qg5hD16jdCTI(wYQ5)G1jKn&z82vid#Q&So57fF7AOu(JQX8Mj96gNza(k0wc2>ce9FG-hxypnIlO$Y)_?3ZNr< zzkN}tsRRAeTde3lTdeq(+F~U%ddZgm=iPEAGB`EENL%uYP)33Kp5u|$8Eg}lA=HkW zYAyt?rQayibgc0tTC=2Vd@QgrzvO-0`12zyPs*5ww62^N4FG&I0{zfr#I^yF|E6q0 z#Yi2$6!CCMl)}$w5V}>UOd9D`3O`dl=6TTreuf=&{7lVgBTDa7xfsT~1c&-Y)m5`j z3@$^>;-^IF{(*LE*?bpgZDHRoh222-iXa)q$q2g0prq%WeAz@t0~QF(L`VjQVQH$1 z8k>|anN3Q%Wy~|lEECzSgmdM(<1sMg?Nfz@{a0|4d3X!w=u-3?Ckqu?rlz1^I`&Gf zu*|+GT)_h}JPiETaTm#4Q21|>UXr(=0>$0)i99{2(5RdUp0ML^XJ>0iS9e$YzScus z&HD}*@_#J1rliPfws=BPqw9u2@DRnJ)v>L-4s>k8KvjS) z3fh3wvKD`eKKbzr_e}neIPw@=pbR#ctaF8eup!lYz)|!NqbK~Mq%qLmaj3PkD~M1` z`#Fk}L}ZecDr&WX`alSfUL858eav-9Re8L5@8Q-%K}and@QtVbPfpv}+I6_IqrGEi zuwS#-Pk35dA8+5$+TGc@v;9z4YiA-u{tjUQ$W$iS)G~n2V3f21ls?gGYv_nTij4{X zj2b&tIx#wQa#Ju|tf3ERKrxtfUjY?z?n6#0-X)SlES09uo3IEFHH#Ivn6RDdgHfXx zGL)k3p==uX;MHk}Xwxapq?%Y&v=I8xLlYE(dcrtMc$oDgL3eV)i8drkc=53-1Wl$$ zVmjqzpan^Mi5TfO8NBewjS0f7l$C`{|1$v>VVnTC$QVkqK9#C;q6lNhVu*a=it}G^ zMVY%2zl%!W*l=}&RJrbEdaP)(WY3qaUkJb08!g%w^LI!G4oUt)lKaqo32RlwM(6!u zA}cnNt4jAo|5Op&P6c>F+hPTe z%@wpp3tD3ZJKuBdTm*5^0WFH5O~GWnlvz!-Hnq@;DO&bM!_|f_HH~{da23XvZ;ZNY z#@nU5njd*e@4$DZ>oNe0jFP}7Zc|?6mEkwfefg#9FU{3#iq>qpx#ivLSk2yd`=y+t zKyG|brojrvmqS^8H}iJhvgtL_mZQ?iv(eJ$VtJzE6;TymKrYc-62Py#vWcfr4__tf z5v+J4ay9a$7pHrorH@Rw@Aylnw!G(Gal5E|dQ{r+r1aED>B&>lspq6-FYiJ!HQ8)4s*dlPG0S} z`;IGba?5+J8oBv5cS?u4rOs!iXZxj%1JR<>G5?@6^qk~>PI5n|E7l6k%L&YDvVQ6X zX-&)RtfH5Pubq42rK>N^m9C1GuA13$D?3)&0(GOTwzN-N8PIk`ge=^c>UU2r=!K4q#D^pL0ybMD^h&wl6nWDd1qptA;~`U>4&8I zlulm`N2fM@WA)AF{&d~VS}AwOti44+rw&E)x?-Ng(vc@6&y(-lpJeFNQ$VLW@yUL^ zxp1$=^v#myEF8Yokca)-mPfIF$GklQhre*rkMB5|3vhV1f_{9re0v!V-?upS+0Eb2 zd}N>5{Qa$IIR7iN75nhErJ*csf>yvRSpulg74q}|@}zF6rj;F6lff;{DlrXrP?G<@ zx-S8a<2ujmo&$rqFoPQ)t^r8mA`ag7As!%jfZ!n>5_LdB4oCtb0k8)kr3olQwo?Kv z)e5wd5@gG3DDlRl5^D`_lmyvmHB@T`4&gZP0isJ; zEKh9%+fLdv239X|@iUH4jeH7O@+;fP+$*+cY}ih+U)^>xZ)gb$ z+{K9~c{{0FwE4C&GHf6%_{Qo}?WXk9xJLM}9mw1i`tG3?)Kj4^j2H%iej*aoO$R;- zl^)b&Z;|gB>VCh}Iqk#rR5{a@g%KQ4d6P9F^XlgBa6w&JeLKG(zh_U>EN=ChJjUa4`Cfl*|@SA;!G3n*)1M z%Kgw+cA35+;z&nOF?xt597YStL{P5Qbt)N@i&XU}DV&40ll*3h{lq--8`bVsR<6bB zR*NOcpa0_U)!`SOQK*TYcv){E|H;^efmr@P%rl@{!XUN*QQ#{Y>y77BPi%;Jn@N1r z3h|A>d^eW2bmFPWQqs^}5-V?|-=(qg<)j(1at^iJqw(Tn3E%Np_HlJ-LUYumY3p0z z8!P_e($u+FQTwgzJ<8H_FkXBp;X53A@OaF3{Qd0XY-u{d1t;DeTZFg$+nez7oh{B? zqV=7uy^6b03XV@55GTN<$bBb z5!DrW+)%n6??I_ThH-^%!oQzF6g^`(Us>-C`UG4ej^>4#zVQ%QmT0PY9Pa`9_wQh` zl&<0NQ8tYO)WMAl=@SZiDKJqzW?-?>e@6>VCv`O)v6M6x3^zi(FN_ocrzm1CE%tmRuxuQ$EX@}uGn6RXCp;n9ZXCcq_;Mva7-R?qZS78|(-pu2$|R!>}9>p^Vof zeDnucrxymqumFs7oo(4%x_%)mb7Dv!4n&A#9I!{U=nXb{2*^R4OTrD^!OhClZzm}x zmH-CTpe_aYWm0qOH~>D>!RUl1zf_4h0wJ)AM}^xjYjq~n&eBGBuBtu~w!DrLCz zIm13PW1nH?8<-X8fTYvK;xC#rK8Nl=e7Cd+`IpTVKU6;K1_4m6_YV-N7|y}H%CA|k z_|3=UuphEdWh|o{aV~O09^>R-aM%$+JNRfq`YU;%s!$+Q9jXZ}hNN`qt64zA&o{Fh zh1|AEEfL7~-cT)bZ)geC;mfMOQiygo3>To*jl+cqmnmrf)T{IWlnHY`*PKm;nlLvM5dk=yhhB3IyN2D6=zE5}gT#hs$6!D? z5w17Cq0|dj)GZ7P>PWZjXDe{1zyEw-PjF~p@SV4?g$|RLE5mbLknA=&HKWi`{F-)V z>dL_Ul^gfRn)GFF2gC<~dYR5qOi_W49J~aNUW7GV$Kdq&<)_U?b%m#7qh5Z>Y%a>4 z(4Y-0=c8TEl2I@mVCek3KNua!J7fUetEtf{G+r3qtRU4kh+W%{96WX?u;XE4U$R^^ zHCd()D=Wd$rzI`Mi-F&R42Nw5r!Unb2tG0Ii#yjs;)nh|W9uaKb{CMKdCS_-)5TRO+n?K`-)Q|m3((E({7a1b}o zX;oniC)7w59i_wj)0Ig%I4-sph28%aYX&3z*_7REX$ zUI_6WsBRWzopnQcbhiA(&+2s&F|=>33qsxSj6rFLVFHeHn%o`g>!Ew+#?hn>7G6iq zS0vw@oo6G-ymRpBerB#F&BkG55o3npk>QhKGX@Muw|DNsNLPAPzFDQ-@q;D`{r?Nv4e`Yz6T>TxEQN+m$lnN-90)DM~rLdzX^?UXWz(`eDW0*_S zRCX{N?m64n9ZESA&!bWqGD=yuKe#hxqwh=l6I@GwNg4bp6_KN6AR{_qf4ZGMF!B{h zjvawe001pAqU;%jD@*x1oT4n4KM}8*nk`BxuQL7sybJNK{8^HN)|7+!Cpq=B;;2M+ zHbX2&2|B=Yx>mVEwfOs2{RhgTg66yH)?&fKGMW`EYFVPXgc%n!zSWwBWBIogYp()=s{Rsbi+Et%KDA$HFyUcM_g z8&XL(q4E~GfXZ7UczxvbB#-#9&p&fdaM)esAdpb5ic|-Fiz>#0uhc4@`)*g)-dKDi z_;&4kE56ehwMMtUo;8_2dHCB!uluGJ->lz4{tAyBPgI|P&y-TvsD1SGSoPI2U-G7{ z$Qosn2NLc^l{R?vl(enc-3etZ!i6`)iT~b;-Pn zWI=f{zu~UeQJgh$5Uy!WQqD_2}QHhF2J6W%7_9%k8i z#Y8Zg6RTSnTemOf-5EntVPfJ?JvA4*$OF0^Uq%{}{oyNz+4{lfR&o;&*;SbCh=B9#AJ-=fP!K%XV z(8Kr>yFTLx*=T1pCQ3ikX_j-rsutO_CiXWl<dZyMs z=!Bi}qE~IqcA;$83S6mja2*`rGgJ&a8K)%cdX*-g(ZRCqADqh1HeT)pD%@Q>hgf`2scm}yHdoRAM%%gW%<{gZXLgrbqXxIaF z1g@GO3P1@jzT$btGbX-pP(IU%a~3np!6T}p?>>O7U68pD=X&}gL*$~oyDx(SAq7!5 zBZ5*IQ7F5FW;?AzLYf42J+GQNPE0Dlq!@`*y8h8sk=I1o@m zg)Nn>~)D>%G+GU3lk@j$+eQQ?=-4ks>PbCY?{Z#UU%og&fOh* zcODJQU5|#qu7iQjgGcv3zjH*YrT4Tr*T47Nrzr>T!DZiTc43(nq$vVZgRh_B>Yo07p)JD#- z0vI3A*yP-WfYgX_)hYELOlcbn+D5{lmUjFpJCa~;;|dK8U<(Nq)3C%wC2eOS_M_nU zG!N0dcSIZbY`nu+2Sz+RYz>hNHL#14KS^LnGCOVn4n*jC7^A_>H0Wvj6Uj3SVA+G@ zB3XP#DiE^xj_psrid>o3o0L6_tOq6L5R!y85>X`#0W(v3gBDa-#>X}>H2ca1xdS*$ zWdZy1EVfYeCCNfSw!xt}gY9w}yz=M7_AE=SxXU_%7JGRIp~(d6>1hK3?X&J;gGc%j zwSvhAsWR%AVZZWTq?VFW;LgEb_uGnv+ek)Bj>qio_%J#DB-BOT~bAOLenMn z@sj#zRib3WsP}ehfb>kKOB>>)4Y9_JQ#%r++eUM41L_lwyr%PN;s9dYkjQJEEQsf= z8FhdhZdf;6zd2sNdFp7Qe#egr4o$4M5&YiSx6l4n&)DIYPh30k`wzzo4vjhyeCR8= z?a7VhwM;s0dDh>~FMG9p;^5TNx61b>^7q9&`%F}9@Az{;hZh!&y6#lg!Bx8RusGV8 zwiJ60i%C^S)dr2yY^Z)tJpSg1sgv4g4kxk zPt9%%Y0tD<;DXJWGgdg}7`ymd`zythd9f1cbsl)H_q|7B4_}PsNU^~yx3ZtP?JIuO zG2xzk`c}#Igl|VIdj})*TcYZSVN=5Wd^A!?M?E z`M%Y;ceDNbo~3)&*}uQej`K*JZi}EXyJV$JzHDSV=pt$r!(8~tBFk5CDjy5kI~Zto zTJJIrQ;MJ4e*(pm$G`8=A@5YF7yMPSYVfG%72^rD|_rDx!m|UqdcNyEGusuYz0Ybfw})uk zJods>Y7c}U!-Wr=ykQ4aYOyB(GK~ckVpgb#`V2I1G0sbr^U|-`uP=IoSYbE}in(Q> z^7-D3ZWIPs28O_N3|HvV-0!%r2vw>zQGa#0+;7QMx-|D&a+UVY{01fGEuiGQ&!ptQ z0!j|dEEzVw#E8YAeS-}(#{EN}r!R0=7TN(7wmU$G&E0*eELnm;zn6NtFQ-{5p&vD(MRT$ zFiH(c3t5wwY70l5Q4pg&P{b#!Mzh9Iq1j;;#_K`!73&<6q%-6o%DjXE#`#NF)iD@w9;H{9%&mfetO7QT`d* z1hy|&Oz}t)HIw$IjR)|T80AjQmX=dMoh4}$KkKE{xMv2LtDI@MXmm)YJMaUD}xv#mdHBQoI5kA(lpWFBBzAtwC)aD&MH}0C) zPu`(6#!6a}i(9AG#!Gf2s~V#9lPg|d7Oj{(HC1w>`p?Uzyf?aH6~{osLt!4spDcfi`~@z@h*6Fbf& zmh{}NswES)>8jRvRqJHdTaLf<{JCd({hs*xJ?|C84nLS!e>72b>~?iMIT)F)UJZS!V%UeAQ*@^87 zMtBQYn%Ru;3&4Fd$Ut&;m|4P1QdQ5Yec>Ud!bmh=#-X+h1gT{nf^{AxDxH!PbmT)G zu-&aa;;Cd+G<&D_26C?d|CZ8xxj!Z*@kqa2cA93^Pp=%2&sc z`n!OV3v_ABNKdnpb&8C?LCHl6D7i>eq5KU>E?z*%#WPF3UZRc@?K^EK9U_)FUx@+W zJrp(&pJxCQ!Pv|x52xjTT+SRB%;ZVypvxIAjS+|Ro`M8Q=F&60<|Ys+rjbbGT67sS zBE>YUvZQ7MLe3lrLKjn|7R@69SQDUx1SdY&uWIejnrjAC$bcy3Atb5V8ObtcvyPK2t|X>FflVFm@ss6}Qe zvKZAhqZVa9YIbQoO3=}W8|aeA$T02xSg$FTe@X{%>M$&ALfqda@{mYori}uX3-CIp z^XuaIb)3+PMH1W zakIZoF>fn~Ip>OWk>K%>UdVVgXv?IhU|hU z1Alxlmb(j7mv^_AuA(y1_4876zGy+R6jts;(FN+qBe8}3h%tNF5 zVnxj{SIeaHmTlv|Tnet386v7PXt-6KUQS~;@PmjeynNxKJK!SC=fR2yHB-J2clHUA z%RGjTq=R|9c^o}LBgit-lI$93c*UlX9xCC(_?(C0!19t-wFrChx!BjYyH^>8Rg53+i{1H$88^=hI%nwBNmIia~feGBS$-vY)vMX*gHyzfhT0zfidX(@}9H_nBP4bg*BY)=cHlO4Jcv+hlM@jrMCkIdr+ZCk!nC za@(#N`T_{P`}}#_lu;EP2XQ1@k#y176BPVC1+?3U{FzIoOx0hm1k%J+AY{3`83%ku zYI7x#x={axU{7CN23@Z0RA%HOn^`Ok2X-GkqWB#(#F6@`2?G?^5CoeL7$T_6nSb}B z#?077soNTpU6h(arA;!nKABu~H9Gxf>}2kD1Egk=k=$$}q((Jth-P$+%xwSijQf;Y zzxz>5<~czYFksfHx0%-hRmmSpI>wERS|vT(T}Gsv887nYymjJ*nrjTHRbHU>P-Z3T z_*>@Eftj_I{sH4J@+UK@{{Q4->KVAS`ZN5Lm}k{a25|{(Bc_5Ef?xDYtDCon(A8le z!dwEVA0C_vO42`4L;v)*)6ju){jkOR>?Vz&226RGEj@~j^_l*@zM%^jx+OzX|0^~1 z8-}JTvx#abWOzpNhdj^8z7)WDFq;YJ?adj1wOSX$P>A_geH0=mwwqsyD({wliDoh= z-_C|SS=7@*cNnfi#zvi4uY8Z5ejZ6BP0GXUSpE||_#p*;3i>Jd0>$gY8S-XW4-5*D ztuopIqX~VeM(pbV@+(^eFjb^H%BigHMMEm(VEuw;E_Nb6p*N7qmOBKG-13?H47&>< zOlIlo?4te39D^eD!_`;@%va}5>>Q_vZSW(U$VM#8u480Z5*U6Z*Gk4ALaH0tm&`AI zdHuEZasiS-S~hOfk`pGg#bF6RH-on8c=#%tD0J{>FE{;|)IyOUu8W}p*JG+aM1c{1Mo07U{$AtHG)Aq)c( zi6upBWQ(ripIq+ZOv?Y#aIP3 zJwS?N3eewZ8#R5+^O5m=H+{9N>H8B!2V(vMcj}t%2?gG@asT2m$M_XftWb(r2y9v4nkh=%}fobt7LKc?ZT4r+?S5) za>x?A{^KwtK01Jj8lMvUq7Q&7(HF`Z1=KsrO7D|#~>qQo-})AVqn zKB%Zo3Bq+d2jb8h5XUq+1z)B(~ z1fqWH%bd5(>3ADkY?!hz=WX1?M(Z|JK@Y!fVjK`U)*Dzq=)iWh66_h98H9#fUu^p3 z@u#cFHvzsN=+y8y;|s33xxyq=22IV4LV)+sGa~>z<3LQ}r)L~$&CqVF%cRCAw+!$U ziZUXDhVgx-E#pqu02kq7a1n>3ciuWPsG|Y9bpi#Q$vb(tmF}Spl1OKoI?5GXM$!f{ zW>&h`5X;00!SGxrhU7gF$$7N>WWW%Q*-Wbj3Z&%4NV6GHOd$b?s?cBo)Jzx&V#q+< zu=1O-w$C>72Z%Ff8T~W#0~CB1Vgu92VLpNXiKn2Vl#Vgnqx*c%Gt=~+9&@A(n85R1 zFl4guzBFV*T{&w%&4FVb=wC3;nT$LvEH^Xc%~UYD9*%j+6P{Hg zZMXf!^V&a~5Q{L&y`##SvVoH97kuU84@d1WZ_CHI+0NxKR4yzYb?V%M*<|-%h0~t$ zxTjoo5GET+V~MPU4=*eE@IpM0Z)R}FSdEhf)wHUXzV!HAhmF={8?DPWryJCwy4)+( zDH(l~YEp)plq?$c=xQQ58{w#IaqYy#*Vc}9jOUZ7_DxR!dP~kmki2F^j}0e$OJdne zRK=a(Ku_!0^ZloS=bOXbLFx24!{VEbm(Lbo8F+FYLBFCoiZuKP=@#E!TFNsHb+RRC zk(8sU?+kPO(~!)0&(W4blF8`d3>T`-GfW!NcHk+^rfic1EpPnGST4z#yvahSwgk%t zR2CpxGF|qD?TT8EUGa4a85J#?xX*l^!ppMGs_%tbEHdVbKyIT<1Ize4V3w#?fYmF@ zY&o8-URcig(u(E0hSi5#&uTAIZ9SIrIdP|;n5M{5Q-C&rWZwj35zc3K&dz!(j@W^Z~=cV%7@^V<;aqvXjk$TFd0cbV*+XC_p za$e7)(sYaiyjA%jD=jnntg-c#%1os3#*9ma(aAJlQK_1|9C;00!>+aElK~$=4VSH< z4(h&a1SyZc7D4J!o{!R*1k(clqb({!klS7*9bRhB|X#qFK5Or9#1-6mHD1iRAtRp}{(=lRAua ziW)qbt1q(#WV|27-I;RNqyo1=Eh!tiy?()68gnkfx@j-Ry6Mk@a)}0QVwX$tzv#Q_Q_Co; z9KZO=TE#0T*5p!TPRp?sk+CKt^9-F|cV4ov5{KeBUa&; z&tr5(rlD-9B@gqapq{@y7vTYIA!ip&aF!K^TPmuez_;fT)kD1x_mfv%tv_Kwmfj?cq}JxB)+M#l>Q9 zb&PA47d!y!a9?q0N(P})D1X?8-7r0~$@S%s2(HUQLs_KhBosK|Z?301@FZ4Upb*0n zdk zW!@rlcul(zI~oR^He;RFMPbUESO)nL(j;5`@V3mgQJg7#kXB-<(AhT;`^glu@KJhj z1Dz(NDY!wGcj=5c1`Td`v0{6>Cd!di5K{$ay_CwG(JeD>0nsa0*Ji~Y%&)t}x*_G# zx`7$1bV?m4Jkp{P5VDi^F2V~p(glW&kZ6q!l4T9=L?r`$O)DYC6g0kPe*4qlEpo`j z8F{J1&J6g#b1j;aHeN5|%U0#7I-o_lp))`gbq*^yDW^3=PnQ4|j+DSsP8rAnjpV1n zL2^aTOtWNFg+IbeKaD?rpi9~D5y^R87P^I#aI=2W5)!{CoU}e|X|Vo!DgWM-%4aE}dgx|Ll+D!GEHGil=#UTDiK@zh#mrX# zU++SL*a;wZd(U9^g)qS7(6+%rslk@Ag}cw6k)>t7KziwCC^1a45E~Zgwt1 z8SLtcXt;lKHK|37p9|tGT!$=11mISEz4w*gubwAM_Qq?jG3(edFeAoo*%x7`ZgI*bNL_dmDV*_o&JA8T589O3g2m7caKy%@*rzcEWKf`HW@wtz_#aY* z2zm;HEwel0q^S%yua#iMHz7SiEulK;)bsmK^`^vgDUVWvE?kGhME=lgQ4hJ%{>+9* z-N@^^M8JL@t&qiti)J-0gC{Z2f*#m;jaEWbVlM?D=gxVt>1xySEh9TWbmz=rV@7~! zc?4LOfqkctqyhMqe=SRO;;#5|SAJvyRb>q27K|OaR(!YEUVn zqJWWu4A*C*fPa}~wKv`c&D(g_B@|SRKb6R@N7I^C{V0F+#Kmj-C&b_HjODK$=|I2` zAXX-8T9UOZlhw_Td3joiTyFj3o?R$d6QgR;9AIQ z@hO`U%A|RWVUl^MlM6HTG9wweW-=p}<{}g=Axulq2WGsRkcDYM0u4H3W5lX*pK#_` zEDPOzN(@^>Aq1oXnYh$lvs`r7EEnB1!@K9#Af_12*Sl^Ewzrd)=WQI?SHig@GJAalzt=L7f49LdUGHr^Y(F#+<9x4P1?}B zc6bF^?PjY1?z8O&1WI<)qzpB2&RP?$qdIR*T*Iyf)P%itU`(hranD*4oqi~KZLW9j z8TROEvQpnCrd|%X=A=$HR~f%=D9g8|5v{&Y^+_o2RV#XNHG0v@>OSWN1mEnO&vjc0 zZ0)e6aA0HM9`;@@ctiP8`NqO>S#^~&%UMI=&@z0IyMp0!r~2WNn`?M7$|Z0>o^=94 z;42>K9sr1IIQTM}%jq{WI#ZSf?SjYFy`4bw;tN+#h=Kny#BwhC$N&J?d2o9N(0JSG zw;yk>A0St*(uH8(>F%!Z>3*rZp((Ij0ctZauG!nq?E7P#N9(mwMrhJ( zZ1C}Rx*wY?-mS(?p96%Or&nG~K)RryaWft(>dIs2xUEF3F){CjmIb{!O0S78GqAUL zBecDaS&ZQ3dEbdDKQ?Dil_vhq5^-W-WYj8BsxvQQ_i|?nb#=wf@ z%UhNQTDg$9DX>DG1=JeRTtJB2yq`>7Hh-q|vc5CeV=(!dT%73)G9d3glPXP!Z2T^4hC@BNjJZYrz zbxFXKl^tdHhX9n;6A6-E2%>$1shq+70hQE;hp1HUfYc9vO&7EcM9Le2roz)*N+Gkb z@fhC@D4T*rLm*{8bG|<~D0|G%7qsU)nKrJMdr;KX_HxF3{?s~H9i(xs6~-b zR{8a&SDL=s60;TSm~{|Me=KApLY`1k_VVzx;g_C?*$N=7E?DvlUv^0r_><79c0(VZ z1m2|o_EM+HeH=r3B#Y7ul(dKozze990+iCIxL1Du8E_u+ zgo5&4eU2`|J)h4@fN zK*73wY3oP_6%s%prE?b|cdjDHR|_dcQwmWE(rTsX-fLbVx!-uH5~yD}LP6y(ypF;w z@OIG@r`lf!uN|CdPZTuWE(=WLQ#XBe%Wagkn4Ffp^yEhl8?^?>Mh>I}O3iczj7&|1 zQ3;Ow;;CcI3m
26_q?r-4Z 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 diff --git a/v2_adminpanel/__pycache__/config.cpython-312.pyc b/v2_adminpanel/__pycache__/config.cpython-312.pyc deleted file mode 100644 index 09aafeb9eb28289c6e14dae101e90d09dafa51b8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2358 zcmah~+fUn896m8gAQuM%xj+geu$C3A5S4anyRMT=azZ@FWwxU(ewY<<3}-9Q_QV@q-}bAi#!rdyguZWdJ!!# zMD8OfuzL1wHcLW2)VoVMHlGtQ4C*@-({I&3G0VWI2)~6u0Tg`blm!YB4Ud=z))M9jz$X81(R zD2iBHT)lq0{{z`#0E(`i2S6GqNx(sfhPZMVz$|WlD;mbA-DS4=E|SVVit^ z&ulm|X~Qm~Tvd=#F^WX7=ZeM(`Fbj95y?n3l-l~b``t|@o=xZSBIKh2TM(j|?54Yr z%}0|gw*V7+=FP_aJdCqBF`i;&ffJ!w3j2@QV4R0y$4bLAi6d>Xvm_cjmWmaf3}|Q# z1^2RvG?%$3n_J7UX?SF?_I7BPc^5GwTZp|b^K2>XIoOH3qBb;4A1VjuJvoLcRmVN3 zXecJTG&2Nu*9o^O>Lzuf13SVUdYaIWO`AA>6|EF|Lj6GJ})QoJlo-2 zDx2p89JC0+CJB{}(AqG`s*RYb9ToMKqG_Y1?CDUUbIt;0gm?-j@(U#Fpu3;JYXSmq z(y?mIh?Qb_ay|4IYu;Z%f30Y!S}dWK8dY+^^w?b4NHrE?>oZftwx(!Ak~??3UaTpV zSV^rdm6xaM8*h3(uBxa}Q9jWi$!`7?>JQ0#zsqK`{Q+Dr!RSjcNZx+|&)4+66Zi)2 z@b{#<(hl|Q7hCRj@0l%cn+a^u?Sacp!2CWk@#M?rBNv+>+zyX6L8v`A(gcBaIAY4d zb4@VNj+||R;r$*!2k*?@f_Cpcc$$P9O>&V?{wQ2y0dt%e7C$qw|o=Fye;oU zn;F`s9=Ptin&GJyGu`ez|BCJ&ptgEn`GK#0d%8uBk=}s&1z?^5W``7Ho`XnxaBPQq z*hP1pf9=xCw$})L>+re^9tzXhtrz0%SVk zk8W!ZHtugU$F8(OR|%G&`oagT?8Kg2X)%{xFxQ?j*ILZ=U)G;7kmT+G4&T=7H{4fT z(-&#kqmL&akN-6J!({Wqjn?=lZ99FkbKlX`OKo{bd;UN>5Paq6@>66UEly79UzOKb A1^@s6 diff --git a/v2_adminpanel/app.py.backup b/v2_adminpanel/app.py.backup deleted file mode 100644 index 96c85f1..0000000 --- a/v2_adminpanel/app.py.backup +++ /dev/null @@ -1,5032 +0,0 @@ -import os -import psycopg2 -from psycopg2.extras import Json -from flask import Flask, render_template, request, redirect, session, url_for, send_file, jsonify, flash -from flask_session import Session -from functools import wraps -from dotenv import load_dotenv -import pandas as pd -from datetime import datetime, timedelta -from zoneinfo import ZoneInfo -import io -import subprocess -import gzip -from cryptography.fernet import Fernet -from pathlib import Path -import time -from apscheduler.schedulers.background import BackgroundScheduler -import logging -import random -import hashlib -import requests -import secrets -import string -import re -import bcrypt -import pyotp -import qrcode -from io import BytesIO -import base64 -import json -from werkzeug.middleware.proxy_fix import ProxyFix -from openpyxl.utils import get_column_letter - -load_dotenv() - -app = Flask(__name__) -app.config['SECRET_KEY'] = os.urandom(24) -app.config['SESSION_TYPE'] = 'filesystem' -app.config['JSON_AS_ASCII'] = False # JSON-Ausgabe mit UTF-8 -app.config['JSONIFY_MIMETYPE'] = 'application/json; charset=utf-8' -app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(minutes=5) # 5 Minuten Session-Timeout -app.config['SESSION_COOKIE_HTTPONLY'] = True -app.config['SESSION_COOKIE_SECURE'] = False # Wird auf True gesetzt wenn HTTPS (intern läuft HTTP) -app.config['SESSION_COOKIE_SAMESITE'] = 'Lax' -app.config['SESSION_COOKIE_NAME'] = 'admin_session' -# WICHTIG: Session-Cookie soll auch nach 5 Minuten ablaufen -app.config['SESSION_REFRESH_EACH_REQUEST'] = False -Session(app) - -# ProxyFix für korrekte IP-Adressen hinter Nginx -app.wsgi_app = ProxyFix( - app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1 -) - -# Backup-Konfiguration -BACKUP_DIR = Path("/app/backups") -BACKUP_DIR.mkdir(exist_ok=True) - -# Rate-Limiting Konfiguration -FAIL_MESSAGES = [ - "NOPE!", - "ACCESS DENIED, TRY HARDER", - "WRONG! 🚫", - "COMPUTER SAYS NO", - "YOU FAILED" -] - -MAX_LOGIN_ATTEMPTS = 5 -BLOCK_DURATION_HOURS = 24 -CAPTCHA_AFTER_ATTEMPTS = 2 - -# Scheduler für automatische Backups -scheduler = BackgroundScheduler() -scheduler.start() - -# Logging konfigurieren -logging.basicConfig(level=logging.INFO) - - -# Login decorator -def login_required(f): - @wraps(f) - def decorated_function(*args, **kwargs): - if 'logged_in' not in session: - return redirect(url_for('login')) - - # Prüfe ob Session abgelaufen ist - if 'last_activity' in session: - last_activity = datetime.fromisoformat(session['last_activity']) - time_since_activity = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None) - last_activity - - # Debug-Logging - app.logger.info(f"Session check for {session.get('username', 'unknown')}: " - f"Last activity: {last_activity}, " - f"Time since: {time_since_activity.total_seconds()} seconds") - - if time_since_activity > timedelta(minutes=5): - # Session abgelaufen - Logout - username = session.get('username', 'unbekannt') - app.logger.info(f"Session timeout for user {username} - auto logout") - # Audit-Log für automatischen Logout (vor session.clear()!) - try: - log_audit('AUTO_LOGOUT', 'session', additional_info={'reason': 'Session timeout (5 minutes)', 'username': username}) - except: - pass - session.clear() - flash('Ihre Sitzung ist abgelaufen. Bitte melden Sie sich erneut an.', 'warning') - return redirect(url_for('login')) - - # Aktivität NICHT automatisch aktualisieren - # Nur bei expliziten Benutzeraktionen (wird vom Heartbeat gemacht) - return f(*args, **kwargs) - return decorated_function - -# DB-Verbindung mit UTF-8 Encoding -def get_connection(): - conn = psycopg2.connect( - host=os.getenv("POSTGRES_HOST", "postgres"), - port=os.getenv("POSTGRES_PORT", "5432"), - dbname=os.getenv("POSTGRES_DB"), - user=os.getenv("POSTGRES_USER"), - password=os.getenv("POSTGRES_PASSWORD"), - options='-c client_encoding=UTF8' - ) - conn.set_client_encoding('UTF8') - return conn - -# User Authentication Helper Functions -def hash_password(password): - """Hash a password using bcrypt""" - return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8') - -def verify_password(password, hashed): - """Verify a password against its hash""" - return bcrypt.checkpw(password.encode('utf-8'), hashed.encode('utf-8')) - -def get_user_by_username(username): - """Get user from database by username""" - conn = get_connection() - cur = conn.cursor() - try: - cur.execute(""" - SELECT id, username, password_hash, email, totp_secret, totp_enabled, - backup_codes, last_password_change, failed_2fa_attempts - FROM users WHERE username = %s - """, (username,)) - user = cur.fetchone() - if user: - return { - 'id': user[0], - 'username': user[1], - 'password_hash': user[2], - 'email': user[3], - 'totp_secret': user[4], - 'totp_enabled': user[5], - 'backup_codes': user[6], - 'last_password_change': user[7], - 'failed_2fa_attempts': user[8] - } - return None - finally: - cur.close() - conn.close() - -def generate_totp_secret(): - """Generate a new TOTP secret""" - return pyotp.random_base32() - -def generate_qr_code(username, totp_secret): - """Generate QR code for TOTP setup""" - totp_uri = pyotp.totp.TOTP(totp_secret).provisioning_uri( - name=username, - issuer_name='V2 Admin Panel' - ) - - qr = qrcode.QRCode(version=1, box_size=10, border=5) - qr.add_data(totp_uri) - qr.make(fit=True) - - img = qr.make_image(fill_color="black", back_color="white") - buf = BytesIO() - img.save(buf, format='PNG') - buf.seek(0) - - return base64.b64encode(buf.getvalue()).decode() - -def verify_totp(totp_secret, token): - """Verify a TOTP token""" - totp = pyotp.TOTP(totp_secret) - return totp.verify(token, valid_window=1) - -def generate_backup_codes(count=8): - """Generate backup codes for 2FA recovery""" - codes = [] - for _ in range(count): - code = ''.join(random.choices(string.ascii_uppercase + string.digits, k=8)) - codes.append(code) - return codes - -def hash_backup_code(code): - """Hash a backup code for storage""" - return hashlib.sha256(code.encode()).hexdigest() - -def verify_backup_code(code, hashed_codes): - """Verify a backup code against stored hashes""" - code_hash = hashlib.sha256(code.encode()).hexdigest() - return code_hash in hashed_codes - -# Audit-Log-Funktion -def log_audit(action, entity_type, entity_id=None, old_values=None, new_values=None, additional_info=None): - """Protokolliert Änderungen im Audit-Log""" - conn = get_connection() - cur = conn.cursor() - - try: - username = session.get('username', 'system') - ip_address = get_client_ip() if request else None - user_agent = request.headers.get('User-Agent') if request else None - - # Debug logging - app.logger.info(f"Audit log - IP address captured: {ip_address}, Action: {action}, User: {username}") - - # Konvertiere Dictionaries zu JSONB - old_json = Json(old_values) if old_values else None - new_json = Json(new_values) if new_values else None - - cur.execute(""" - INSERT INTO audit_log - (username, action, entity_type, entity_id, old_values, new_values, - ip_address, user_agent, additional_info) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) - """, (username, action, entity_type, entity_id, old_json, new_json, - ip_address, user_agent, additional_info)) - - conn.commit() - except Exception as e: - print(f"Audit log error: {e}") - conn.rollback() - finally: - cur.close() - conn.close() - -# Verschlüsselungs-Funktionen -def get_or_create_encryption_key(): - """Holt oder erstellt einen Verschlüsselungsschlüssel""" - key_file = BACKUP_DIR / ".backup_key" - - # Versuche Key aus Umgebungsvariable zu lesen - env_key = os.getenv("BACKUP_ENCRYPTION_KEY") - if env_key: - try: - # Validiere den Key - Fernet(env_key.encode()) - return env_key.encode() - except: - pass - - # Wenn kein gültiger Key in ENV, prüfe Datei - if key_file.exists(): - return key_file.read_bytes() - - # Erstelle neuen Key - key = Fernet.generate_key() - key_file.write_bytes(key) - logging.info("Neuer Backup-Verschlüsselungsschlüssel erstellt") - return key - -# Backup-Funktionen -def create_backup(backup_type="manual", created_by=None): - """Erstellt ein verschlüsseltes Backup der Datenbank""" - start_time = time.time() - timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime("%Y%m%d_%H%M%S") - filename = f"backup_v2docker_{timestamp}_encrypted.sql.gz.enc" - filepath = BACKUP_DIR / filename - - conn = get_connection() - cur = conn.cursor() - - # Backup-Eintrag erstellen - cur.execute(""" - INSERT INTO backup_history - (filename, filepath, backup_type, status, created_by, is_encrypted) - VALUES (%s, %s, %s, %s, %s, %s) - RETURNING id - """, (filename, str(filepath), backup_type, 'in_progress', - created_by or 'system', True)) - backup_id = cur.fetchone()[0] - conn.commit() - - try: - # PostgreSQL Dump erstellen - dump_command = [ - 'pg_dump', - '-h', os.getenv("POSTGRES_HOST", "postgres"), - '-p', os.getenv("POSTGRES_PORT", "5432"), - '-U', os.getenv("POSTGRES_USER"), - '-d', os.getenv("POSTGRES_DB"), - '--no-password', - '--verbose' - ] - - # PGPASSWORD setzen - env = os.environ.copy() - env['PGPASSWORD'] = os.getenv("POSTGRES_PASSWORD") - - # Dump ausführen - result = subprocess.run(dump_command, capture_output=True, text=True, env=env) - - if result.returncode != 0: - raise Exception(f"pg_dump failed: {result.stderr}") - - dump_data = result.stdout.encode('utf-8') - - # Komprimieren - compressed_data = gzip.compress(dump_data) - - # Verschlüsseln - key = get_or_create_encryption_key() - f = Fernet(key) - encrypted_data = f.encrypt(compressed_data) - - # Speichern - filepath.write_bytes(encrypted_data) - - # Statistiken sammeln - cur.execute("SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'public'") - tables_count = cur.fetchone()[0] - - cur.execute(""" - SELECT SUM(n_live_tup) - FROM pg_stat_user_tables - """) - records_count = cur.fetchone()[0] or 0 - - duration = time.time() - start_time - filesize = filepath.stat().st_size - - # Backup-Eintrag aktualisieren - cur.execute(""" - UPDATE backup_history - SET status = %s, filesize = %s, tables_count = %s, - records_count = %s, duration_seconds = %s - WHERE id = %s - """, ('success', filesize, tables_count, records_count, duration, backup_id)) - - conn.commit() - - # Audit-Log - log_audit('BACKUP', 'database', backup_id, - additional_info=f"Backup erstellt: {filename} ({filesize} bytes)") - - # E-Mail-Benachrichtigung (wenn konfiguriert) - send_backup_notification(True, filename, filesize, duration) - - logging.info(f"Backup erfolgreich erstellt: {filename}") - return True, filename - - except Exception as e: - # Fehler protokollieren - cur.execute(""" - UPDATE backup_history - SET status = %s, error_message = %s, duration_seconds = %s - WHERE id = %s - """, ('failed', str(e), time.time() - start_time, backup_id)) - conn.commit() - - logging.error(f"Backup fehlgeschlagen: {e}") - send_backup_notification(False, filename, error=str(e)) - - return False, str(e) - - finally: - cur.close() - conn.close() - -def restore_backup(backup_id, encryption_key=None): - """Stellt ein Backup wieder her""" - conn = get_connection() - cur = conn.cursor() - - try: - # Backup-Info abrufen - cur.execute(""" - SELECT filename, filepath, is_encrypted - FROM backup_history - WHERE id = %s - """, (backup_id,)) - backup_info = cur.fetchone() - - if not backup_info: - raise Exception("Backup nicht gefunden") - - filename, filepath, is_encrypted = backup_info - filepath = Path(filepath) - - if not filepath.exists(): - raise Exception("Backup-Datei nicht gefunden") - - # Datei lesen - encrypted_data = filepath.read_bytes() - - # Entschlüsseln - if is_encrypted: - key = encryption_key.encode() if encryption_key else get_or_create_encryption_key() - try: - f = Fernet(key) - compressed_data = f.decrypt(encrypted_data) - except: - raise Exception("Entschlüsselung fehlgeschlagen. Falsches Passwort?") - else: - compressed_data = encrypted_data - - # Dekomprimieren - dump_data = gzip.decompress(compressed_data) - sql_commands = dump_data.decode('utf-8') - - # Bestehende Verbindungen schließen - cur.close() - conn.close() - - # Datenbank wiederherstellen - restore_command = [ - 'psql', - '-h', os.getenv("POSTGRES_HOST", "postgres"), - '-p', os.getenv("POSTGRES_PORT", "5432"), - '-U', os.getenv("POSTGRES_USER"), - '-d', os.getenv("POSTGRES_DB"), - '--no-password' - ] - - env = os.environ.copy() - env['PGPASSWORD'] = os.getenv("POSTGRES_PASSWORD") - - result = subprocess.run(restore_command, input=sql_commands, - capture_output=True, text=True, env=env) - - if result.returncode != 0: - raise Exception(f"Wiederherstellung fehlgeschlagen: {result.stderr}") - - # Audit-Log (neue Verbindung) - log_audit('RESTORE', 'database', backup_id, - additional_info=f"Backup wiederhergestellt: {filename}") - - return True, "Backup erfolgreich wiederhergestellt" - - except Exception as e: - logging.error(f"Wiederherstellung fehlgeschlagen: {e}") - return False, str(e) - -def send_backup_notification(success, filename, filesize=None, duration=None, error=None): - """Sendet E-Mail-Benachrichtigung (wenn konfiguriert)""" - if not os.getenv("EMAIL_ENABLED", "false").lower() == "true": - return - - # E-Mail-Funktion vorbereitet aber deaktiviert - # TODO: Implementieren wenn E-Mail-Server konfiguriert ist - logging.info(f"E-Mail-Benachrichtigung vorbereitet: Backup {'erfolgreich' if success else 'fehlgeschlagen'}") - -# Scheduled Backup Job -def scheduled_backup(): - """Führt ein geplantes Backup aus""" - logging.info("Starte geplantes Backup...") - create_backup(backup_type="scheduled", created_by="scheduler") - -# Scheduler konfigurieren - täglich um 3:00 Uhr -scheduler.add_job( - scheduled_backup, - 'cron', - hour=3, - minute=0, - id='daily_backup', - replace_existing=True -) - -# Rate-Limiting Funktionen -def get_client_ip(): - """Ermittelt die echte IP-Adresse des Clients""" - # Debug logging - app.logger.info(f"Headers - X-Real-IP: {request.headers.get('X-Real-IP')}, X-Forwarded-For: {request.headers.get('X-Forwarded-For')}, Remote-Addr: {request.remote_addr}") - - # Try X-Real-IP first (set by nginx) - if request.headers.get('X-Real-IP'): - return request.headers.get('X-Real-IP') - # Then X-Forwarded-For - elif request.headers.get('X-Forwarded-For'): - # X-Forwarded-For can contain multiple IPs, take the first one - return request.headers.get('X-Forwarded-For').split(',')[0].strip() - # Fallback to remote_addr - else: - return request.remote_addr - -def check_ip_blocked(ip_address): - """Prüft ob eine IP-Adresse gesperrt ist""" - conn = get_connection() - cur = conn.cursor() - - cur.execute(""" - SELECT blocked_until FROM login_attempts - WHERE ip_address = %s AND blocked_until IS NOT NULL - """, (ip_address,)) - - result = cur.fetchone() - cur.close() - conn.close() - - if result and result[0]: - if result[0] > datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None): - return True, result[0] - return False, None - -def record_failed_attempt(ip_address, username): - """Zeichnet einen fehlgeschlagenen Login-Versuch auf""" - conn = get_connection() - cur = conn.cursor() - - # Random Fehlermeldung - error_message = random.choice(FAIL_MESSAGES) - - try: - # Prüfen ob IP bereits existiert - cur.execute(""" - SELECT attempt_count FROM login_attempts - WHERE ip_address = %s - """, (ip_address,)) - - result = cur.fetchone() - - if result: - # Update bestehenden Eintrag - new_count = result[0] + 1 - blocked_until = None - - if new_count >= MAX_LOGIN_ATTEMPTS: - blocked_until = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None) + timedelta(hours=BLOCK_DURATION_HOURS) - # E-Mail-Benachrichtigung (wenn aktiviert) - if os.getenv("EMAIL_ENABLED", "false").lower() == "true": - send_security_alert_email(ip_address, username, new_count) - - cur.execute(""" - UPDATE login_attempts - SET attempt_count = %s, - last_attempt = CURRENT_TIMESTAMP, - blocked_until = %s, - last_username_tried = %s, - last_error_message = %s - WHERE ip_address = %s - """, (new_count, blocked_until, username, error_message, ip_address)) - else: - # Neuen Eintrag erstellen - cur.execute(""" - INSERT INTO login_attempts - (ip_address, attempt_count, last_username_tried, last_error_message) - VALUES (%s, 1, %s, %s) - """, (ip_address, username, error_message)) - - conn.commit() - - # Audit-Log - log_audit('LOGIN_FAILED', 'user', - additional_info=f"IP: {ip_address}, User: {username}, Message: {error_message}") - - except Exception as e: - print(f"Rate limiting error: {e}") - conn.rollback() - finally: - cur.close() - conn.close() - - return error_message - -def reset_login_attempts(ip_address): - """Setzt die Login-Versuche für eine IP zurück""" - conn = get_connection() - cur = conn.cursor() - - try: - cur.execute(""" - DELETE FROM login_attempts - WHERE ip_address = %s - """, (ip_address,)) - conn.commit() - except Exception as e: - print(f"Reset attempts error: {e}") - conn.rollback() - finally: - cur.close() - conn.close() - -def get_login_attempts(ip_address): - """Gibt die Anzahl der Login-Versuche für eine IP zurück""" - conn = get_connection() - cur = conn.cursor() - - cur.execute(""" - SELECT attempt_count FROM login_attempts - WHERE ip_address = %s - """, (ip_address,)) - - result = cur.fetchone() - cur.close() - conn.close() - - return result[0] if result else 0 - -def send_security_alert_email(ip_address, username, attempt_count): - """Sendet eine Sicherheitswarnung per E-Mail""" - subject = f"⚠️ SICHERHEITSWARNUNG: {attempt_count} fehlgeschlagene Login-Versuche" - body = f""" - WARNUNG: Mehrere fehlgeschlagene Login-Versuche erkannt! - - IP-Adresse: {ip_address} - Versuchter Benutzername: {username} - Anzahl Versuche: {attempt_count} - Zeit: {datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y-%m-%d %H:%M:%S')} - - Die IP-Adresse wurde für 24 Stunden gesperrt. - - Dies ist eine automatische Nachricht vom v2-Docker Admin Panel. - """ - - # TODO: E-Mail-Versand implementieren wenn SMTP konfiguriert - logging.warning(f"Sicherheitswarnung: {attempt_count} fehlgeschlagene Versuche von IP {ip_address}") - print(f"E-Mail würde gesendet: {subject}") - -def verify_recaptcha(response): - """Verifiziert die reCAPTCHA v2 Response mit Google""" - secret_key = os.getenv('RECAPTCHA_SECRET_KEY') - - # Wenn kein Secret Key konfiguriert ist, CAPTCHA als bestanden werten (für PoC) - if not secret_key: - logging.warning("RECAPTCHA_SECRET_KEY nicht konfiguriert - CAPTCHA wird übersprungen") - return True - - # Verifizierung bei Google - try: - verify_url = 'https://www.google.com/recaptcha/api/siteverify' - data = { - 'secret': secret_key, - 'response': response - } - - # Timeout für Request setzen - r = requests.post(verify_url, data=data, timeout=5) - result = r.json() - - # Log für Debugging - if not result.get('success'): - logging.warning(f"reCAPTCHA Validierung fehlgeschlagen: {result.get('error-codes', [])}") - - return result.get('success', False) - - except requests.exceptions.RequestException as e: - logging.error(f"reCAPTCHA Verifizierung fehlgeschlagen: {str(e)}") - # Bei Netzwerkfehlern CAPTCHA als bestanden werten - return True - except Exception as e: - logging.error(f"Unerwarteter Fehler bei reCAPTCHA: {str(e)}") - return False - -def generate_license_key(license_type='full'): - """ - Generiert einen Lizenzschlüssel im Format: AF-F-YYYYMM-XXXX-YYYY-ZZZZ - - AF = Account Factory (Produktkennung) - F/T = F für Fullversion, T für Testversion - YYYY = Jahr - MM = Monat - XXXX-YYYY-ZZZZ = Zufällige alphanumerische Zeichen - """ - # Erlaubte Zeichen (ohne verwirrende wie 0/O, 1/I/l) - chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' - - # Datum-Teil - now = datetime.now(ZoneInfo("Europe/Berlin")) - date_part = now.strftime('%Y%m') - type_char = 'F' if license_type == 'full' else 'T' - - # Zufällige Teile generieren (3 Blöcke à 4 Zeichen) - parts = [] - for _ in range(3): - part = ''.join(secrets.choice(chars) for _ in range(4)) - parts.append(part) - - # Key zusammensetzen - key = f"AF-{type_char}-{date_part}-{parts[0]}-{parts[1]}-{parts[2]}" - - return key - -def validate_license_key(key): - """ - Validiert das License Key Format - Erwartet: AF-F-YYYYMM-XXXX-YYYY-ZZZZ oder AF-T-YYYYMM-XXXX-YYYY-ZZZZ - """ - if not key: - return False - - # Pattern für das neue Format - # AF- (fest) + F oder T + - + 6 Ziffern (YYYYMM) + - + 4 Zeichen + - + 4 Zeichen + - + 4 Zeichen - pattern = r'^AF-[FT]-\d{6}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$' - - # Großbuchstaben für Vergleich - return bool(re.match(pattern, key.upper())) - -@app.route("/login", methods=["GET", "POST"]) -def login(): - # Timing-Attack Schutz - Start Zeit merken - start_time = time.time() - - # IP-Adresse ermitteln - ip_address = get_client_ip() - - # Prüfen ob IP gesperrt ist - is_blocked, blocked_until = check_ip_blocked(ip_address) - if is_blocked: - time_remaining = (blocked_until - datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None)).total_seconds() / 3600 - error_msg = f"IP GESPERRT! Noch {time_remaining:.1f} Stunden warten." - return render_template("login.html", error=error_msg, error_type="blocked") - - # Anzahl bisheriger Versuche - attempt_count = get_login_attempts(ip_address) - - if request.method == "POST": - username = request.form.get("username") - password = request.form.get("password") - captcha_response = request.form.get("g-recaptcha-response") - - # CAPTCHA-Prüfung nur wenn Keys konfiguriert sind - recaptcha_site_key = os.getenv('RECAPTCHA_SITE_KEY') - if attempt_count >= CAPTCHA_AFTER_ATTEMPTS and recaptcha_site_key: - if not captcha_response: - # Timing-Attack Schutz - elapsed = time.time() - start_time - if elapsed < 1.0: - time.sleep(1.0 - elapsed) - return render_template("login.html", - error="CAPTCHA ERFORDERLICH!", - show_captcha=True, - error_type="captcha", - attempts_left=max(0, MAX_LOGIN_ATTEMPTS - attempt_count), - recaptcha_site_key=recaptcha_site_key) - - # CAPTCHA validieren - if not verify_recaptcha(captcha_response): - # Timing-Attack Schutz - elapsed = time.time() - start_time - if elapsed < 1.0: - time.sleep(1.0 - elapsed) - return render_template("login.html", - error="CAPTCHA UNGÜLTIG! Bitte erneut versuchen.", - show_captcha=True, - error_type="captcha", - attempts_left=max(0, MAX_LOGIN_ATTEMPTS - attempt_count), - recaptcha_site_key=recaptcha_site_key) - - # Check user in database first, fallback to env vars - user = get_user_by_username(username) - login_success = False - needs_2fa = False - - if user: - # Database user authentication - if verify_password(password, user['password_hash']): - login_success = True - needs_2fa = user['totp_enabled'] - else: - # Fallback to environment variables for backward compatibility - admin1_user = os.getenv("ADMIN1_USERNAME") - admin1_pass = os.getenv("ADMIN1_PASSWORD") - admin2_user = os.getenv("ADMIN2_USERNAME") - admin2_pass = os.getenv("ADMIN2_PASSWORD") - - if ((username == admin1_user and password == admin1_pass) or - (username == admin2_user and password == admin2_pass)): - login_success = True - - # Timing-Attack Schutz - Mindestens 1 Sekunde warten - elapsed = time.time() - start_time - if elapsed < 1.0: - time.sleep(1.0 - elapsed) - - if login_success: - # Erfolgreicher Login - if needs_2fa: - # Store temporary session for 2FA verification - session['temp_username'] = username - session['temp_user_id'] = user['id'] - session['awaiting_2fa'] = True - return redirect(url_for('verify_2fa')) - else: - # Complete login without 2FA - session.permanent = True # Aktiviert das Timeout - session['logged_in'] = True - session['username'] = username - session['user_id'] = user['id'] if user else None - session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() - reset_login_attempts(ip_address) - log_audit('LOGIN_SUCCESS', 'user', - additional_info=f"Erfolgreiche Anmeldung von IP: {ip_address}") - return redirect(url_for('dashboard')) - else: - # Fehlgeschlagener Login - error_message = record_failed_attempt(ip_address, username) - new_attempt_count = get_login_attempts(ip_address) - - # Prüfen ob jetzt gesperrt - is_now_blocked, _ = check_ip_blocked(ip_address) - if is_now_blocked: - log_audit('LOGIN_BLOCKED', 'security', - additional_info=f"IP {ip_address} wurde nach {MAX_LOGIN_ATTEMPTS} Versuchen gesperrt") - - return render_template("login.html", - error=error_message, - show_captcha=(new_attempt_count >= CAPTCHA_AFTER_ATTEMPTS and os.getenv('RECAPTCHA_SITE_KEY')), - error_type="failed", - attempts_left=max(0, MAX_LOGIN_ATTEMPTS - new_attempt_count), - recaptcha_site_key=os.getenv('RECAPTCHA_SITE_KEY')) - - # GET Request - return render_template("login.html", - show_captcha=(attempt_count >= CAPTCHA_AFTER_ATTEMPTS and os.getenv('RECAPTCHA_SITE_KEY')), - attempts_left=max(0, MAX_LOGIN_ATTEMPTS - attempt_count), - recaptcha_site_key=os.getenv('RECAPTCHA_SITE_KEY')) - -@app.route("/logout") -def logout(): - username = session.get('username', 'unknown') - log_audit('LOGOUT', 'user', additional_info=f"Abmeldung") - session.pop('logged_in', None) - session.pop('username', None) - session.pop('user_id', None) - session.pop('temp_username', None) - session.pop('temp_user_id', None) - session.pop('awaiting_2fa', None) - return redirect(url_for('login')) - -@app.route("/verify-2fa", methods=["GET", "POST"]) -def verify_2fa(): - if not session.get('awaiting_2fa'): - return redirect(url_for('login')) - - if request.method == "POST": - token = request.form.get('token', '').replace(' ', '') - username = session.get('temp_username') - user_id = session.get('temp_user_id') - - if not username or not user_id: - flash('Session expired. Please login again.', 'error') - return redirect(url_for('login')) - - user = get_user_by_username(username) - if not user: - flash('User not found.', 'error') - return redirect(url_for('login')) - - # Check if it's a backup code - if len(token) == 8 and token.isupper(): - # Try backup code - backup_codes = json.loads(user['backup_codes']) if user['backup_codes'] else [] - if verify_backup_code(token, backup_codes): - # Remove used backup code - code_hash = hash_backup_code(token) - backup_codes.remove(code_hash) - - conn = get_connection() - cur = conn.cursor() - cur.execute("UPDATE users SET backup_codes = %s WHERE id = %s", - (json.dumps(backup_codes), user_id)) - conn.commit() - cur.close() - conn.close() - - # Complete login - session.permanent = True - session['logged_in'] = True - session['username'] = username - session['user_id'] = user_id - session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() - session.pop('temp_username', None) - session.pop('temp_user_id', None) - session.pop('awaiting_2fa', None) - - flash('Login successful using backup code. Please generate new backup codes.', 'warning') - log_audit('LOGIN_2FA_BACKUP', 'user', additional_info=f"2FA login with backup code") - return redirect(url_for('dashboard')) - else: - # Try TOTP token - if verify_totp(user['totp_secret'], token): - # Complete login - session.permanent = True - session['logged_in'] = True - session['username'] = username - session['user_id'] = user_id - session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() - session.pop('temp_username', None) - session.pop('temp_user_id', None) - session.pop('awaiting_2fa', None) - - log_audit('LOGIN_2FA_SUCCESS', 'user', additional_info=f"2FA login successful") - return redirect(url_for('dashboard')) - - # Failed verification - conn = get_connection() - cur = conn.cursor() - cur.execute("UPDATE users SET failed_2fa_attempts = failed_2fa_attempts + 1, last_failed_2fa = %s WHERE id = %s", - (datetime.now(), user_id)) - conn.commit() - cur.close() - conn.close() - - flash('Invalid authentication code. Please try again.', 'error') - log_audit('LOGIN_2FA_FAILED', 'user', additional_info=f"Failed 2FA attempt") - - return render_template('verify_2fa.html') - -@app.route("/profile") -@login_required -def profile(): - user = get_user_by_username(session['username']) - if not user: - # For environment-based users, redirect with message - flash('Bitte führen Sie das Migrations-Script aus, um Passwort-Änderung und 2FA zu aktivieren.', 'info') - return redirect(url_for('dashboard')) - return render_template('profile.html', user=user) - -@app.route("/profile/change-password", methods=["POST"]) -@login_required -def change_password(): - current_password = request.form.get('current_password') - new_password = request.form.get('new_password') - confirm_password = request.form.get('confirm_password') - - user = get_user_by_username(session['username']) - - # Verify current password - if not verify_password(current_password, user['password_hash']): - flash('Current password is incorrect.', 'error') - return redirect(url_for('profile')) - - # Check new password - if new_password != confirm_password: - flash('New passwords do not match.', 'error') - return redirect(url_for('profile')) - - if len(new_password) < 8: - flash('Password must be at least 8 characters long.', 'error') - return redirect(url_for('profile')) - - # Update password - new_hash = hash_password(new_password) - conn = get_connection() - cur = conn.cursor() - cur.execute("UPDATE users SET password_hash = %s, last_password_change = %s WHERE id = %s", - (new_hash, datetime.now(), user['id'])) - conn.commit() - cur.close() - conn.close() - - log_audit('PASSWORD_CHANGE', 'user', entity_id=user['id'], - additional_info="Password changed successfully") - flash('Password changed successfully.', 'success') - return redirect(url_for('profile')) - -@app.route("/profile/setup-2fa") -@login_required -def setup_2fa(): - user = get_user_by_username(session['username']) - - if user['totp_enabled']: - flash('2FA is already enabled for your account.', 'info') - return redirect(url_for('profile')) - - # Generate new TOTP secret - totp_secret = generate_totp_secret() - session['temp_totp_secret'] = totp_secret - - # Generate QR code - qr_code = generate_qr_code(user['username'], totp_secret) - - return render_template('setup_2fa.html', - totp_secret=totp_secret, - qr_code=qr_code) - -@app.route("/profile/enable-2fa", methods=["POST"]) -@login_required -def enable_2fa(): - token = request.form.get('token', '').replace(' ', '') - totp_secret = session.get('temp_totp_secret') - - if not totp_secret: - flash('2FA setup session expired. Please try again.', 'error') - return redirect(url_for('setup_2fa')) - - # Verify the token - if not verify_totp(totp_secret, token): - flash('Invalid authentication code. Please try again.', 'error') - return redirect(url_for('setup_2fa')) - - # Generate backup codes - backup_codes = generate_backup_codes() - hashed_codes = [hash_backup_code(code) for code in backup_codes] - - # Enable 2FA - conn = get_connection() - cur = conn.cursor() - cur.execute(""" - UPDATE users - SET totp_secret = %s, totp_enabled = TRUE, backup_codes = %s - WHERE username = %s - """, (totp_secret, json.dumps(hashed_codes), session['username'])) - conn.commit() - cur.close() - conn.close() - - session.pop('temp_totp_secret', None) - - log_audit('2FA_ENABLED', 'user', additional_info="2FA enabled successfully") - - # Show backup codes - return render_template('backup_codes.html', backup_codes=backup_codes) - -@app.route("/profile/disable-2fa", methods=["POST"]) -@login_required -def disable_2fa(): - password = request.form.get('password') - user = get_user_by_username(session['username']) - - # Verify password - if not verify_password(password, user['password_hash']): - flash('Incorrect password.', 'error') - return redirect(url_for('profile')) - - # Disable 2FA - conn = get_connection() - cur = conn.cursor() - cur.execute(""" - UPDATE users - SET totp_enabled = FALSE, totp_secret = NULL, backup_codes = NULL - WHERE username = %s - """, (session['username'],)) - conn.commit() - cur.close() - conn.close() - - log_audit('2FA_DISABLED', 'user', additional_info="2FA disabled") - flash('2FA has been disabled for your account.', 'success') - return redirect(url_for('profile')) - -@app.route("/heartbeat", methods=['POST']) -@login_required -def heartbeat(): - """Endpoint für Session Keep-Alive - aktualisiert last_activity""" - # Aktualisiere last_activity nur wenn explizit angefordert - session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() - # Force session save - session.modified = True - - return jsonify({ - 'status': 'ok', - 'last_activity': session['last_activity'], - 'username': session.get('username') - }) - -@app.route("/api/generate-license-key", methods=['POST']) -@login_required -def api_generate_key(): - """API Endpoint zur Generierung eines neuen Lizenzschlüssels""" - try: - # Lizenztyp aus Request holen (default: full) - data = request.get_json() or {} - license_type = data.get('type', 'full') - - # Key generieren - key = generate_license_key(license_type) - - # Prüfen ob Key bereits existiert (sehr unwahrscheinlich aber sicher ist sicher) - conn = get_connection() - cur = conn.cursor() - - # Wiederhole bis eindeutiger Key gefunden - attempts = 0 - while attempts < 10: # Max 10 Versuche - cur.execute("SELECT 1 FROM licenses WHERE license_key = %s", (key,)) - if not cur.fetchone(): - break # Key ist eindeutig - key = generate_license_key(license_type) - attempts += 1 - - cur.close() - conn.close() - - # Log für Audit - log_audit('GENERATE_KEY', 'license', - additional_info={'type': license_type, 'key': key}) - - return jsonify({ - 'success': True, - 'key': key, - 'type': license_type - }) - - except Exception as e: - logging.error(f"Fehler bei Key-Generierung: {str(e)}") - return jsonify({ - 'success': False, - 'error': 'Fehler bei der Key-Generierung' - }), 500 - -@app.route("/api/customers", methods=['GET']) -@login_required -def api_customers(): - """API Endpoint für die Kundensuche mit Select2""" - try: - # Suchparameter - search = request.args.get('q', '').strip() - page = request.args.get('page', 1, type=int) - per_page = 20 - customer_id = request.args.get('id', type=int) - - conn = get_connection() - cur = conn.cursor() - - # Einzelnen Kunden per ID abrufen - if customer_id: - cur.execute(""" - SELECT c.id, c.name, c.email, - COUNT(l.id) as license_count - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - WHERE c.id = %s - GROUP BY c.id, c.name, c.email - """, (customer_id,)) - - customer = cur.fetchone() - results = [] - if customer: - results.append({ - 'id': customer[0], - 'text': f"{customer[1]} ({customer[2]})", - 'name': customer[1], - 'email': customer[2], - 'license_count': customer[3] - }) - - cur.close() - conn.close() - - return jsonify({ - 'results': results, - 'pagination': {'more': False} - }) - - # SQL Query mit optionaler Suche - elif search: - cur.execute(""" - SELECT c.id, c.name, c.email, - COUNT(l.id) as license_count - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - WHERE LOWER(c.name) LIKE LOWER(%s) - OR LOWER(c.email) LIKE LOWER(%s) - GROUP BY c.id, c.name, c.email - ORDER BY c.name - LIMIT %s OFFSET %s - """, (f'%{search}%', f'%{search}%', per_page, (page - 1) * per_page)) - else: - cur.execute(""" - SELECT c.id, c.name, c.email, - COUNT(l.id) as license_count - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - GROUP BY c.id, c.name, c.email - ORDER BY c.name - LIMIT %s OFFSET %s - """, (per_page, (page - 1) * per_page)) - - customers = cur.fetchall() - - # Format für Select2 - results = [] - for customer in customers: - results.append({ - 'id': customer[0], - 'text': f"{customer[1]} - {customer[2]} ({customer[3]} Lizenzen)", - 'name': customer[1], - 'email': customer[2], - 'license_count': customer[3] - }) - - # Gesamtanzahl für Pagination - if search: - cur.execute(""" - SELECT COUNT(*) FROM customers - WHERE LOWER(name) LIKE LOWER(%s) - OR LOWER(email) LIKE LOWER(%s) - """, (f'%{search}%', f'%{search}%')) - else: - cur.execute("SELECT COUNT(*) FROM customers") - - total_count = cur.fetchone()[0] - - cur.close() - conn.close() - - # Select2 Response Format - return jsonify({ - 'results': results, - 'pagination': { - 'more': (page * per_page) < total_count - } - }) - - except Exception as e: - logging.error(f"Fehler bei Kundensuche: {str(e)}") - return jsonify({ - 'results': [], - 'error': 'Fehler bei der Kundensuche' - }), 500 - -@app.route("/") -@login_required -def dashboard(): - conn = get_connection() - cur = conn.cursor() - - # Statistiken abrufen - # Gesamtanzahl Kunden (ohne Testdaten) - cur.execute("SELECT COUNT(*) FROM customers WHERE is_test = FALSE") - total_customers = cur.fetchone()[0] - - # Gesamtanzahl Lizenzen (ohne Testdaten) - cur.execute("SELECT COUNT(*) FROM licenses WHERE is_test = FALSE") - total_licenses = cur.fetchone()[0] - - # Aktive Lizenzen (nicht abgelaufen und is_active = true, ohne Testdaten) - cur.execute(""" - SELECT COUNT(*) FROM licenses - WHERE valid_until >= CURRENT_DATE AND is_active = TRUE AND is_test = FALSE - """) - active_licenses = cur.fetchone()[0] - - # Aktive Sessions - cur.execute("SELECT COUNT(*) FROM sessions WHERE is_active = TRUE") - active_sessions_count = cur.fetchone()[0] - - # Abgelaufene Lizenzen (ohne Testdaten) - cur.execute(""" - SELECT COUNT(*) FROM licenses - WHERE valid_until < CURRENT_DATE AND is_test = FALSE - """) - expired_licenses = cur.fetchone()[0] - - # Deaktivierte Lizenzen (ohne Testdaten) - cur.execute(""" - SELECT COUNT(*) FROM licenses - WHERE is_active = FALSE AND is_test = FALSE - """) - inactive_licenses = cur.fetchone()[0] - - # Lizenzen die in den nächsten 30 Tagen ablaufen (ohne Testdaten) - cur.execute(""" - SELECT COUNT(*) FROM licenses - WHERE valid_until >= CURRENT_DATE - AND valid_until < CURRENT_DATE + INTERVAL '30 days' - AND is_active = TRUE - AND is_test = FALSE - """) - expiring_soon = cur.fetchone()[0] - - # Testlizenzen vs Vollversionen (ohne Testdaten) - cur.execute(""" - SELECT license_type, COUNT(*) - FROM licenses - WHERE is_test = FALSE - GROUP BY license_type - """) - license_types = dict(cur.fetchall()) - - # Anzahl Testdaten - cur.execute("SELECT COUNT(*) FROM licenses WHERE is_test = TRUE") - test_data_count = cur.fetchone()[0] - - # Anzahl Test-Kunden - cur.execute("SELECT COUNT(*) FROM customers WHERE is_test = TRUE") - test_customers_count = cur.fetchone()[0] - - # Anzahl Test-Ressourcen - cur.execute("SELECT COUNT(*) FROM resource_pools WHERE is_test = TRUE") - test_resources_count = cur.fetchone()[0] - - # Letzte 5 erstellten Lizenzen (ohne Testdaten) - cur.execute(""" - SELECT l.id, l.license_key, c.name, l.valid_until, - CASE - WHEN l.is_active = FALSE THEN 'deaktiviert' - WHEN l.valid_until < CURRENT_DATE THEN 'abgelaufen' - WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'läuft bald ab' - ELSE 'aktiv' - END as status - FROM licenses l - JOIN customers c ON l.customer_id = c.id - WHERE l.is_test = FALSE - ORDER BY l.id DESC - LIMIT 5 - """) - recent_licenses = cur.fetchall() - - # Bald ablaufende Lizenzen (nächste 30 Tage, ohne Testdaten) - cur.execute(""" - SELECT l.id, l.license_key, c.name, l.valid_until, - l.valid_until - CURRENT_DATE as days_left - FROM licenses l - JOIN customers c ON l.customer_id = c.id - WHERE l.valid_until >= CURRENT_DATE - AND l.valid_until < CURRENT_DATE + INTERVAL '30 days' - AND l.is_active = TRUE - AND l.is_test = FALSE - ORDER BY l.valid_until - LIMIT 10 - """) - expiring_licenses = cur.fetchall() - - # Letztes Backup - cur.execute(""" - SELECT created_at, filesize, duration_seconds, backup_type, status - FROM backup_history - ORDER BY created_at DESC - LIMIT 1 - """) - last_backup_info = cur.fetchone() - - # Sicherheitsstatistiken - # Gesperrte IPs - cur.execute(""" - SELECT COUNT(*) FROM login_attempts - WHERE blocked_until IS NOT NULL AND blocked_until > CURRENT_TIMESTAMP - """) - blocked_ips_count = cur.fetchone()[0] - - # Fehlversuche heute - cur.execute(""" - SELECT COALESCE(SUM(attempt_count), 0) FROM login_attempts - WHERE last_attempt::date = CURRENT_DATE - """) - failed_attempts_today = cur.fetchone()[0] - - # Letzte 5 Sicherheitsereignisse - cur.execute(""" - SELECT - la.ip_address, - la.attempt_count, - la.last_attempt, - la.blocked_until, - la.last_username_tried, - la.last_error_message - FROM login_attempts la - ORDER BY la.last_attempt DESC - LIMIT 5 - """) - recent_security_events = [] - for event in cur.fetchall(): - recent_security_events.append({ - 'ip_address': event[0], - 'attempt_count': event[1], - 'last_attempt': event[2].strftime('%d.%m %H:%M'), - 'blocked_until': event[3].strftime('%d.%m %H:%M') if event[3] and event[3] > datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None) else None, - 'username_tried': event[4], - 'error_message': event[5] - }) - - # Sicherheitslevel berechnen - if blocked_ips_count > 5 or failed_attempts_today > 50: - security_level = 'danger' - security_level_text = 'KRITISCH' - elif blocked_ips_count > 2 or failed_attempts_today > 20: - security_level = 'warning' - security_level_text = 'ERHÖHT' - else: - security_level = 'success' - security_level_text = 'NORMAL' - - # Resource Pool Statistiken (nur Live-Daten, keine Testdaten) - cur.execute(""" - SELECT - resource_type, - COUNT(*) FILTER (WHERE status = 'available') as available, - COUNT(*) FILTER (WHERE status = 'allocated') as allocated, - COUNT(*) FILTER (WHERE status = 'quarantine') as quarantine, - COUNT(*) as total - FROM resource_pools - WHERE is_test = FALSE - GROUP BY resource_type - """) - - resource_stats = {} - resource_warning = None - - for row in cur.fetchall(): - available_percent = round((row[1] / row[4] * 100) if row[4] > 0 else 0, 1) - resource_stats[row[0]] = { - 'available': row[1], - 'allocated': row[2], - 'quarantine': row[3], - 'total': row[4], - 'available_percent': available_percent, - 'warning_level': 'danger' if row[1] < 50 else 'warning' if row[1] < 100 else 'success' - } - - # Warnung bei niedrigem Bestand - if row[1] < 50: - if not resource_warning: - resource_warning = f"Niedriger Bestand bei {row[0].upper()}: nur noch {row[1]} verfügbar!" - else: - resource_warning += f" | {row[0].upper()}: {row[1]}" - - cur.close() - conn.close() - - stats = { - 'total_customers': total_customers, - 'total_licenses': total_licenses, - 'active_licenses': active_licenses, - 'expired_licenses': expired_licenses, - 'inactive_licenses': inactive_licenses, - 'expiring_soon': expiring_soon, - 'full_licenses': license_types.get('full', 0), - 'test_licenses': license_types.get('test', 0), - 'test_data_count': test_data_count, - 'test_customers_count': test_customers_count, - 'test_resources_count': test_resources_count, - 'recent_licenses': recent_licenses, - 'expiring_licenses': expiring_licenses, - 'active_sessions': active_sessions_count, - 'last_backup': last_backup_info, - # Sicherheitsstatistiken - 'blocked_ips_count': blocked_ips_count, - 'failed_attempts_today': failed_attempts_today, - 'recent_security_events': recent_security_events, - 'security_level': security_level, - 'security_level_text': security_level_text, - 'resource_stats': resource_stats - } - - return render_template("dashboard.html", - stats=stats, - resource_stats=resource_stats, - resource_warning=resource_warning, - username=session.get('username')) - -@app.route("/create", methods=["GET", "POST"]) -@login_required -def create_license(): - if request.method == "POST": - customer_id = request.form.get("customer_id") - license_key = request.form["license_key"].upper() # Immer Großbuchstaben - license_type = request.form["license_type"] - valid_from = request.form["valid_from"] - is_test = request.form.get("is_test") == "on" # Checkbox value - - # Berechne valid_until basierend auf Laufzeit - duration = int(request.form.get("duration", 1)) - duration_type = request.form.get("duration_type", "years") - - from datetime import datetime, timedelta - from dateutil.relativedelta import relativedelta - - start_date = datetime.strptime(valid_from, "%Y-%m-%d") - - if duration_type == "days": - end_date = start_date + timedelta(days=duration) - elif duration_type == "months": - end_date = start_date + relativedelta(months=duration) - else: # years - end_date = start_date + relativedelta(years=duration) - - # Ein Tag abziehen, da der Starttag mitgezählt wird - end_date = end_date - timedelta(days=1) - valid_until = end_date.strftime("%Y-%m-%d") - - # Validiere License Key Format - if not validate_license_key(license_key): - flash('Ungültiges License Key Format! Erwartet: AF-YYYYMMFT-XXXX-YYYY-ZZZZ', 'error') - return redirect(url_for('create_license')) - - # Resource counts - domain_count = int(request.form.get("domain_count", 1)) - ipv4_count = int(request.form.get("ipv4_count", 1)) - phone_count = int(request.form.get("phone_count", 1)) - device_limit = int(request.form.get("device_limit", 3)) - - conn = get_connection() - cur = conn.cursor() - - try: - # Prüfe ob neuer Kunde oder bestehender - if customer_id == "new": - # Neuer Kunde - name = request.form.get("customer_name") - email = request.form.get("email") - - if not name: - flash('Kundenname ist erforderlich!', 'error') - return redirect(url_for('create_license')) - - # Prüfe ob E-Mail bereits existiert - if email: - cur.execute("SELECT id, name FROM customers WHERE LOWER(email) = LOWER(%s)", (email,)) - existing = cur.fetchone() - if existing: - flash(f'E-Mail bereits vergeben für Kunde: {existing[1]}', 'error') - return redirect(url_for('create_license')) - - # Kunde einfügen (erbt Test-Status von Lizenz) - cur.execute(""" - INSERT INTO customers (name, email, is_test, created_at) - VALUES (%s, %s, %s, NOW()) - RETURNING id - """, (name, email, is_test)) - customer_id = cur.fetchone()[0] - customer_info = {'name': name, 'email': email, 'is_test': is_test} - - # Audit-Log für neuen Kunden - log_audit('CREATE', 'customer', customer_id, - new_values={'name': name, 'email': email, 'is_test': is_test}) - else: - # Bestehender Kunde - hole Infos für Audit-Log - cur.execute("SELECT name, email, is_test FROM customers WHERE id = %s", (customer_id,)) - customer_data = cur.fetchone() - if not customer_data: - flash('Kunde nicht gefunden!', 'error') - return redirect(url_for('create_license')) - customer_info = {'name': customer_data[0], 'email': customer_data[1]} - - # Wenn Kunde Test-Kunde ist, Lizenz auch als Test markieren - if customer_data[2]: # is_test des Kunden - is_test = True - - # Lizenz hinzufügen - cur.execute(""" - INSERT INTO licenses (license_key, customer_id, license_type, valid_from, valid_until, is_active, - domain_count, ipv4_count, phone_count, device_limit, is_test) - VALUES (%s, %s, %s, %s, %s, TRUE, %s, %s, %s, %s, %s) - RETURNING id - """, (license_key, customer_id, license_type, valid_from, valid_until, - domain_count, ipv4_count, phone_count, device_limit, is_test)) - license_id = cur.fetchone()[0] - - # Ressourcen zuweisen - try: - # Prüfe Verfügbarkeit - cur.execute(""" - SELECT - (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s) as domains, - (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s) as ipv4s, - (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s) as phones - """, (is_test, is_test, is_test)) - available = cur.fetchone() - - if available[0] < domain_count: - raise ValueError(f"Nicht genügend Domains verfügbar (benötigt: {domain_count}, verfügbar: {available[0]})") - if available[1] < ipv4_count: - raise ValueError(f"Nicht genügend IPv4-Adressen verfügbar (benötigt: {ipv4_count}, verfügbar: {available[1]})") - if available[2] < phone_count: - raise ValueError(f"Nicht genügend Telefonnummern verfügbar (benötigt: {phone_count}, verfügbar: {available[2]})") - - # Domains zuweisen - if domain_count > 0: - cur.execute(""" - SELECT id FROM resource_pools - WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s - LIMIT %s FOR UPDATE - """, (is_test, domain_count)) - for (resource_id,) in cur.fetchall(): - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], resource_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, resource_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (resource_id, license_id, session['username'], get_client_ip())) - - # IPv4s zuweisen - if ipv4_count > 0: - cur.execute(""" - SELECT id FROM resource_pools - WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s - LIMIT %s FOR UPDATE - """, (is_test, ipv4_count)) - for (resource_id,) in cur.fetchall(): - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], resource_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, resource_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (resource_id, license_id, session['username'], get_client_ip())) - - # Telefonnummern zuweisen - if phone_count > 0: - cur.execute(""" - SELECT id FROM resource_pools - WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s - LIMIT %s FOR UPDATE - """, (is_test, phone_count)) - for (resource_id,) in cur.fetchall(): - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], resource_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, resource_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (resource_id, license_id, session['username'], get_client_ip())) - - except ValueError as e: - conn.rollback() - flash(str(e), 'error') - return redirect(url_for('create_license')) - - conn.commit() - - # Audit-Log - log_audit('CREATE', 'license', license_id, - new_values={ - 'license_key': license_key, - 'customer_name': customer_info['name'], - 'customer_email': customer_info['email'], - 'license_type': license_type, - 'valid_from': valid_from, - 'valid_until': valid_until, - 'device_limit': device_limit, - 'is_test': is_test - }) - - flash(f'Lizenz {license_key} erfolgreich erstellt!', 'success') - - except Exception as e: - conn.rollback() - logging.error(f"Fehler beim Erstellen der Lizenz: {str(e)}") - flash('Fehler beim Erstellen der Lizenz!', 'error') - finally: - cur.close() - conn.close() - - # Preserve show_test parameter if present - redirect_url = "/create" - if request.args.get('show_test') == 'true': - redirect_url += "?show_test=true" - return redirect(redirect_url) - - # Unterstützung für vorausgewählten Kunden - preselected_customer_id = request.args.get('customer_id', type=int) - return render_template("index.html", username=session.get('username'), preselected_customer_id=preselected_customer_id) - -@app.route("/batch", methods=["GET", "POST"]) -@login_required -def batch_licenses(): - """Batch-Generierung mehrerer Lizenzen für einen Kunden""" - if request.method == "POST": - # Formulardaten - customer_id = request.form.get("customer_id") - license_type = request.form["license_type"] - quantity = int(request.form["quantity"]) - valid_from = request.form["valid_from"] - is_test = request.form.get("is_test") == "on" # Checkbox value - - # Berechne valid_until basierend auf Laufzeit - duration = int(request.form.get("duration", 1)) - duration_type = request.form.get("duration_type", "years") - - from datetime import datetime, timedelta - from dateutil.relativedelta import relativedelta - - start_date = datetime.strptime(valid_from, "%Y-%m-%d") - - if duration_type == "days": - end_date = start_date + timedelta(days=duration) - elif duration_type == "months": - end_date = start_date + relativedelta(months=duration) - else: # years - end_date = start_date + relativedelta(years=duration) - - # Ein Tag abziehen, da der Starttag mitgezählt wird - end_date = end_date - timedelta(days=1) - valid_until = end_date.strftime("%Y-%m-%d") - - # Resource counts - domain_count = int(request.form.get("domain_count", 1)) - ipv4_count = int(request.form.get("ipv4_count", 1)) - phone_count = int(request.form.get("phone_count", 1)) - device_limit = int(request.form.get("device_limit", 3)) - - # Sicherheitslimit - if quantity < 1 or quantity > 100: - flash('Anzahl muss zwischen 1 und 100 liegen!', 'error') - return redirect(url_for('batch_licenses')) - - conn = get_connection() - cur = conn.cursor() - - try: - # Prüfe ob neuer Kunde oder bestehender - if customer_id == "new": - # Neuer Kunde - name = request.form.get("customer_name") - email = request.form.get("email") - - if not name: - flash('Kundenname ist erforderlich!', 'error') - return redirect(url_for('batch_licenses')) - - # Prüfe ob E-Mail bereits existiert - if email: - cur.execute("SELECT id, name FROM customers WHERE LOWER(email) = LOWER(%s)", (email,)) - existing = cur.fetchone() - if existing: - flash(f'E-Mail bereits vergeben für Kunde: {existing[1]}', 'error') - return redirect(url_for('batch_licenses')) - - # Kunde einfügen (erbt Test-Status von Lizenz) - cur.execute(""" - INSERT INTO customers (name, email, is_test, created_at) - VALUES (%s, %s, %s, NOW()) - RETURNING id - """, (name, email, is_test)) - customer_id = cur.fetchone()[0] - - # Audit-Log für neuen Kunden - log_audit('CREATE', 'customer', customer_id, - new_values={'name': name, 'email': email, 'is_test': is_test}) - else: - # Bestehender Kunde - hole Infos - cur.execute("SELECT name, email, is_test FROM customers WHERE id = %s", (customer_id,)) - customer_data = cur.fetchone() - if not customer_data: - flash('Kunde nicht gefunden!', 'error') - return redirect(url_for('batch_licenses')) - name = customer_data[0] - email = customer_data[1] - - # Wenn Kunde Test-Kunde ist, Lizenzen auch als Test markieren - if customer_data[2]: # is_test des Kunden - is_test = True - - # Prüfe Ressourcen-Verfügbarkeit für gesamten Batch - total_domains_needed = domain_count * quantity - total_ipv4s_needed = ipv4_count * quantity - total_phones_needed = phone_count * quantity - - cur.execute(""" - SELECT - (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s) as domains, - (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s) as ipv4s, - (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s) as phones - """, (is_test, is_test, is_test)) - available = cur.fetchone() - - if available[0] < total_domains_needed: - flash(f"Nicht genügend Domains verfügbar (benötigt: {total_domains_needed}, verfügbar: {available[0]})", 'error') - return redirect(url_for('batch_licenses')) - if available[1] < total_ipv4s_needed: - flash(f"Nicht genügend IPv4-Adressen verfügbar (benötigt: {total_ipv4s_needed}, verfügbar: {available[1]})", 'error') - return redirect(url_for('batch_licenses')) - if available[2] < total_phones_needed: - flash(f"Nicht genügend Telefonnummern verfügbar (benötigt: {total_phones_needed}, verfügbar: {available[2]})", 'error') - return redirect(url_for('batch_licenses')) - - # Lizenzen generieren und speichern - generated_licenses = [] - for i in range(quantity): - # Eindeutigen Key generieren - attempts = 0 - while attempts < 10: - license_key = generate_license_key(license_type) - cur.execute("SELECT 1 FROM licenses WHERE license_key = %s", (license_key,)) - if not cur.fetchone(): - break - attempts += 1 - - # Lizenz einfügen - cur.execute(""" - INSERT INTO licenses (license_key, customer_id, license_type, is_test, - valid_from, valid_until, is_active, - domain_count, ipv4_count, phone_count, device_limit) - VALUES (%s, %s, %s, %s, %s, %s, true, %s, %s, %s, %s) - RETURNING id - """, (license_key, customer_id, license_type, is_test, valid_from, valid_until, - domain_count, ipv4_count, phone_count, device_limit)) - license_id = cur.fetchone()[0] - - # Ressourcen für diese Lizenz zuweisen - # Domains - if domain_count > 0: - cur.execute(""" - SELECT id FROM resource_pools - WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s - LIMIT %s FOR UPDATE - """, (is_test, domain_count)) - for (resource_id,) in cur.fetchall(): - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], resource_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, resource_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (resource_id, license_id, session['username'], get_client_ip())) - - # IPv4s - if ipv4_count > 0: - cur.execute(""" - SELECT id FROM resource_pools - WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s - LIMIT %s FOR UPDATE - """, (is_test, ipv4_count)) - for (resource_id,) in cur.fetchall(): - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], resource_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, resource_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (resource_id, license_id, session['username'], get_client_ip())) - - # Telefonnummern - if phone_count > 0: - cur.execute(""" - SELECT id FROM resource_pools - WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s - LIMIT %s FOR UPDATE - """, (is_test, phone_count)) - for (resource_id,) in cur.fetchall(): - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], resource_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, resource_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (resource_id, license_id, session['username'], get_client_ip())) - - generated_licenses.append({ - 'id': license_id, - 'key': license_key, - 'type': license_type - }) - - conn.commit() - - # Audit-Log - log_audit('CREATE_BATCH', 'license', - new_values={'customer': name, 'quantity': quantity, 'type': license_type}, - additional_info=f"Batch-Generierung von {quantity} Lizenzen") - - # Session für Export speichern - session['batch_export'] = { - 'customer': name, - 'email': email, - 'licenses': generated_licenses, - 'valid_from': valid_from, - 'valid_until': valid_until, - 'timestamp': datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() - } - - flash(f'{quantity} Lizenzen erfolgreich generiert!', 'success') - return render_template("batch_result.html", - customer=name, - email=email, - licenses=generated_licenses, - valid_from=valid_from, - valid_until=valid_until) - - except Exception as e: - conn.rollback() - logging.error(f"Fehler bei Batch-Generierung: {str(e)}") - flash('Fehler bei der Batch-Generierung!', 'error') - return redirect(url_for('batch_licenses')) - finally: - cur.close() - conn.close() - - # GET Request - return render_template("batch_form.html") - -@app.route("/batch/export") -@login_required -def export_batch(): - """Exportiert die zuletzt generierten Batch-Lizenzen""" - batch_data = session.get('batch_export') - if not batch_data: - flash('Keine Batch-Daten zum Exportieren vorhanden!', 'error') - return redirect(url_for('batch_licenses')) - - # CSV generieren - output = io.StringIO() - output.write('\ufeff') # UTF-8 BOM für Excel - - # Header - output.write(f"Kunde: {batch_data['customer']}\n") - output.write(f"E-Mail: {batch_data['email']}\n") - output.write(f"Generiert am: {datetime.fromisoformat(batch_data['timestamp']).strftime('%d.%m.%Y %H:%M')}\n") - output.write(f"Gültig von: {batch_data['valid_from']} bis {batch_data['valid_until']}\n") - output.write("\n") - output.write("Nr;Lizenzschlüssel;Typ\n") - - # Lizenzen - for i, license in enumerate(batch_data['licenses'], 1): - typ_text = "Vollversion" if license['type'] == 'full' else "Testversion" - output.write(f"{i};{license['key']};{typ_text}\n") - - output.seek(0) - - # Audit-Log - log_audit('EXPORT', 'batch_licenses', - additional_info=f"Export von {len(batch_data['licenses'])} Batch-Lizenzen") - - return send_file( - io.BytesIO(output.getvalue().encode('utf-8-sig')), - mimetype='text/csv', - as_attachment=True, - download_name=f"batch_licenses_{batch_data['customer'].replace(' ', '_')}_{datetime.now(ZoneInfo('Europe/Berlin')).strftime('%Y%m%d_%H%M%S')}.csv" - ) - -@app.route("/licenses") -@login_required -def licenses(): - # Redirect zur kombinierten Ansicht - return redirect("/customers-licenses") - -@app.route("/license/edit/", methods=["GET", "POST"]) -@login_required -def edit_license(license_id): - conn = get_connection() - cur = conn.cursor() - - if request.method == "POST": - # Alte Werte für Audit-Log abrufen - cur.execute(""" - SELECT license_key, license_type, valid_from, valid_until, is_active, is_test, device_limit - FROM licenses WHERE id = %s - """, (license_id,)) - old_license = cur.fetchone() - - # Update license - license_key = request.form["license_key"] - license_type = request.form["license_type"] - valid_from = request.form["valid_from"] - valid_until = request.form["valid_until"] - is_active = request.form.get("is_active") == "on" - is_test = request.form.get("is_test") == "on" - device_limit = int(request.form.get("device_limit", 3)) - - cur.execute(""" - UPDATE licenses - SET license_key = %s, license_type = %s, valid_from = %s, - valid_until = %s, is_active = %s, is_test = %s, device_limit = %s - WHERE id = %s - """, (license_key, license_type, valid_from, valid_until, is_active, is_test, device_limit, license_id)) - - conn.commit() - - # Audit-Log - log_audit('UPDATE', 'license', license_id, - old_values={ - 'license_key': old_license[0], - 'license_type': old_license[1], - 'valid_from': str(old_license[2]), - 'valid_until': str(old_license[3]), - 'is_active': old_license[4], - 'is_test': old_license[5], - 'device_limit': old_license[6] - }, - new_values={ - 'license_key': license_key, - 'license_type': license_type, - 'valid_from': valid_from, - 'valid_until': valid_until, - 'is_active': is_active, - 'is_test': is_test, - 'device_limit': device_limit - }) - - cur.close() - conn.close() - - # Redirect zurück zu customers-licenses mit beibehaltenen Parametern - redirect_url = "/customers-licenses" - - # Behalte show_test Parameter bei (aus Form oder GET-Parameter) - show_test = request.form.get('show_test') or request.args.get('show_test') - if show_test == 'true': - redirect_url += "?show_test=true" - - # Behalte customer_id bei wenn vorhanden - if request.referrer and 'customer_id=' in request.referrer: - import re - match = re.search(r'customer_id=(\d+)', request.referrer) - if match: - connector = "&" if "?" in redirect_url else "?" - redirect_url += f"{connector}customer_id={match.group(1)}" - - return redirect(redirect_url) - - # Get license data - cur.execute(""" - SELECT l.id, l.license_key, c.name, c.email, l.license_type, - l.valid_from, l.valid_until, l.is_active, c.id, l.is_test, l.device_limit - FROM licenses l - JOIN customers c ON l.customer_id = c.id - WHERE l.id = %s - """, (license_id,)) - - license = cur.fetchone() - cur.close() - conn.close() - - if not license: - return redirect("/licenses") - - return render_template("edit_license.html", license=license, username=session.get('username')) - -@app.route("/license/delete/", methods=["POST"]) -@login_required -def delete_license(license_id): - conn = get_connection() - cur = conn.cursor() - - # Lizenzdetails für Audit-Log abrufen - cur.execute(""" - SELECT l.license_key, c.name, l.license_type - FROM licenses l - JOIN customers c ON l.customer_id = c.id - WHERE l.id = %s - """, (license_id,)) - license_info = cur.fetchone() - - cur.execute("DELETE FROM licenses WHERE id = %s", (license_id,)) - - conn.commit() - - # Audit-Log - if license_info: - log_audit('DELETE', 'license', license_id, - old_values={ - 'license_key': license_info[0], - 'customer_name': license_info[1], - 'license_type': license_info[2] - }) - - cur.close() - conn.close() - - return redirect("/licenses") - -@app.route("/customers") -@login_required -def customers(): - # Redirect zur kombinierten Ansicht - return redirect("/customers-licenses") - -@app.route("/customer/edit/", methods=["GET", "POST"]) -@login_required -def edit_customer(customer_id): - conn = get_connection() - cur = conn.cursor() - - if request.method == "POST": - # Alte Werte für Audit-Log abrufen - cur.execute("SELECT name, email, is_test FROM customers WHERE id = %s", (customer_id,)) - old_customer = cur.fetchone() - - # Update customer - name = request.form["name"] - email = request.form["email"] - is_test = request.form.get("is_test") == "on" - - cur.execute(""" - UPDATE customers - SET name = %s, email = %s, is_test = %s - WHERE id = %s - """, (name, email, is_test, customer_id)) - - conn.commit() - - # Audit-Log - log_audit('UPDATE', 'customer', customer_id, - old_values={ - 'name': old_customer[0], - 'email': old_customer[1], - 'is_test': old_customer[2] - }, - new_values={ - 'name': name, - 'email': email, - 'is_test': is_test - }) - - cur.close() - conn.close() - - # Redirect zurück zu customers-licenses mit beibehaltenen Parametern - redirect_url = "/customers-licenses" - - # Behalte show_test Parameter bei (aus Form oder GET-Parameter) - show_test = request.form.get('show_test') or request.args.get('show_test') - if show_test == 'true': - redirect_url += "?show_test=true" - - # Behalte customer_id bei (immer der aktuelle Kunde) - connector = "&" if "?" in redirect_url else "?" - redirect_url += f"{connector}customer_id={customer_id}" - - return redirect(redirect_url) - - # Get customer data with licenses - cur.execute(""" - SELECT id, name, email, created_at, is_test FROM customers WHERE id = %s - """, (customer_id,)) - - customer = cur.fetchone() - if not customer: - cur.close() - conn.close() - return "Kunde nicht gefunden", 404 - - - # Get customer's licenses - cur.execute(""" - SELECT id, license_key, license_type, valid_from, valid_until, is_active - FROM licenses - WHERE customer_id = %s - ORDER BY valid_until DESC - """, (customer_id,)) - - licenses = cur.fetchall() - - cur.close() - conn.close() - - if not customer: - return redirect("/customers-licenses") - - return render_template("edit_customer.html", customer=customer, licenses=licenses, username=session.get('username')) - -@app.route("/customer/create", methods=["GET", "POST"]) -@login_required -def create_customer(): - """Erstellt einen neuen Kunden ohne Lizenz""" - if request.method == "POST": - name = request.form.get('name') - email = request.form.get('email') - is_test = request.form.get('is_test') == 'on' - - if not name or not email: - flash("Name und E-Mail sind Pflichtfelder!", "error") - return render_template("create_customer.html", username=session.get('username')) - - conn = get_connection() - cur = conn.cursor() - - try: - # Prüfen ob E-Mail bereits existiert - cur.execute("SELECT id, name FROM customers WHERE email = %s", (email,)) - existing = cur.fetchone() - if existing: - flash(f"Ein Kunde mit der E-Mail '{email}' existiert bereits: {existing[1]}", "error") - return render_template("create_customer.html", username=session.get('username')) - - # Kunde erstellen - cur.execute(""" - INSERT INTO customers (name, email, created_at, is_test) - VALUES (%s, %s, %s, %s) RETURNING id - """, (name, email, datetime.now(), is_test)) - - customer_id = cur.fetchone()[0] - conn.commit() - - # Audit-Log - log_audit('CREATE', 'customer', customer_id, - new_values={ - 'name': name, - 'email': email, - 'is_test': is_test - }) - - flash(f"Kunde '{name}' wurde erfolgreich angelegt!", "success") - return redirect(f"/customer/edit/{customer_id}") - - except Exception as e: - conn.rollback() - flash(f"Fehler beim Anlegen des Kunden: {str(e)}", "error") - return render_template("create_customer.html", username=session.get('username')) - finally: - cur.close() - conn.close() - - # GET Request - Formular anzeigen - return render_template("create_customer.html", username=session.get('username')) - -@app.route("/customer/delete/", methods=["POST"]) -@login_required -def delete_customer(customer_id): - conn = get_connection() - cur = conn.cursor() - - # Prüfen ob Kunde Lizenzen hat - cur.execute("SELECT COUNT(*) FROM licenses WHERE customer_id = %s", (customer_id,)) - license_count = cur.fetchone()[0] - - if license_count > 0: - # Kunde hat Lizenzen - nicht löschen - cur.close() - conn.close() - return redirect("/customers") - - # Kundendetails für Audit-Log abrufen - cur.execute("SELECT name, email FROM customers WHERE id = %s", (customer_id,)) - customer_info = cur.fetchone() - - # Kunde löschen wenn keine Lizenzen vorhanden - cur.execute("DELETE FROM customers WHERE id = %s", (customer_id,)) - - conn.commit() - - # Audit-Log - if customer_info: - log_audit('DELETE', 'customer', customer_id, - old_values={ - 'name': customer_info[0], - 'email': customer_info[1] - }) - - cur.close() - conn.close() - - return redirect("/customers") - -@app.route("/customers-licenses") -@login_required -def customers_licenses(): - """Kombinierte Ansicht für Kunden und deren Lizenzen""" - conn = get_connection() - cur = conn.cursor() - - # Hole alle Kunden mit Lizenzstatistiken (inkl. Testkunden wenn gewünscht) - show_test = request.args.get('show_test', 'false').lower() == 'true' - - query = """ - SELECT - c.id, - c.name, - c.email, - c.created_at, - COUNT(l.id) as total_licenses, - COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE THEN 1 END) as active_licenses, - COUNT(CASE WHEN l.valid_until < CURRENT_DATE THEN 1 END) as expired_licenses - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - """ - - if not show_test: - query += " WHERE c.is_test = FALSE" - - query += """ - GROUP BY c.id, c.name, c.email, c.created_at - ORDER BY c.name - """ - - cur.execute(query) - customers = cur.fetchall() - - # Hole ausgewählten Kunden nur wenn explizit in URL angegeben - selected_customer_id = request.args.get('customer_id', type=int) - licenses = [] - selected_customer = None - - if customers and selected_customer_id: - # Hole Daten des ausgewählten Kunden - for customer in customers: - if customer[0] == selected_customer_id: - selected_customer = customer - break - - # Hole Lizenzen des ausgewählten Kunden - if selected_customer: - cur.execute(""" - SELECT - l.id, - l.license_key, - l.license_type, - l.valid_from, - l.valid_until, - l.is_active, - CASE - WHEN l.is_active = FALSE THEN 'deaktiviert' - WHEN l.valid_until < CURRENT_DATE THEN 'abgelaufen' - WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'läuft bald ab' - ELSE 'aktiv' - END as status, - l.domain_count, - l.ipv4_count, - l.phone_count, - l.device_limit, - (SELECT COUNT(*) FROM device_registrations WHERE license_id = l.id AND is_active = TRUE) as active_devices, - -- Actual resource counts - (SELECT COUNT(*) FROM license_resources lr - JOIN resource_pools rp ON lr.resource_id = rp.id - WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'domain') as actual_domain_count, - (SELECT COUNT(*) FROM license_resources lr - JOIN resource_pools rp ON lr.resource_id = rp.id - WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'ipv4') as actual_ipv4_count, - (SELECT COUNT(*) FROM license_resources lr - JOIN resource_pools rp ON lr.resource_id = rp.id - WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'phone') as actual_phone_count - FROM licenses l - WHERE l.customer_id = %s - ORDER BY l.created_at DESC, l.id DESC - """, (selected_customer_id,)) - licenses = cur.fetchall() - - cur.close() - conn.close() - - return render_template("customers_licenses.html", - customers=customers, - selected_customer=selected_customer, - selected_customer_id=selected_customer_id, - licenses=licenses, - show_test=show_test) - -@app.route("/api/customer//licenses") -@login_required -def api_customer_licenses(customer_id): - """API-Endpoint für AJAX-Abruf der Lizenzen eines Kunden""" - conn = get_connection() - cur = conn.cursor() - - # Hole Lizenzen des Kunden - cur.execute(""" - SELECT - l.id, - l.license_key, - l.license_type, - l.valid_from, - l.valid_until, - l.is_active, - CASE - WHEN l.is_active = FALSE THEN 'deaktiviert' - WHEN l.valid_until < CURRENT_DATE THEN 'abgelaufen' - WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'läuft bald ab' - ELSE 'aktiv' - END as status, - l.domain_count, - l.ipv4_count, - l.phone_count, - l.device_limit, - (SELECT COUNT(*) FROM device_registrations WHERE license_id = l.id AND is_active = TRUE) as active_devices, - -- Actual resource counts - (SELECT COUNT(*) FROM license_resources lr - JOIN resource_pools rp ON lr.resource_id = rp.id - WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'domain') as actual_domain_count, - (SELECT COUNT(*) FROM license_resources lr - JOIN resource_pools rp ON lr.resource_id = rp.id - WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'ipv4') as actual_ipv4_count, - (SELECT COUNT(*) FROM license_resources lr - JOIN resource_pools rp ON lr.resource_id = rp.id - WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'phone') as actual_phone_count - FROM licenses l - WHERE l.customer_id = %s - ORDER BY l.created_at DESC, l.id DESC - """, (customer_id,)) - - licenses = [] - for row in cur.fetchall(): - license_id = row[0] - - # Hole die konkreten zugewiesenen Ressourcen für diese Lizenz - cur.execute(""" - SELECT rp.id, rp.resource_type, rp.resource_value, lr.assigned_at - FROM resource_pools rp - JOIN license_resources lr ON rp.id = lr.resource_id - WHERE lr.license_id = %s AND lr.is_active = true - ORDER BY rp.resource_type, rp.resource_value - """, (license_id,)) - - resources = { - 'domains': [], - 'ipv4s': [], - 'phones': [] - } - - for res_row in cur.fetchall(): - resource_info = { - 'id': res_row[0], - 'value': res_row[2], - 'assigned_at': res_row[3].strftime('%d.%m.%Y') if res_row[3] else '' - } - - if res_row[1] == 'domain': - resources['domains'].append(resource_info) - elif res_row[1] == 'ipv4': - resources['ipv4s'].append(resource_info) - elif res_row[1] == 'phone': - resources['phones'].append(resource_info) - - licenses.append({ - 'id': row[0], - 'license_key': row[1], - 'license_type': row[2], - 'valid_from': row[3].strftime('%d.%m.%Y') if row[3] else '', - 'valid_until': row[4].strftime('%d.%m.%Y') if row[4] else '', - 'is_active': row[5], - 'status': row[6], - 'domain_count': row[7], # limit - 'ipv4_count': row[8], # limit - 'phone_count': row[9], # limit - 'device_limit': row[10], - 'active_devices': row[11], - 'actual_domain_count': row[12], # actual count - 'actual_ipv4_count': row[13], # actual count - 'actual_phone_count': row[14], # actual count - 'resources': resources - }) - - cur.close() - conn.close() - - return jsonify({ - 'success': True, - 'licenses': licenses, - 'count': len(licenses) - }) - -@app.route("/api/customer//quick-stats") -@login_required -def api_customer_quick_stats(customer_id): - """API-Endpoint für Schnellstatistiken eines Kunden""" - conn = get_connection() - cur = conn.cursor() - - # Hole Kundenstatistiken - cur.execute(""" - SELECT - COUNT(l.id) as total_licenses, - COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE THEN 1 END) as active_licenses, - COUNT(CASE WHEN l.valid_until < CURRENT_DATE THEN 1 END) as expired_licenses, - COUNT(CASE WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' AND l.valid_until >= CURRENT_DATE THEN 1 END) as expiring_soon - FROM licenses l - WHERE l.customer_id = %s - """, (customer_id,)) - - stats = cur.fetchone() - - cur.close() - conn.close() - - return jsonify({ - 'success': True, - 'stats': { - 'total': stats[0], - 'active': stats[1], - 'expired': stats[2], - 'expiring_soon': stats[3] - } - }) - -@app.route("/api/license//quick-edit", methods=['POST']) -@login_required -def api_license_quick_edit(license_id): - """API-Endpoint für schnelle Lizenz-Bearbeitung""" - conn = get_connection() - cur = conn.cursor() - - try: - data = request.get_json() - - # Hole alte Werte für Audit-Log - cur.execute(""" - SELECT is_active, valid_until, license_type - FROM licenses WHERE id = %s - """, (license_id,)) - old_values = cur.fetchone() - - if not old_values: - return jsonify({'success': False, 'error': 'Lizenz nicht gefunden'}), 404 - - # Update-Felder vorbereiten - updates = [] - params = [] - new_values = {} - - if 'is_active' in data: - updates.append("is_active = %s") - params.append(data['is_active']) - new_values['is_active'] = data['is_active'] - - if 'valid_until' in data: - updates.append("valid_until = %s") - params.append(data['valid_until']) - new_values['valid_until'] = data['valid_until'] - - if 'license_type' in data: - updates.append("license_type = %s") - params.append(data['license_type']) - new_values['license_type'] = data['license_type'] - - if updates: - params.append(license_id) - cur.execute(f""" - UPDATE licenses - SET {', '.join(updates)} - WHERE id = %s - """, params) - - conn.commit() - - # Audit-Log - log_audit('UPDATE', 'license', license_id, - old_values={ - 'is_active': old_values[0], - 'valid_until': old_values[1].isoformat() if old_values[1] else None, - 'license_type': old_values[2] - }, - new_values=new_values) - - cur.close() - conn.close() - - return jsonify({'success': True}) - - except Exception as e: - conn.rollback() - cur.close() - conn.close() - return jsonify({'success': False, 'error': str(e)}), 500 - -@app.route("/api/license//resources") -@login_required -def api_license_resources(license_id): - """API-Endpoint für detaillierte Ressourcen-Informationen einer Lizenz""" - conn = get_connection() - cur = conn.cursor() - - try: - # Hole die konkreten zugewiesenen Ressourcen für diese Lizenz - cur.execute(""" - SELECT rp.id, rp.resource_type, rp.resource_value, lr.assigned_at - FROM resource_pools rp - JOIN license_resources lr ON rp.id = lr.resource_id - WHERE lr.license_id = %s AND lr.is_active = true - ORDER BY rp.resource_type, rp.resource_value - """, (license_id,)) - - resources = { - 'domains': [], - 'ipv4s': [], - 'phones': [] - } - - for row in cur.fetchall(): - resource_info = { - 'id': row[0], - 'value': row[2], - 'assigned_at': row[3].strftime('%d.%m.%Y') if row[3] else '' - } - - if row[1] == 'domain': - resources['domains'].append(resource_info) - elif row[1] == 'ipv4': - resources['ipv4s'].append(resource_info) - elif row[1] == 'phone': - resources['phones'].append(resource_info) - - cur.close() - conn.close() - - return jsonify({ - 'success': True, - 'resources': resources - }) - - except Exception as e: - cur.close() - conn.close() - return jsonify({'success': False, 'error': str(e)}), 500 - -@app.route("/sessions") -@login_required -def sessions(): - conn = get_connection() - cur = conn.cursor() - - # Sortierparameter - active_sort = request.args.get('active_sort', 'last_heartbeat') - active_order = request.args.get('active_order', 'desc') - ended_sort = request.args.get('ended_sort', 'ended_at') - ended_order = request.args.get('ended_order', 'desc') - - # Whitelist für erlaubte Sortierfelder - Aktive Sessions - active_sort_fields = { - 'customer': 'c.name', - 'license': 'l.license_key', - 'ip': 's.ip_address', - 'started': 's.started_at', - 'last_heartbeat': 's.last_heartbeat', - 'inactive': 'minutes_inactive' - } - - # Whitelist für erlaubte Sortierfelder - Beendete Sessions - ended_sort_fields = { - 'customer': 'c.name', - 'license': 'l.license_key', - 'ip': 's.ip_address', - 'started': 's.started_at', - 'ended_at': 's.ended_at', - 'duration': 'duration_minutes' - } - - # Validierung - if active_sort not in active_sort_fields: - active_sort = 'last_heartbeat' - if ended_sort not in ended_sort_fields: - ended_sort = 'ended_at' - if active_order not in ['asc', 'desc']: - active_order = 'desc' - if ended_order not in ['asc', 'desc']: - ended_order = 'desc' - - # Aktive Sessions abrufen - cur.execute(f""" - SELECT s.id, s.session_id, l.license_key, c.name, s.ip_address, - s.user_agent, s.started_at, s.last_heartbeat, - EXTRACT(EPOCH FROM (NOW() - s.last_heartbeat))/60 as minutes_inactive - FROM sessions s - JOIN licenses l ON s.license_id = l.id - JOIN customers c ON l.customer_id = c.id - WHERE s.is_active = TRUE - ORDER BY {active_sort_fields[active_sort]} {active_order.upper()} - """) - active_sessions = cur.fetchall() - - # Inaktive Sessions der letzten 24 Stunden - cur.execute(f""" - SELECT s.id, s.session_id, l.license_key, c.name, s.ip_address, - s.started_at, s.ended_at, - EXTRACT(EPOCH FROM (s.ended_at - s.started_at))/60 as duration_minutes - FROM sessions s - JOIN licenses l ON s.license_id = l.id - JOIN customers c ON l.customer_id = c.id - WHERE s.is_active = FALSE - AND s.ended_at > NOW() - INTERVAL '24 hours' - ORDER BY {ended_sort_fields[ended_sort]} {ended_order.upper()} - LIMIT 50 - """) - recent_sessions = cur.fetchall() - - cur.close() - conn.close() - - return render_template("sessions.html", - active_sessions=active_sessions, - recent_sessions=recent_sessions, - active_sort=active_sort, - active_order=active_order, - ended_sort=ended_sort, - ended_order=ended_order, - username=session.get('username')) - -@app.route("/session/end/", methods=["POST"]) -@login_required -def end_session(session_id): - conn = get_connection() - cur = conn.cursor() - - # Session beenden - cur.execute(""" - UPDATE sessions - SET is_active = FALSE, ended_at = NOW() - WHERE id = %s AND is_active = TRUE - """, (session_id,)) - - conn.commit() - cur.close() - conn.close() - - return redirect("/sessions") - -@app.route("/export/licenses") -@login_required -def export_licenses(): - conn = get_connection() - cur = conn.cursor() - - # Alle Lizenzen mit Kundeninformationen abrufen (ohne Testdaten, außer explizit gewünscht) - include_test = request.args.get('include_test', 'false').lower() == 'true' - customer_id = request.args.get('customer_id', type=int) - - query = """ - SELECT l.id, l.license_key, c.name as customer_name, c.email as customer_email, - l.license_type, l.valid_from, l.valid_until, l.is_active, l.is_test, - CASE - WHEN l.is_active = FALSE THEN 'Deaktiviert' - WHEN l.valid_until < CURRENT_DATE THEN 'Abgelaufen' - WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'Läuft bald ab' - ELSE 'Aktiv' - END as status - FROM licenses l - JOIN customers c ON l.customer_id = c.id - """ - - # Build WHERE clause - where_conditions = [] - params = [] - - if not include_test: - where_conditions.append("l.is_test = FALSE") - - if customer_id: - where_conditions.append("l.customer_id = %s") - params.append(customer_id) - - if where_conditions: - query += " WHERE " + " AND ".join(where_conditions) - - query += " ORDER BY l.id" - - cur.execute(query, params) - - # Spaltennamen - columns = ['ID', 'Lizenzschlüssel', 'Kunde', 'E-Mail', 'Typ', - 'Gültig von', 'Gültig bis', 'Aktiv', 'Testdaten', 'Status'] - - # Daten in DataFrame - data = cur.fetchall() - df = pd.DataFrame(data, columns=columns) - - # Datumsformatierung - df['Gültig von'] = pd.to_datetime(df['Gültig von']).dt.strftime('%d.%m.%Y') - df['Gültig bis'] = pd.to_datetime(df['Gültig bis']).dt.strftime('%d.%m.%Y') - - # Typ und Aktiv Status anpassen - df['Typ'] = df['Typ'].replace({'full': 'Vollversion', 'test': 'Testversion'}) - df['Aktiv'] = df['Aktiv'].replace({True: 'Ja', False: 'Nein'}) - df['Testdaten'] = df['Testdaten'].replace({True: 'Ja', False: 'Nein'}) - - cur.close() - conn.close() - - # Export Format - export_format = request.args.get('format', 'excel') - - # Audit-Log - log_audit('EXPORT', 'license', - additional_info=f"Export aller Lizenzen als {export_format.upper()}") - filename = f'lizenzen_export_{datetime.now(ZoneInfo("Europe/Berlin")).strftime("%Y%m%d_%H%M%S")}' - - if export_format == 'csv': - # CSV Export - output = io.StringIO() - df.to_csv(output, index=False, encoding='utf-8-sig', sep=';') - output.seek(0) - - return send_file( - io.BytesIO(output.getvalue().encode('utf-8-sig')), - mimetype='text/csv', - as_attachment=True, - download_name=f'{filename}.csv' - ) - else: - # Excel Export - output = io.BytesIO() - with pd.ExcelWriter(output, engine='openpyxl') as writer: - df.to_excel(writer, sheet_name='Lizenzen', index=False) - - # Formatierung - worksheet = writer.sheets['Lizenzen'] - for column in worksheet.columns: - max_length = 0 - column_letter = column[0].column_letter - for cell in column: - try: - if len(str(cell.value)) > max_length: - max_length = len(str(cell.value)) - except: - pass - adjusted_width = min(max_length + 2, 50) - worksheet.column_dimensions[column_letter].width = adjusted_width - - output.seek(0) - - return send_file( - output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=f'{filename}.xlsx' - ) - -@app.route("/export/audit") -@login_required -def export_audit(): - conn = get_connection() - cur = conn.cursor() - - # Holen der Filter-Parameter - filter_user = request.args.get('user', '') - filter_action = request.args.get('action', '') - filter_entity = request.args.get('entity', '') - export_format = request.args.get('format', 'excel') - - # SQL Query mit Filtern - query = """ - SELECT id, timestamp, username, action, entity_type, entity_id, - old_values, new_values, ip_address, user_agent, additional_info - FROM audit_log - WHERE 1=1 - """ - params = [] - - if filter_user: - query += " AND username ILIKE %s" - params.append(f'%{filter_user}%') - - if filter_action: - query += " AND action = %s" - params.append(filter_action) - - if filter_entity: - query += " AND entity_type = %s" - params.append(filter_entity) - - query += " ORDER BY timestamp DESC" - - cur.execute(query, params) - audit_logs = cur.fetchall() - cur.close() - conn.close() - - # Daten für Export vorbereiten - data = [] - for log in audit_logs: - action_text = { - 'CREATE': 'Erstellt', - 'UPDATE': 'Bearbeitet', - 'DELETE': 'Gelöscht', - 'LOGIN': 'Anmeldung', - 'LOGOUT': 'Abmeldung', - 'AUTO_LOGOUT': 'Auto-Logout', - 'EXPORT': 'Export', - 'GENERATE_KEY': 'Key generiert', - 'CREATE_BATCH': 'Batch erstellt', - 'BACKUP': 'Backup erstellt', - 'LOGIN_2FA_SUCCESS': '2FA-Anmeldung', - 'LOGIN_2FA_BACKUP': '2FA-Backup-Code', - 'LOGIN_2FA_FAILED': '2FA-Fehlgeschlagen', - 'LOGIN_BLOCKED': 'Login-Blockiert', - 'RESTORE': 'Wiederhergestellt', - 'PASSWORD_CHANGE': 'Passwort geändert', - '2FA_ENABLED': '2FA aktiviert', - '2FA_DISABLED': '2FA deaktiviert' - }.get(log[3], log[3]) - - data.append({ - 'ID': log[0], - 'Zeitstempel': log[1].strftime('%d.%m.%Y %H:%M:%S'), - 'Benutzer': log[2], - 'Aktion': action_text, - 'Entität': log[4], - 'Entität-ID': log[5] or '', - 'IP-Adresse': log[8] or '', - 'Zusatzinfo': log[10] or '' - }) - - # DataFrame erstellen - df = pd.DataFrame(data) - - # Timestamp für Dateiname - timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') - filename = f'audit_log_export_{timestamp}' - - # Audit Log für Export - log_audit('EXPORT', 'audit_log', - additional_info=f"{export_format.upper()} Export mit {len(data)} Einträgen") - - if export_format == 'csv': - # CSV Export - output = io.StringIO() - # UTF-8 BOM für Excel - output.write('\ufeff') - df.to_csv(output, index=False, sep=';', encoding='utf-8') - output.seek(0) - - return send_file( - io.BytesIO(output.getvalue().encode('utf-8')), - mimetype='text/csv;charset=utf-8', - as_attachment=True, - download_name=f'{filename}.csv' - ) - else: - # Excel Export - output = BytesIO() - with pd.ExcelWriter(output, engine='openpyxl') as writer: - df.to_excel(writer, index=False, sheet_name='Audit Log') - - # Spaltenbreiten anpassen - worksheet = writer.sheets['Audit Log'] - for idx, col in enumerate(df.columns): - max_length = max( - df[col].astype(str).map(len).max(), - len(col) - ) + 2 - worksheet.column_dimensions[get_column_letter(idx + 1)].width = min(max_length, 50) - - output.seek(0) - - return send_file( - output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=f'{filename}.xlsx' - ) - -@app.route("/export/customers") -@login_required -def export_customers(): - conn = get_connection() - cur = conn.cursor() - - # Check if test data should be included - include_test = request.args.get('include_test', 'false').lower() == 'true' - - # Build query based on test data filter - if include_test: - # Include all customers - query = """ - SELECT c.id, c.name, c.email, c.created_at, c.is_test, - COUNT(l.id) as total_licenses, - COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE THEN 1 END) as active_licenses, - COUNT(CASE WHEN l.valid_until < CURRENT_DATE THEN 1 END) as expired_licenses - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - GROUP BY c.id, c.name, c.email, c.created_at, c.is_test - ORDER BY c.id - """ - else: - # Exclude test customers and test licenses - query = """ - SELECT c.id, c.name, c.email, c.created_at, c.is_test, - COUNT(CASE WHEN l.is_test = FALSE THEN 1 END) as total_licenses, - COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE AND l.is_test = FALSE THEN 1 END) as active_licenses, - COUNT(CASE WHEN l.valid_until < CURRENT_DATE AND l.is_test = FALSE THEN 1 END) as expired_licenses - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - WHERE c.is_test = FALSE - GROUP BY c.id, c.name, c.email, c.created_at, c.is_test - ORDER BY c.id - """ - - cur.execute(query) - - # Spaltennamen - columns = ['ID', 'Name', 'E-Mail', 'Erstellt am', 'Testdaten', - 'Lizenzen gesamt', 'Aktive Lizenzen', 'Abgelaufene Lizenzen'] - - # Daten in DataFrame - data = cur.fetchall() - df = pd.DataFrame(data, columns=columns) - - # Datumsformatierung - df['Erstellt am'] = pd.to_datetime(df['Erstellt am']).dt.strftime('%d.%m.%Y %H:%M') - - # Testdaten formatting - df['Testdaten'] = df['Testdaten'].replace({True: 'Ja', False: 'Nein'}) - - cur.close() - conn.close() - - # Export Format - export_format = request.args.get('format', 'excel') - - # Audit-Log - log_audit('EXPORT', 'customer', - additional_info=f"Export aller Kunden als {export_format.upper()}") - filename = f'kunden_export_{datetime.now(ZoneInfo("Europe/Berlin")).strftime("%Y%m%d_%H%M%S")}' - - if export_format == 'csv': - # CSV Export - output = io.StringIO() - df.to_csv(output, index=False, encoding='utf-8-sig', sep=';') - output.seek(0) - - return send_file( - io.BytesIO(output.getvalue().encode('utf-8-sig')), - mimetype='text/csv', - as_attachment=True, - download_name=f'{filename}.csv' - ) - else: - # Excel Export - output = io.BytesIO() - with pd.ExcelWriter(output, engine='openpyxl') as writer: - df.to_excel(writer, sheet_name='Kunden', index=False) - - # Formatierung - worksheet = writer.sheets['Kunden'] - for column in worksheet.columns: - max_length = 0 - column_letter = column[0].column_letter - for cell in column: - try: - if len(str(cell.value)) > max_length: - max_length = len(str(cell.value)) - except: - pass - adjusted_width = min(max_length + 2, 50) - worksheet.column_dimensions[column_letter].width = adjusted_width - - output.seek(0) - - return send_file( - output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=f'{filename}.xlsx' - ) - -@app.route("/export/sessions") -@login_required -def export_sessions(): - conn = get_connection() - cur = conn.cursor() - - # Holen des Session-Typs (active oder ended) - session_type = request.args.get('type', 'active') - export_format = request.args.get('format', 'excel') - - # Daten je nach Typ abrufen - if session_type == 'active': - # Aktive Lizenz-Sessions - cur.execute(""" - SELECT s.id, l.license_key, c.name as customer_name, s.session_id, - s.started_at, s.last_heartbeat, - EXTRACT(EPOCH FROM (NOW() - s.started_at))::INT as duration_seconds, - s.ip_address, s.user_agent - FROM sessions s - JOIN licenses l ON s.license_id = l.id - JOIN customers c ON l.customer_id = c.id - WHERE s.is_active = true - ORDER BY s.last_heartbeat DESC - """) - sessions = cur.fetchall() - - # Daten für Export vorbereiten - data = [] - for sess in sessions: - duration = sess[6] - hours = duration // 3600 - minutes = (duration % 3600) // 60 - seconds = duration % 60 - - data.append({ - 'Session-ID': sess[0], - 'Lizenzschlüssel': sess[1], - 'Kunde': sess[2], - 'Session-ID (Tech)': sess[3], - 'Startzeit': sess[4].strftime('%d.%m.%Y %H:%M:%S'), - 'Letzte Aktivität': sess[5].strftime('%d.%m.%Y %H:%M:%S'), - 'Dauer': f"{hours}h {minutes}m {seconds}s", - 'IP-Adresse': sess[7], - 'Browser': sess[8] - }) - - sheet_name = 'Aktive Sessions' - filename_prefix = 'aktive_sessions' - else: - # Beendete Lizenz-Sessions - cur.execute(""" - SELECT s.id, l.license_key, c.name as customer_name, s.session_id, - s.started_at, s.ended_at, - EXTRACT(EPOCH FROM (s.ended_at - s.started_at))::INT as duration_seconds, - s.ip_address, s.user_agent - FROM sessions s - JOIN licenses l ON s.license_id = l.id - JOIN customers c ON l.customer_id = c.id - WHERE s.is_active = false AND s.ended_at IS NOT NULL - ORDER BY s.ended_at DESC - LIMIT 1000 - """) - sessions = cur.fetchall() - - # Daten für Export vorbereiten - data = [] - for sess in sessions: - duration = sess[6] if sess[6] else 0 - hours = duration // 3600 - minutes = (duration % 3600) // 60 - seconds = duration % 60 - - data.append({ - 'Session-ID': sess[0], - 'Lizenzschlüssel': sess[1], - 'Kunde': sess[2], - 'Session-ID (Tech)': sess[3], - 'Startzeit': sess[4].strftime('%d.%m.%Y %H:%M:%S'), - 'Endzeit': sess[5].strftime('%d.%m.%Y %H:%M:%S') if sess[5] else '', - 'Dauer': f"{hours}h {minutes}m {seconds}s", - 'IP-Adresse': sess[7], - 'Browser': sess[8] - }) - - sheet_name = 'Beendete Sessions' - filename_prefix = 'beendete_sessions' - - cur.close() - conn.close() - - # DataFrame erstellen - df = pd.DataFrame(data) - - # Timestamp für Dateiname - timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') - filename = f'{filename_prefix}_export_{timestamp}' - - # Audit Log für Export - log_audit('EXPORT', 'sessions', - additional_info=f"{export_format.upper()} Export von {session_type} Sessions mit {len(data)} Einträgen") - - if export_format == 'csv': - # CSV Export - output = io.StringIO() - # UTF-8 BOM für Excel - output.write('\ufeff') - df.to_csv(output, index=False, sep=';', encoding='utf-8') - output.seek(0) - - return send_file( - io.BytesIO(output.getvalue().encode('utf-8')), - mimetype='text/csv;charset=utf-8', - as_attachment=True, - download_name=f'{filename}.csv' - ) - else: - # Excel Export - output = BytesIO() - with pd.ExcelWriter(output, engine='openpyxl') as writer: - df.to_excel(writer, index=False, sheet_name=sheet_name) - - # Spaltenbreiten anpassen - worksheet = writer.sheets[sheet_name] - for idx, col in enumerate(df.columns): - max_length = max( - df[col].astype(str).map(len).max(), - len(col) - ) + 2 - worksheet.column_dimensions[get_column_letter(idx + 1)].width = min(max_length, 50) - - output.seek(0) - - return send_file( - output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=f'{filename}.xlsx' - ) - -@app.route("/export/resources") -@login_required -def export_resources(): - conn = get_connection() - cur = conn.cursor() - - # Holen der Filter-Parameter - filter_type = request.args.get('type', '') - filter_status = request.args.get('status', '') - search_query = request.args.get('search', '') - show_test = request.args.get('show_test', 'false').lower() == 'true' - export_format = request.args.get('format', 'excel') - - # SQL Query mit Filtern - query = """ - SELECT r.id, r.resource_type, r.resource_value, r.status, r.allocated_to_license, - r.created_at, r.status_changed_at, - l.license_key, c.name as customer_name, c.email as customer_email, - l.license_type - FROM resource_pools r - LEFT JOIN licenses l ON r.allocated_to_license = l.id - LEFT JOIN customers c ON l.customer_id = c.id - WHERE 1=1 - """ - params = [] - - # Filter für Testdaten - if not show_test: - query += " AND (r.is_test = false OR r.is_test IS NULL)" - - # Filter für Ressourcentyp - if filter_type: - query += " AND r.resource_type = %s" - params.append(filter_type) - - # Filter für Status - if filter_status: - query += " AND r.status = %s" - params.append(filter_status) - - # Suchfilter - if search_query: - query += " AND (r.resource_value ILIKE %s OR l.license_key ILIKE %s OR c.name ILIKE %s)" - params.extend([f'%{search_query}%', f'%{search_query}%', f'%{search_query}%']) - - query += " ORDER BY r.id DESC" - - cur.execute(query, params) - resources = cur.fetchall() - cur.close() - conn.close() - - # Daten für Export vorbereiten - data = [] - for res in resources: - status_text = { - 'available': 'Verfügbar', - 'allocated': 'Zugewiesen', - 'quarantine': 'Quarantäne' - }.get(res[3], res[3]) - - type_text = { - 'domain': 'Domain', - 'ipv4': 'IPv4', - 'phone': 'Telefon' - }.get(res[1], res[1]) - - data.append({ - 'ID': res[0], - 'Typ': type_text, - 'Ressource': res[2], - 'Status': status_text, - 'Lizenzschlüssel': res[7] or '', - 'Kunde': res[8] or '', - 'Kunden-Email': res[9] or '', - 'Lizenztyp': res[10] or '', - 'Erstellt am': res[5].strftime('%d.%m.%Y %H:%M:%S') if res[5] else '', - 'Zugewiesen am': res[6].strftime('%d.%m.%Y %H:%M:%S') if res[6] else '' - }) - - # DataFrame erstellen - df = pd.DataFrame(data) - - # Timestamp für Dateiname - timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') - filename = f'resources_export_{timestamp}' - - # Audit Log für Export - log_audit('EXPORT', 'resources', - additional_info=f"{export_format.upper()} Export mit {len(data)} Ressourcen") - - if export_format == 'csv': - # CSV Export - output = io.StringIO() - # UTF-8 BOM für Excel - output.write('\ufeff') - df.to_csv(output, index=False, sep=';', encoding='utf-8') - output.seek(0) - - return send_file( - io.BytesIO(output.getvalue().encode('utf-8')), - mimetype='text/csv;charset=utf-8', - as_attachment=True, - download_name=f'{filename}.csv' - ) - else: - # Excel Export - output = BytesIO() - with pd.ExcelWriter(output, engine='openpyxl') as writer: - df.to_excel(writer, index=False, sheet_name='Resources') - - # Spaltenbreiten anpassen - worksheet = writer.sheets['Resources'] - for idx, col in enumerate(df.columns): - max_length = max( - df[col].astype(str).map(len).max(), - len(col) - ) + 2 - worksheet.column_dimensions[get_column_letter(idx + 1)].width = min(max_length, 50) - - output.seek(0) - - return send_file( - output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=f'{filename}.xlsx' - ) - -@app.route("/audit") -@login_required -def audit_log(): - conn = get_connection() - cur = conn.cursor() - - # Parameter - filter_user = request.args.get('user', '').strip() - filter_action = request.args.get('action', '').strip() - filter_entity = request.args.get('entity', '').strip() - page = request.args.get('page', 1, type=int) - sort = request.args.get('sort', 'timestamp') - order = request.args.get('order', 'desc') - per_page = 50 - - # Whitelist für erlaubte Sortierfelder - allowed_sort_fields = { - 'timestamp': 'timestamp', - 'username': 'username', - 'action': 'action', - 'entity': 'entity_type', - 'ip': 'ip_address' - } - - # Validierung - if sort not in allowed_sort_fields: - sort = 'timestamp' - if order not in ['asc', 'desc']: - order = 'desc' - - sort_field = allowed_sort_fields[sort] - - # SQL Query mit optionalen Filtern - query = """ - SELECT id, timestamp, username, action, entity_type, entity_id, - old_values, new_values, ip_address, user_agent, additional_info - FROM audit_log - WHERE 1=1 - """ - - params = [] - - # Filter - if filter_user: - query += " AND LOWER(username) LIKE LOWER(%s)" - params.append(f'%{filter_user}%') - - if filter_action: - query += " AND action = %s" - params.append(filter_action) - - if filter_entity: - query += " AND entity_type = %s" - params.append(filter_entity) - - # Gesamtanzahl für Pagination - count_query = "SELECT COUNT(*) FROM (" + query + ") as count_table" - cur.execute(count_query, params) - total = cur.fetchone()[0] - - # Pagination - offset = (page - 1) * per_page - query += f" ORDER BY {sort_field} {order.upper()} LIMIT %s OFFSET %s" - params.extend([per_page, offset]) - - cur.execute(query, params) - logs = cur.fetchall() - - # JSON-Werte parsen - parsed_logs = [] - for log in logs: - parsed_log = list(log) - # old_values und new_values sind bereits Dictionaries (JSONB) - # Keine Konvertierung nötig - parsed_logs.append(parsed_log) - - # Pagination Info - total_pages = (total + per_page - 1) // per_page - - cur.close() - conn.close() - - return render_template("audit_log.html", - logs=parsed_logs, - filter_user=filter_user, - filter_action=filter_action, - filter_entity=filter_entity, - page=page, - total_pages=total_pages, - total=total, - sort=sort, - order=order, - username=session.get('username')) - -@app.route("/backups") -@login_required -def backups(): - """Zeigt die Backup-Historie an""" - conn = get_connection() - cur = conn.cursor() - - # Letztes erfolgreiches Backup für Dashboard - cur.execute(""" - SELECT created_at, filesize, duration_seconds - FROM backup_history - WHERE status = 'success' - ORDER BY created_at DESC - LIMIT 1 - """) - last_backup = cur.fetchone() - - # Alle Backups abrufen - cur.execute(""" - SELECT id, filename, filesize, backup_type, status, error_message, - created_at, created_by, tables_count, records_count, - duration_seconds, is_encrypted - FROM backup_history - ORDER BY created_at DESC - """) - backups = cur.fetchall() - - cur.close() - conn.close() - - return render_template("backups.html", - backups=backups, - last_backup=last_backup, - username=session.get('username')) - -@app.route("/backup/create", methods=["POST"]) -@login_required -def create_backup_route(): - """Erstellt ein manuelles Backup""" - username = session.get('username') - success, result = create_backup(backup_type="manual", created_by=username) - - if success: - return jsonify({ - 'success': True, - 'message': f'Backup erfolgreich erstellt: {result}' - }) - else: - return jsonify({ - 'success': False, - 'message': f'Backup fehlgeschlagen: {result}' - }), 500 - -@app.route("/backup/restore/", methods=["POST"]) -@login_required -def restore_backup_route(backup_id): - """Stellt ein Backup wieder her""" - encryption_key = request.form.get('encryption_key') - - success, message = restore_backup(backup_id, encryption_key) - - if success: - return jsonify({ - 'success': True, - 'message': message - }) - else: - return jsonify({ - 'success': False, - 'message': message - }), 500 - -@app.route("/backup/download/") -@login_required -def download_backup(backup_id): - """Lädt eine Backup-Datei herunter""" - conn = get_connection() - cur = conn.cursor() - - cur.execute(""" - SELECT filename, filepath - FROM backup_history - WHERE id = %s - """, (backup_id,)) - backup_info = cur.fetchone() - - cur.close() - conn.close() - - if not backup_info: - return "Backup nicht gefunden", 404 - - filename, filepath = backup_info - filepath = Path(filepath) - - if not filepath.exists(): - return "Backup-Datei nicht gefunden", 404 - - # Audit-Log - log_audit('DOWNLOAD', 'backup', backup_id, - additional_info=f"Backup heruntergeladen: {filename}") - - return send_file(filepath, as_attachment=True, download_name=filename) - -@app.route("/backup/delete/", methods=["DELETE"]) -@login_required -def delete_backup(backup_id): - """Löscht ein Backup""" - conn = get_connection() - cur = conn.cursor() - - try: - # Backup-Informationen abrufen - cur.execute(""" - SELECT filename, filepath - FROM backup_history - WHERE id = %s - """, (backup_id,)) - backup_info = cur.fetchone() - - if not backup_info: - return jsonify({ - 'success': False, - 'message': 'Backup nicht gefunden' - }), 404 - - filename, filepath = backup_info - filepath = Path(filepath) - - # Datei löschen, wenn sie existiert - if filepath.exists(): - filepath.unlink() - - # Aus Datenbank löschen - cur.execute(""" - DELETE FROM backup_history - WHERE id = %s - """, (backup_id,)) - - conn.commit() - - # Audit-Log - log_audit('DELETE', 'backup', backup_id, - additional_info=f"Backup gelöscht: {filename}") - - return jsonify({ - 'success': True, - 'message': f'Backup "{filename}" wurde erfolgreich gelöscht' - }) - - except Exception as e: - conn.rollback() - return jsonify({ - 'success': False, - 'message': f'Fehler beim Löschen des Backups: {str(e)}' - }), 500 - finally: - cur.close() - conn.close() - -@app.route("/security/blocked-ips") -@login_required -def blocked_ips(): - """Zeigt alle gesperrten IPs an""" - conn = get_connection() - cur = conn.cursor() - - cur.execute(""" - SELECT - ip_address, - attempt_count, - first_attempt, - last_attempt, - blocked_until, - last_username_tried, - last_error_message - FROM login_attempts - WHERE blocked_until IS NOT NULL - ORDER BY blocked_until DESC - """) - - blocked_ips_list = [] - for ip in cur.fetchall(): - blocked_ips_list.append({ - 'ip_address': ip[0], - 'attempt_count': ip[1], - 'first_attempt': ip[2].strftime('%d.%m.%Y %H:%M'), - 'last_attempt': ip[3].strftime('%d.%m.%Y %H:%M'), - 'blocked_until': ip[4].strftime('%d.%m.%Y %H:%M'), - 'is_active': ip[4] > datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None), - 'last_username': ip[5], - 'last_error': ip[6] - }) - - cur.close() - conn.close() - - return render_template("blocked_ips.html", - blocked_ips=blocked_ips_list, - username=session.get('username')) - -@app.route("/security/unblock-ip", methods=["POST"]) -@login_required -def unblock_ip(): - """Entsperrt eine IP-Adresse""" - ip_address = request.form.get('ip_address') - - if ip_address: - conn = get_connection() - cur = conn.cursor() - - cur.execute(""" - UPDATE login_attempts - SET blocked_until = NULL - WHERE ip_address = %s - """, (ip_address,)) - - conn.commit() - cur.close() - conn.close() - - # Audit-Log - log_audit('UNBLOCK_IP', 'security', - additional_info=f"IP {ip_address} manuell entsperrt") - - return redirect(url_for('blocked_ips')) - -@app.route("/security/clear-attempts", methods=["POST"]) -@login_required -def clear_attempts(): - """Löscht alle Login-Versuche für eine IP""" - ip_address = request.form.get('ip_address') - - if ip_address: - reset_login_attempts(ip_address) - - # Audit-Log - log_audit('CLEAR_ATTEMPTS', 'security', - additional_info=f"Login-Versuche für IP {ip_address} zurückgesetzt") - - return redirect(url_for('blocked_ips')) - -# API Endpoints for License Management -@app.route("/api/license//toggle", methods=["POST"]) -@login_required -def toggle_license_api(license_id): - """Toggle license active status via API""" - try: - data = request.get_json() - is_active = data.get('is_active', False) - - conn = get_connection() - cur = conn.cursor() - - # Update license status - cur.execute(""" - UPDATE licenses - SET is_active = %s - WHERE id = %s - """, (is_active, license_id)) - - conn.commit() - - # Log the action - log_audit('UPDATE', 'license', license_id, - new_values={'is_active': is_active}, - additional_info=f"Lizenz {'aktiviert' if is_active else 'deaktiviert'} via Toggle") - - cur.close() - conn.close() - - return jsonify({'success': True, 'message': 'Status erfolgreich geändert'}) - except Exception as e: - return jsonify({'success': False, 'message': str(e)}), 500 - -@app.route("/api/licenses/bulk-activate", methods=["POST"]) -@login_required -def bulk_activate_licenses(): - """Activate multiple licenses at once""" - try: - data = request.get_json() - license_ids = data.get('ids', []) - - if not license_ids: - return jsonify({'success': False, 'message': 'Keine Lizenzen ausgewählt'}), 400 - - conn = get_connection() - cur = conn.cursor() - - # Update all selected licenses (nur Live-Daten) - cur.execute(""" - UPDATE licenses - SET is_active = TRUE - WHERE id = ANY(%s) AND is_test = FALSE - """, (license_ids,)) - - affected_rows = cur.rowcount - conn.commit() - - # Log the bulk action - log_audit('BULK_UPDATE', 'licenses', None, - new_values={'is_active': True, 'count': affected_rows}, - additional_info=f"{affected_rows} Lizenzen aktiviert") - - cur.close() - conn.close() - - return jsonify({'success': True, 'message': f'{affected_rows} Lizenzen aktiviert'}) - except Exception as e: - return jsonify({'success': False, 'message': str(e)}), 500 - -@app.route("/api/licenses/bulk-deactivate", methods=["POST"]) -@login_required -def bulk_deactivate_licenses(): - """Deactivate multiple licenses at once""" - try: - data = request.get_json() - license_ids = data.get('ids', []) - - if not license_ids: - return jsonify({'success': False, 'message': 'Keine Lizenzen ausgewählt'}), 400 - - conn = get_connection() - cur = conn.cursor() - - # Update all selected licenses (nur Live-Daten) - cur.execute(""" - UPDATE licenses - SET is_active = FALSE - WHERE id = ANY(%s) AND is_test = FALSE - """, (license_ids,)) - - affected_rows = cur.rowcount - conn.commit() - - # Log the bulk action - log_audit('BULK_UPDATE', 'licenses', None, - new_values={'is_active': False, 'count': affected_rows}, - additional_info=f"{affected_rows} Lizenzen deaktiviert") - - cur.close() - conn.close() - - return jsonify({'success': True, 'message': f'{affected_rows} Lizenzen deaktiviert'}) - except Exception as e: - return jsonify({'success': False, 'message': str(e)}), 500 - -@app.route("/api/license//devices") -@login_required -def get_license_devices(license_id): - """Hole alle registrierten Geräte einer Lizenz""" - try: - conn = get_connection() - cur = conn.cursor() - - # Prüfe ob Lizenz existiert und hole device_limit - cur.execute(""" - SELECT device_limit FROM licenses WHERE id = %s - """, (license_id,)) - license_data = cur.fetchone() - - if not license_data: - return jsonify({'success': False, 'message': 'Lizenz nicht gefunden'}), 404 - - device_limit = license_data[0] - - # Hole alle Geräte für diese Lizenz - cur.execute(""" - SELECT id, hardware_id, device_name, operating_system, - first_seen, last_seen, is_active, ip_address - FROM device_registrations - WHERE license_id = %s - ORDER BY is_active DESC, last_seen DESC - """, (license_id,)) - - devices = [] - for row in cur.fetchall(): - devices.append({ - 'id': row[0], - 'hardware_id': row[1], - 'device_name': row[2] or 'Unbekanntes Gerät', - 'operating_system': row[3] or 'Unbekannt', - 'first_seen': row[4].strftime('%d.%m.%Y %H:%M') if row[4] else '', - 'last_seen': row[5].strftime('%d.%m.%Y %H:%M') if row[5] else '', - 'is_active': row[6], - 'ip_address': row[7] or '-' - }) - - cur.close() - conn.close() - - return jsonify({ - 'success': True, - 'devices': devices, - 'device_limit': device_limit, - 'active_count': sum(1 for d in devices if d['is_active']) - }) - - except Exception as e: - logging.error(f"Fehler beim Abrufen der Geräte: {str(e)}") - return jsonify({'success': False, 'message': 'Fehler beim Abrufen der Geräte'}), 500 - -@app.route("/api/license//register-device", methods=["POST"]) -def register_device(license_id): - """Registriere ein neues Gerät für eine Lizenz""" - try: - data = request.get_json() - hardware_id = data.get('hardware_id') - device_name = data.get('device_name', '') - operating_system = data.get('operating_system', '') - - if not hardware_id: - return jsonify({'success': False, 'message': 'Hardware-ID fehlt'}), 400 - - conn = get_connection() - cur = conn.cursor() - - # Prüfe ob Lizenz existiert und aktiv ist - cur.execute(""" - SELECT device_limit, is_active, valid_until - FROM licenses - WHERE id = %s - """, (license_id,)) - license_data = cur.fetchone() - - if not license_data: - return jsonify({'success': False, 'message': 'Lizenz nicht gefunden'}), 404 - - device_limit, is_active, valid_until = license_data - - # Prüfe ob Lizenz aktiv und gültig ist - if not is_active: - return jsonify({'success': False, 'message': 'Lizenz ist deaktiviert'}), 403 - - if valid_until < datetime.now(ZoneInfo("Europe/Berlin")).date(): - return jsonify({'success': False, 'message': 'Lizenz ist abgelaufen'}), 403 - - # Prüfe ob Gerät bereits registriert ist - cur.execute(""" - SELECT id, is_active FROM device_registrations - WHERE license_id = %s AND hardware_id = %s - """, (license_id, hardware_id)) - existing_device = cur.fetchone() - - if existing_device: - device_id, is_device_active = existing_device - if is_device_active: - # Gerät ist bereits aktiv, update last_seen - cur.execute(""" - UPDATE device_registrations - SET last_seen = CURRENT_TIMESTAMP, - ip_address = %s, - user_agent = %s - WHERE id = %s - """, (get_client_ip(), request.headers.get('User-Agent', ''), device_id)) - conn.commit() - return jsonify({'success': True, 'message': 'Gerät bereits registriert', 'device_id': device_id}) - else: - # Gerät war deaktiviert, prüfe ob wir es reaktivieren können - cur.execute(""" - SELECT COUNT(*) FROM device_registrations - WHERE license_id = %s AND is_active = TRUE - """, (license_id,)) - active_count = cur.fetchone()[0] - - if active_count >= device_limit: - return jsonify({'success': False, 'message': f'Gerätelimit erreicht ({device_limit} Geräte)'}), 403 - - # Reaktiviere das Gerät - cur.execute(""" - UPDATE device_registrations - SET is_active = TRUE, - last_seen = CURRENT_TIMESTAMP, - deactivated_at = NULL, - deactivated_by = NULL, - ip_address = %s, - user_agent = %s - WHERE id = %s - """, (get_client_ip(), request.headers.get('User-Agent', ''), device_id)) - conn.commit() - return jsonify({'success': True, 'message': 'Gerät reaktiviert', 'device_id': device_id}) - - # Neues Gerät - prüfe Gerätelimit - cur.execute(""" - SELECT COUNT(*) FROM device_registrations - WHERE license_id = %s AND is_active = TRUE - """, (license_id,)) - active_count = cur.fetchone()[0] - - if active_count >= device_limit: - return jsonify({'success': False, 'message': f'Gerätelimit erreicht ({device_limit} Geräte)'}), 403 - - # Registriere neues Gerät - cur.execute(""" - INSERT INTO device_registrations - (license_id, hardware_id, device_name, operating_system, ip_address, user_agent) - VALUES (%s, %s, %s, %s, %s, %s) - RETURNING id - """, (license_id, hardware_id, device_name, operating_system, - get_client_ip(), request.headers.get('User-Agent', ''))) - device_id = cur.fetchone()[0] - - conn.commit() - - # Audit Log - log_audit('DEVICE_REGISTER', 'device', device_id, - new_values={'license_id': license_id, 'hardware_id': hardware_id}) - - cur.close() - conn.close() - - return jsonify({'success': True, 'message': 'Gerät erfolgreich registriert', 'device_id': device_id}) - - except Exception as e: - logging.error(f"Fehler bei Geräte-Registrierung: {str(e)}") - return jsonify({'success': False, 'message': 'Fehler bei der Registrierung'}), 500 - -@app.route("/api/license//deactivate-device/", methods=["POST"]) -@login_required -def deactivate_device(license_id, device_id): - """Deaktiviere ein registriertes Gerät""" - try: - conn = get_connection() - cur = conn.cursor() - - # Prüfe ob das Gerät zu dieser Lizenz gehört - cur.execute(""" - SELECT id FROM device_registrations - WHERE id = %s AND license_id = %s AND is_active = TRUE - """, (device_id, license_id)) - - if not cur.fetchone(): - return jsonify({'success': False, 'message': 'Gerät nicht gefunden oder bereits deaktiviert'}), 404 - - # Deaktiviere das Gerät - cur.execute(""" - UPDATE device_registrations - SET is_active = FALSE, - deactivated_at = CURRENT_TIMESTAMP, - deactivated_by = %s - WHERE id = %s - """, (session['username'], device_id)) - - conn.commit() - - # Audit Log - log_audit('DEVICE_DEACTIVATE', 'device', device_id, - old_values={'is_active': True}, - new_values={'is_active': False}) - - cur.close() - conn.close() - - return jsonify({'success': True, 'message': 'Gerät erfolgreich deaktiviert'}) - - except Exception as e: - logging.error(f"Fehler beim Deaktivieren des Geräts: {str(e)}") - return jsonify({'success': False, 'message': 'Fehler beim Deaktivieren'}), 500 - -@app.route("/api/licenses/bulk-delete", methods=["POST"]) -@login_required -def bulk_delete_licenses(): - """Delete multiple licenses at once""" - try: - data = request.get_json() - license_ids = data.get('ids', []) - - if not license_ids: - return jsonify({'success': False, 'message': 'Keine Lizenzen ausgewählt'}), 400 - - conn = get_connection() - cur = conn.cursor() - - # Get license info for audit log (nur Live-Daten) - cur.execute(""" - SELECT license_key - FROM licenses - WHERE id = ANY(%s) AND is_test = FALSE - """, (license_ids,)) - license_keys = [row[0] for row in cur.fetchall()] - - # Delete all selected licenses (nur Live-Daten) - cur.execute(""" - DELETE FROM licenses - WHERE id = ANY(%s) AND is_test = FALSE - """, (license_ids,)) - - affected_rows = cur.rowcount - conn.commit() - - # Log the bulk action - log_audit('BULK_DELETE', 'licenses', None, - old_values={'license_keys': license_keys, 'count': affected_rows}, - additional_info=f"{affected_rows} Lizenzen gelöscht") - - cur.close() - conn.close() - - return jsonify({'success': True, 'message': f'{affected_rows} Lizenzen gelöscht'}) - except Exception as e: - return jsonify({'success': False, 'message': str(e)}), 500 - -# ===================== RESOURCE POOL MANAGEMENT ===================== - -@app.route('/resources') -@login_required -def resources(): - """Resource Pool Hauptübersicht""" - conn = get_connection() - cur = conn.cursor() - - # Prüfe ob Testdaten angezeigt werden sollen (gleiche Logik wie bei Kunden) - show_test = request.args.get('show_test', 'false').lower() == 'true' - - # Statistiken abrufen - cur.execute(""" - SELECT - resource_type, - COUNT(*) FILTER (WHERE status = 'available') as available, - COUNT(*) FILTER (WHERE status = 'allocated') as allocated, - COUNT(*) FILTER (WHERE status = 'quarantine') as quarantine, - COUNT(*) as total - FROM resource_pools - WHERE is_test = %s - GROUP BY resource_type - """, (show_test,)) - - stats = {} - for row in cur.fetchall(): - stats[row[0]] = { - 'available': row[1], - 'allocated': row[2], - 'quarantine': row[3], - 'total': row[4], - 'available_percent': round((row[1] / row[4] * 100) if row[4] > 0 else 0, 1) - } - - # Letzte Aktivitäten (gefiltert nach Test/Live) - cur.execute(""" - SELECT - rh.action, - rh.action_by, - rh.action_at, - rp.resource_type, - rp.resource_value, - rh.details - FROM resource_history rh - JOIN resource_pools rp ON rh.resource_id = rp.id - WHERE rp.is_test = %s - ORDER BY rh.action_at DESC - LIMIT 10 - """, (show_test,)) - recent_activities = cur.fetchall() - - # Ressourcen-Liste mit Pagination - page = request.args.get('page', 1, type=int) - per_page = 50 - offset = (page - 1) * per_page - - resource_type = request.args.get('type', '') - status_filter = request.args.get('status', '') - search = request.args.get('search', '') - - # Sortierung - sort_by = request.args.get('sort', 'id') - sort_order = request.args.get('order', 'desc') - - # Base Query - query = """ - SELECT - rp.id, - rp.resource_type, - rp.resource_value, - rp.status, - rp.allocated_to_license, - l.license_key, - c.name as customer_name, - rp.status_changed_at, - rp.quarantine_reason, - rp.quarantine_until, - c.id as customer_id - FROM resource_pools rp - LEFT JOIN licenses l ON rp.allocated_to_license = l.id - LEFT JOIN customers c ON l.customer_id = c.id - WHERE rp.is_test = %s - """ - params = [show_test] - - if resource_type: - query += " AND rp.resource_type = %s" - params.append(resource_type) - - if status_filter: - query += " AND rp.status = %s" - params.append(status_filter) - - if search: - query += " AND rp.resource_value ILIKE %s" - params.append(f'%{search}%') - - # Count total - count_query = f"SELECT COUNT(*) FROM ({query}) as cnt" - cur.execute(count_query, params) - total = cur.fetchone()[0] - total_pages = (total + per_page - 1) // per_page - - # Get paginated results with dynamic sorting - sort_column_map = { - 'id': 'rp.id', - 'type': 'rp.resource_type', - 'resource': 'rp.resource_value', - 'status': 'rp.status', - 'assigned': 'c.name', - 'changed': 'rp.status_changed_at' - } - - sort_column = sort_column_map.get(sort_by, 'rp.id') - sort_direction = 'ASC' if sort_order == 'asc' else 'DESC' - - query += f" ORDER BY {sort_column} {sort_direction} LIMIT %s OFFSET %s" - params.extend([per_page, offset]) - - cur.execute(query, params) - resources = cur.fetchall() - - cur.close() - conn.close() - - return render_template('resources.html', - stats=stats, - resources=resources, - recent_activities=recent_activities, - page=page, - total_pages=total_pages, - total=total, - resource_type=resource_type, - status_filter=status_filter, - search=search, - show_test=show_test, - sort_by=sort_by, - sort_order=sort_order, - datetime=datetime, - timedelta=timedelta) - -@app.route('/resources/add', methods=['GET', 'POST']) -@login_required -def add_resources(): - """Ressourcen zum Pool hinzufügen""" - # Hole show_test Parameter für die Anzeige - show_test = request.args.get('show_test', 'false').lower() == 'true' - - if request.method == 'POST': - resource_type = request.form.get('resource_type') - resources_text = request.form.get('resources_text', '') - is_test = request.form.get('is_test') == 'on' # Checkbox für Testdaten - - # Parse resources (one per line) - resources = [r.strip() for r in resources_text.split('\n') if r.strip()] - - if not resources: - flash('Keine Ressourcen angegeben', 'error') - return redirect(url_for('add_resources', show_test=show_test)) - - conn = get_connection() - cur = conn.cursor() - - added = 0 - duplicates = 0 - - for resource_value in resources: - try: - cur.execute(""" - INSERT INTO resource_pools (resource_type, resource_value, status_changed_by, is_test) - VALUES (%s, %s, %s, %s) - ON CONFLICT (resource_type, resource_value) DO NOTHING - """, (resource_type, resource_value, session['username'], is_test)) - - if cur.rowcount > 0: - added += 1 - # Get the inserted ID - cur.execute("SELECT id FROM resource_pools WHERE resource_type = %s AND resource_value = %s", - (resource_type, resource_value)) - resource_id = cur.fetchone()[0] - - # Log in history - cur.execute(""" - INSERT INTO resource_history (resource_id, action, action_by, ip_address) - VALUES (%s, 'created', %s, %s) - """, (resource_id, session['username'], get_client_ip())) - else: - duplicates += 1 - - except Exception as e: - app.logger.error(f"Error adding resource {resource_value}: {e}") - - conn.commit() - cur.close() - conn.close() - - log_audit('CREATE', 'resource_pool', None, - new_values={'type': resource_type, 'added': added, 'duplicates': duplicates, 'is_test': is_test}, - additional_info=f"{added} {'Test-' if is_test else ''}Ressourcen hinzugefügt, {duplicates} Duplikate übersprungen") - - flash(f'{added} {"Test-" if is_test else ""}Ressourcen hinzugefügt, {duplicates} Duplikate übersprungen', 'success') - return redirect(url_for('resources', show_test=show_test)) - - return render_template('add_resources.html', show_test=show_test) - -@app.route('/resources/quarantine/', methods=['POST']) -@login_required -def quarantine_resource(resource_id): - """Ressource in Quarantäne setzen""" - reason = request.form.get('reason', 'review') - until_date = request.form.get('until_date') - notes = request.form.get('notes', '') - - conn = get_connection() - cur = conn.cursor() - - # Get current resource info - cur.execute("SELECT resource_type, resource_value, status FROM resource_pools WHERE id = %s", (resource_id,)) - resource = cur.fetchone() - - if not resource: - flash('Ressource nicht gefunden', 'error') - return redirect(url_for('resources')) - - old_status = resource[2] - - # Update resource - cur.execute(""" - UPDATE resource_pools - SET status = 'quarantine', - quarantine_reason = %s, - quarantine_until = %s, - notes = %s, - status_changed_at = CURRENT_TIMESTAMP, - status_changed_by = %s - WHERE id = %s - """, (reason, until_date if until_date else None, notes, session['username'], resource_id)) - - # Log in history - cur.execute(""" - INSERT INTO resource_history (resource_id, action, action_by, ip_address, details) - VALUES (%s, 'quarantined', %s, %s, %s) - """, (resource_id, session['username'], get_client_ip(), - Json({'reason': reason, 'until': until_date, 'notes': notes, 'old_status': old_status}))) - - conn.commit() - cur.close() - conn.close() - - log_audit('UPDATE', 'resource', resource_id, - old_values={'status': old_status}, - new_values={'status': 'quarantine', 'reason': reason}, - additional_info=f"Ressource {resource[0]}: {resource[1]} in Quarantäne") - - flash('Ressource in Quarantäne gesetzt', 'success') - - # Redirect mit allen aktuellen Filtern - return redirect(url_for('resources', - show_test=request.args.get('show_test', request.form.get('show_test', 'false')), - type=request.args.get('type', request.form.get('type', '')), - status=request.args.get('status', request.form.get('status', '')), - search=request.args.get('search', request.form.get('search', '')))) - -@app.route('/resources/release', methods=['POST']) -@login_required -def release_resources(): - """Ressourcen aus Quarantäne freigeben""" - resource_ids = request.form.getlist('resource_ids') - - if not resource_ids: - flash('Keine Ressourcen ausgewählt', 'error') - return redirect(url_for('resources')) - - conn = get_connection() - cur = conn.cursor() - - released = 0 - for resource_id in resource_ids: - cur.execute(""" - UPDATE resource_pools - SET status = 'available', - quarantine_reason = NULL, - quarantine_until = NULL, - allocated_to_license = NULL, - status_changed_at = CURRENT_TIMESTAMP, - status_changed_by = %s - WHERE id = %s AND status = 'quarantine' - """, (session['username'], resource_id)) - - if cur.rowcount > 0: - released += 1 - # Log in history - cur.execute(""" - INSERT INTO resource_history (resource_id, action, action_by, ip_address) - VALUES (%s, 'released', %s, %s) - """, (resource_id, session['username'], get_client_ip())) - - conn.commit() - cur.close() - conn.close() - - log_audit('UPDATE', 'resource_pool', None, - new_values={'released': released}, - additional_info=f"{released} Ressourcen aus Quarantäne freigegeben") - - flash(f'{released} Ressourcen freigegeben', 'success') - - # Redirect mit allen aktuellen Filtern - return redirect(url_for('resources', - show_test=request.args.get('show_test', request.form.get('show_test', 'false')), - type=request.args.get('type', request.form.get('type', '')), - status=request.args.get('status', request.form.get('status', '')), - search=request.args.get('search', request.form.get('search', '')))) - -@app.route('/api/resources/allocate', methods=['POST']) -@login_required -def allocate_resources_api(): - """API für Ressourcen-Zuweisung bei Lizenzerstellung""" - data = request.json - license_id = data.get('license_id') - domain_count = data.get('domain_count', 1) - ipv4_count = data.get('ipv4_count', 1) - phone_count = data.get('phone_count', 1) - - conn = get_connection() - cur = conn.cursor() - - try: - allocated = {'domains': [], 'ipv4s': [], 'phones': []} - - # Allocate domains - if domain_count > 0: - cur.execute(""" - SELECT id, resource_value FROM resource_pools - WHERE resource_type = 'domain' AND status = 'available' - LIMIT %s FOR UPDATE - """, (domain_count,)) - domains = cur.fetchall() - - if len(domains) < domain_count: - raise ValueError(f"Nicht genügend Domains verfügbar (benötigt: {domain_count}, verfügbar: {len(domains)})") - - for domain_id, domain_value in domains: - # Update resource status - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', - allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, - status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], domain_id)) - - # Create assignment - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, domain_id, session['username'])) - - # Log history - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (domain_id, license_id, session['username'], get_client_ip())) - - allocated['domains'].append(domain_value) - - # Allocate IPv4s (similar logic) - if ipv4_count > 0: - cur.execute(""" - SELECT id, resource_value FROM resource_pools - WHERE resource_type = 'ipv4' AND status = 'available' - LIMIT %s FOR UPDATE - """, (ipv4_count,)) - ipv4s = cur.fetchall() - - if len(ipv4s) < ipv4_count: - raise ValueError(f"Nicht genügend IPv4-Adressen verfügbar") - - for ipv4_id, ipv4_value in ipv4s: - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', - allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, - status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], ipv4_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, ipv4_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (ipv4_id, license_id, session['username'], get_client_ip())) - - allocated['ipv4s'].append(ipv4_value) - - # Allocate phones (similar logic) - if phone_count > 0: - cur.execute(""" - SELECT id, resource_value FROM resource_pools - WHERE resource_type = 'phone' AND status = 'available' - LIMIT %s FOR UPDATE - """, (phone_count,)) - phones = cur.fetchall() - - if len(phones) < phone_count: - raise ValueError(f"Nicht genügend Telefonnummern verfügbar") - - for phone_id, phone_value in phones: - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', - allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, - status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], phone_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, phone_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (phone_id, license_id, session['username'], get_client_ip())) - - allocated['phones'].append(phone_value) - - # Update license resource counts - cur.execute(""" - UPDATE licenses - SET domain_count = %s, - ipv4_count = %s, - phone_count = %s - WHERE id = %s - """, (domain_count, ipv4_count, phone_count, license_id)) - - conn.commit() - cur.close() - conn.close() - - return jsonify({ - 'success': True, - 'allocated': allocated - }) - - except Exception as e: - conn.rollback() - cur.close() - conn.close() - return jsonify({ - 'success': False, - 'error': str(e) - }), 400 - -@app.route('/api/resources/check-availability', methods=['GET']) -@login_required -def check_resource_availability(): - """Prüft verfügbare Ressourcen""" - resource_type = request.args.get('type', '') - count = request.args.get('count', 10, type=int) - show_test = request.args.get('show_test', 'false').lower() == 'true' - - conn = get_connection() - cur = conn.cursor() - - if resource_type: - # Spezifische Ressourcen für einen Typ - cur.execute(""" - SELECT id, resource_value - FROM resource_pools - WHERE status = 'available' - AND resource_type = %s - AND is_test = %s - ORDER BY resource_value - LIMIT %s - """, (resource_type, show_test, count)) - - resources = [] - for row in cur.fetchall(): - resources.append({ - 'id': row[0], - 'value': row[1] - }) - - cur.close() - conn.close() - - return jsonify({ - 'available': resources, - 'type': resource_type, - 'count': len(resources) - }) - else: - # Zusammenfassung aller Typen - cur.execute(""" - SELECT - resource_type, - COUNT(*) as available - FROM resource_pools - WHERE status = 'available' - AND is_test = %s - GROUP BY resource_type - """, (show_test,)) - - availability = {} - for row in cur.fetchall(): - availability[row[0]] = row[1] - - cur.close() - conn.close() - - return jsonify(availability) - -@app.route('/api/global-search', methods=['GET']) -@login_required -def global_search(): - """Global search API endpoint for searching customers and licenses""" - query = request.args.get('q', '').strip() - - if not query or len(query) < 2: - return jsonify({'customers': [], 'licenses': []}) - - conn = get_connection() - cur = conn.cursor() - - # Search pattern with wildcards - search_pattern = f'%{query}%' - - # Search customers - cur.execute(""" - SELECT id, name, email, company_name - FROM customers - WHERE (LOWER(name) LIKE LOWER(%s) - OR LOWER(email) LIKE LOWER(%s) - OR LOWER(company_name) LIKE LOWER(%s)) - AND is_test = FALSE - ORDER BY name - LIMIT 5 - """, (search_pattern, search_pattern, search_pattern)) - - customers = [] - for row in cur.fetchall(): - customers.append({ - 'id': row[0], - 'name': row[1], - 'email': row[2], - 'company_name': row[3] - }) - - # Search licenses - cur.execute(""" - SELECT l.id, l.license_key, c.name as customer_name - FROM licenses l - JOIN customers c ON l.customer_id = c.id - WHERE LOWER(l.license_key) LIKE LOWER(%s) - AND l.is_test = FALSE - ORDER BY l.created_at DESC - LIMIT 5 - """, (search_pattern,)) - - licenses = [] - for row in cur.fetchall(): - licenses.append({ - 'id': row[0], - 'license_key': row[1], - 'customer_name': row[2] - }) - - cur.close() - conn.close() - - return jsonify({ - 'customers': customers, - 'licenses': licenses - }) - -@app.route('/resources/history/') -@login_required -def resource_history(resource_id): - """Zeigt die komplette Historie einer Ressource""" - conn = get_connection() - cur = conn.cursor() - - # Get complete resource info using named columns - cur.execute(""" - SELECT id, resource_type, resource_value, status, allocated_to_license, - status_changed_at, status_changed_by, quarantine_reason, - quarantine_until, created_at, notes - FROM resource_pools - WHERE id = %s - """, (resource_id,)) - row = cur.fetchone() - - if not row: - flash('Ressource nicht gefunden', 'error') - return redirect(url_for('resources')) - - # Create resource object with named attributes - resource = { - 'id': row[0], - 'resource_type': row[1], - 'resource_value': row[2], - 'status': row[3], - 'allocated_to_license': row[4], - 'status_changed_at': row[5], - 'status_changed_by': row[6], - 'quarantine_reason': row[7], - 'quarantine_until': row[8], - 'created_at': row[9], - 'notes': row[10] - } - - # Get license info if allocated - license_info = None - if resource['allocated_to_license']: - cur.execute("SELECT license_key FROM licenses WHERE id = %s", - (resource['allocated_to_license'],)) - lic = cur.fetchone() - if lic: - license_info = {'license_key': lic[0]} - - # Get history with named columns - cur.execute(""" - SELECT - rh.action, - rh.action_by, - rh.action_at, - rh.details, - rh.license_id, - rh.ip_address - FROM resource_history rh - WHERE rh.resource_id = %s - ORDER BY rh.action_at DESC - """, (resource_id,)) - - history = [] - for row in cur.fetchall(): - history.append({ - 'action': row[0], - 'action_by': row[1], - 'action_at': row[2], - 'details': row[3], - 'license_id': row[4], - 'ip_address': row[5] - }) - - cur.close() - conn.close() - - # Convert to object-like for template - class ResourceObj: - def __init__(self, data): - for key, value in data.items(): - setattr(self, key, value) - - resource_obj = ResourceObj(resource) - history_objs = [ResourceObj(h) for h in history] - - return render_template('resource_history.html', - resource=resource_obj, - license_info=license_info, - history=history_objs) - -@app.route('/resources/metrics') -@login_required -def resources_metrics(): - """Dashboard für Resource Metrics und Reports""" - conn = get_connection() - cur = conn.cursor() - - # Overall stats with fallback values - cur.execute(""" - SELECT - COUNT(DISTINCT resource_id) as total_resources, - COALESCE(AVG(performance_score), 0) as avg_performance, - COALESCE(SUM(cost), 0) as total_cost, - COALESCE(SUM(revenue), 0) as total_revenue, - COALESCE(SUM(issues_count), 0) as total_issues - FROM resource_metrics - WHERE metric_date >= CURRENT_DATE - INTERVAL '30 days' - """) - row = cur.fetchone() - - # Calculate ROI - roi = 0 - if row[2] > 0: # if total_cost > 0 - roi = row[3] / row[2] # revenue / cost - - stats = { - 'total_resources': row[0] or 0, - 'avg_performance': row[1] or 0, - 'total_cost': row[2] or 0, - 'total_revenue': row[3] or 0, - 'total_issues': row[4] or 0, - 'roi': roi - } - - # Performance by type - cur.execute(""" - SELECT - rp.resource_type, - COALESCE(AVG(rm.performance_score), 0) as avg_score, - COUNT(DISTINCT rp.id) as resource_count - FROM resource_pools rp - LEFT JOIN resource_metrics rm ON rp.id = rm.resource_id - AND rm.metric_date >= CURRENT_DATE - INTERVAL '30 days' - GROUP BY rp.resource_type - ORDER BY rp.resource_type - """) - performance_by_type = cur.fetchall() - - # Utilization data - cur.execute(""" - SELECT - resource_type, - COUNT(*) FILTER (WHERE status = 'allocated') as allocated, - COUNT(*) as total, - ROUND(COUNT(*) FILTER (WHERE status = 'allocated') * 100.0 / COUNT(*), 1) as allocated_percent - FROM resource_pools - GROUP BY resource_type - """) - utilization_rows = cur.fetchall() - utilization_data = [ - { - 'type': row[0].upper(), - 'allocated': row[1], - 'total': row[2], - 'allocated_percent': row[3] - } - for row in utilization_rows - ] - - # Top performing resources - cur.execute(""" - SELECT - rp.id, - rp.resource_type, - rp.resource_value, - COALESCE(AVG(rm.performance_score), 0) as avg_score, - COALESCE(SUM(rm.revenue), 0) as total_revenue, - COALESCE(SUM(rm.cost), 1) as total_cost, - CASE - WHEN COALESCE(SUM(rm.cost), 0) = 0 THEN 0 - ELSE COALESCE(SUM(rm.revenue), 0) / COALESCE(SUM(rm.cost), 1) - END as roi - FROM resource_pools rp - LEFT JOIN resource_metrics rm ON rp.id = rm.resource_id - AND rm.metric_date >= CURRENT_DATE - INTERVAL '30 days' - WHERE rp.status != 'quarantine' - GROUP BY rp.id, rp.resource_type, rp.resource_value - HAVING AVG(rm.performance_score) IS NOT NULL - ORDER BY avg_score DESC - LIMIT 10 - """) - top_rows = cur.fetchall() - top_performers = [ - { - 'id': row[0], - 'resource_type': row[1], - 'resource_value': row[2], - 'avg_score': row[3], - 'roi': row[6] - } - for row in top_rows - ] - - # Resources with issues - cur.execute(""" - SELECT - rp.id, - rp.resource_type, - rp.resource_value, - rp.status, - COALESCE(SUM(rm.issues_count), 0) as total_issues - FROM resource_pools rp - LEFT JOIN resource_metrics rm ON rp.id = rm.resource_id - AND rm.metric_date >= CURRENT_DATE - INTERVAL '30 days' - WHERE rm.issues_count > 0 OR rp.status = 'quarantine' - GROUP BY rp.id, rp.resource_type, rp.resource_value, rp.status - HAVING SUM(rm.issues_count) > 0 - ORDER BY total_issues DESC - LIMIT 10 - """) - problem_rows = cur.fetchall() - problem_resources = [ - { - 'id': row[0], - 'resource_type': row[1], - 'resource_value': row[2], - 'status': row[3], - 'total_issues': row[4] - } - for row in problem_rows - ] - - # Daily metrics for trend chart (last 30 days) - cur.execute(""" - SELECT - metric_date, - COALESCE(AVG(performance_score), 0) as avg_performance, - COALESCE(SUM(issues_count), 0) as total_issues - FROM resource_metrics - WHERE metric_date >= CURRENT_DATE - INTERVAL '30 days' - GROUP BY metric_date - ORDER BY metric_date - """) - daily_rows = cur.fetchall() - daily_metrics = [ - { - 'date': row[0].strftime('%d.%m'), - 'performance': float(row[1]), - 'issues': int(row[2]) - } - for row in daily_rows - ] - - cur.close() - conn.close() - - return render_template('resource_metrics.html', - stats=stats, - performance_by_type=performance_by_type, - utilization_data=utilization_data, - top_performers=top_performers, - problem_resources=problem_resources, - daily_metrics=daily_metrics) - -@app.route('/resources/report', methods=['GET']) -@login_required -def resources_report(): - """Generiert Ressourcen-Reports oder zeigt Report-Formular""" - # Prüfe ob Download angefordert wurde - if request.args.get('download') == 'true': - report_type = request.args.get('type', 'usage') - format_type = request.args.get('format', 'excel') - date_from = request.args.get('from', (datetime.now(ZoneInfo("Europe/Berlin")) - timedelta(days=30)).strftime('%Y-%m-%d')) - date_to = request.args.get('to', datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y-%m-%d')) - - conn = get_connection() - cur = conn.cursor() - - if report_type == 'usage': - # Auslastungsreport - query = """ - SELECT - rp.resource_type, - rp.resource_value, - rp.status, - COUNT(DISTINCT rh.license_id) as unique_licenses, - COUNT(rh.id) as total_allocations, - MIN(rh.action_at) as first_used, - MAX(rh.action_at) as last_used - FROM resource_pools rp - LEFT JOIN resource_history rh ON rp.id = rh.resource_id - AND rh.action = 'allocated' - AND rh.action_at BETWEEN %s AND %s - GROUP BY rp.id, rp.resource_type, rp.resource_value, rp.status - ORDER BY rp.resource_type, total_allocations DESC - """ - cur.execute(query, (date_from, date_to)) - columns = ['Typ', 'Ressource', 'Status', 'Unique Lizenzen', 'Gesamt Zuweisungen', 'Erste Nutzung', 'Letzte Nutzung'] - - elif report_type == 'performance': - # Performance-Report - query = """ - SELECT - rp.resource_type, - rp.resource_value, - AVG(rm.performance_score) as avg_performance, - SUM(rm.usage_count) as total_usage, - SUM(rm.revenue) as total_revenue, - SUM(rm.cost) as total_cost, - SUM(rm.revenue - rm.cost) as profit, - SUM(rm.issues_count) as total_issues - FROM resource_pools rp - JOIN resource_metrics rm ON rp.id = rm.resource_id - WHERE rm.metric_date BETWEEN %s AND %s - GROUP BY rp.id, rp.resource_type, rp.resource_value - ORDER BY profit DESC - """ - cur.execute(query, (date_from, date_to)) - columns = ['Typ', 'Ressource', 'Durchschn. Performance', 'Gesamt Nutzung', 'Umsatz', 'Kosten', 'Gewinn', 'Issues'] - - elif report_type == 'compliance': - # Compliance-Report - query = """ - SELECT - rh.action_at, - rh.action, - rh.action_by, - rp.resource_type, - rp.resource_value, - l.license_key, - c.name as customer_name, - rh.ip_address - FROM resource_history rh - JOIN resource_pools rp ON rh.resource_id = rp.id - LEFT JOIN licenses l ON rh.license_id = l.id - LEFT JOIN customers c ON l.customer_id = c.id - WHERE rh.action_at BETWEEN %s AND %s - ORDER BY rh.action_at DESC - """ - cur.execute(query, (date_from, date_to)) - columns = ['Zeit', 'Aktion', 'Von', 'Typ', 'Ressource', 'Lizenz', 'Kunde', 'IP-Adresse'] - - else: # inventory report - # Inventar-Report - query = """ - SELECT - resource_type, - COUNT(*) FILTER (WHERE status = 'available') as available, - COUNT(*) FILTER (WHERE status = 'allocated') as allocated, - COUNT(*) FILTER (WHERE status = 'quarantine') as quarantine, - COUNT(*) as total - FROM resource_pools - GROUP BY resource_type - ORDER BY resource_type - """ - cur.execute(query) - columns = ['Typ', 'Verfügbar', 'Zugeteilt', 'Quarantäne', 'Gesamt'] - - # Convert to DataFrame - data = cur.fetchall() - df = pd.DataFrame(data, columns=columns) - - cur.close() - conn.close() - - # Generate file - timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') - filename = f"resource_report_{report_type}_{timestamp}" - - if format_type == 'excel': - output = io.BytesIO() - with pd.ExcelWriter(output, engine='openpyxl') as writer: - df.to_excel(writer, sheet_name='Report', index=False) - - # Auto-adjust columns width - worksheet = writer.sheets['Report'] - for column in worksheet.columns: - max_length = 0 - column = [cell for cell in column] - for cell in column: - try: - if len(str(cell.value)) > max_length: - max_length = len(str(cell.value)) - except: - pass - adjusted_width = (max_length + 2) - worksheet.column_dimensions[column[0].column_letter].width = adjusted_width - - output.seek(0) - - log_audit('EXPORT', 'resource_report', None, - new_values={'type': report_type, 'format': 'excel', 'rows': len(df)}, - additional_info=f"Resource Report {report_type} exportiert") - - return send_file(output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=f'{filename}.xlsx') - - else: # CSV - output = io.StringIO() - df.to_csv(output, index=False, sep=';', encoding='utf-8-sig') - output.seek(0) - - log_audit('EXPORT', 'resource_report', None, - new_values={'type': report_type, 'format': 'csv', 'rows': len(df)}, - additional_info=f"Resource Report {report_type} exportiert") - - return send_file(io.BytesIO(output.getvalue().encode('utf-8-sig')), - mimetype='text/csv', - as_attachment=True, - download_name=f'{filename}.csv') - - # Wenn kein Download, zeige Report-Formular - return render_template('resource_report.html', - datetime=datetime, - timedelta=timedelta, - username=session.get('username')) - -if __name__ == "__main__": - app.run(host="0.0.0.0", port=5000) diff --git a/v2_adminpanel/app.py.backup_20250616_233145 b/v2_adminpanel/app.py.backup_20250616_233145 deleted file mode 100644 index 0622714..0000000 --- a/v2_adminpanel/app.py.backup_20250616_233145 +++ /dev/null @@ -1,4461 +0,0 @@ -import os -import sys -import time -import json -import logging -import requests -import re -import random -import base64 -from io import BytesIO -from datetime import datetime, timedelta -from zoneinfo import ZoneInfo -from pathlib import Path - -# Add current directory to Python path to ensure modules can be imported -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) - -from flask import Flask, render_template, request, redirect, session, url_for, send_file, jsonify, flash -from flask_session import Session -from werkzeug.middleware.proxy_fix import ProxyFix -from apscheduler.schedulers.background import BackgroundScheduler -import pandas as pd -from psycopg2.extras import Json - -# Import our new modules -import config -from db import get_connection, get_db_connection, get_db_cursor, execute_query -from auth.decorators import login_required -from auth.password import hash_password, verify_password -from auth.two_factor import ( - generate_totp_secret, generate_qr_code, verify_totp, - generate_backup_codes, hash_backup_code, verify_backup_code -) -from auth.rate_limiting import ( - check_ip_blocked, record_failed_attempt, - reset_login_attempts, get_login_attempts -) -from utils.network import get_client_ip -from utils.audit import log_audit -from utils.license import generate_license_key, validate_license_key -from utils.backup import create_backup, restore_backup, get_or_create_encryption_key -from utils.export import ( - create_excel_export, format_datetime_for_export, - prepare_license_export_data, prepare_customer_export_data, - prepare_session_export_data, prepare_audit_export_data -) -from models import get_user_by_username - -app = Flask(__name__) -# Load configuration from config module -app.config['SECRET_KEY'] = config.SECRET_KEY -app.config['SESSION_TYPE'] = config.SESSION_TYPE -app.config['JSON_AS_ASCII'] = config.JSON_AS_ASCII -app.config['JSONIFY_MIMETYPE'] = config.JSONIFY_MIMETYPE -app.config['PERMANENT_SESSION_LIFETIME'] = config.PERMANENT_SESSION_LIFETIME -app.config['SESSION_COOKIE_HTTPONLY'] = config.SESSION_COOKIE_HTTPONLY -app.config['SESSION_COOKIE_SECURE'] = config.SESSION_COOKIE_SECURE -app.config['SESSION_COOKIE_SAMESITE'] = config.SESSION_COOKIE_SAMESITE -app.config['SESSION_COOKIE_NAME'] = config.SESSION_COOKIE_NAME -app.config['SESSION_REFRESH_EACH_REQUEST'] = config.SESSION_REFRESH_EACH_REQUEST -Session(app) - -# ProxyFix für korrekte IP-Adressen hinter Nginx -app.wsgi_app = ProxyFix( - app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1 -) - -# Configuration is now loaded from config module - -# Scheduler für automatische Backups -scheduler = BackgroundScheduler() -scheduler.start() - -# Logging konfigurieren -logging.basicConfig(level=logging.INFO) - -# Import and register blueprints -from routes.auth_routes import auth_bp -from routes.admin_routes import admin_bp - -# Temporarily comment out blueprints to avoid conflicts -# app.register_blueprint(auth_bp) -# app.register_blueprint(admin_bp) - - -# Scheduled Backup Job -def scheduled_backup(): - """Führt ein geplantes Backup aus""" - logging.info("Starte geplantes Backup...") - create_backup(backup_type="scheduled", created_by="scheduler") - -# Scheduler konfigurieren - täglich um 3:00 Uhr -scheduler.add_job( - scheduled_backup, - 'cron', - hour=config.SCHEDULER_CONFIG['backup_hour'], - minute=config.SCHEDULER_CONFIG['backup_minute'], - id='daily_backup', - replace_existing=True -) - - -def verify_recaptcha(response): - """Verifiziert die reCAPTCHA v2 Response mit Google""" - secret_key = config.RECAPTCHA_SECRET_KEY - - # Wenn kein Secret Key konfiguriert ist, CAPTCHA als bestanden werten (für PoC) - if not secret_key: - logging.warning("RECAPTCHA_SECRET_KEY nicht konfiguriert - CAPTCHA wird übersprungen") - return True - - # Verifizierung bei Google - try: - verify_url = 'https://www.google.com/recaptcha/api/siteverify' - data = { - 'secret': secret_key, - 'response': response - } - - # Timeout für Request setzen - r = requests.post(verify_url, data=data, timeout=5) - result = r.json() - - # Log für Debugging - if not result.get('success'): - logging.warning(f"reCAPTCHA Validierung fehlgeschlagen: {result.get('error-codes', [])}") - - return result.get('success', False) - - except requests.exceptions.RequestException as e: - logging.error(f"reCAPTCHA Verifizierung fehlgeschlagen: {str(e)}") - # Bei Netzwerkfehlern CAPTCHA als bestanden werten - return True - except Exception as e: - logging.error(f"Unerwarteter Fehler bei reCAPTCHA: {str(e)}") - return False - - -@app.route("/login", methods=["GET", "POST"]) -def login(): - # Timing-Attack Schutz - Start Zeit merken - start_time = time.time() - - # IP-Adresse ermitteln - ip_address = get_client_ip() - - # Prüfen ob IP gesperrt ist - is_blocked, blocked_until = check_ip_blocked(ip_address) - if is_blocked: - time_remaining = (blocked_until - datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None)).total_seconds() / 3600 - error_msg = f"IP GESPERRT! Noch {time_remaining:.1f} Stunden warten." - return render_template("login.html", error=error_msg, error_type="blocked") - - # Anzahl bisheriger Versuche - attempt_count = get_login_attempts(ip_address) - - if request.method == "POST": - username = request.form.get("username") - password = request.form.get("password") - captcha_response = request.form.get("g-recaptcha-response") - - # CAPTCHA-Prüfung nur wenn Keys konfiguriert sind - recaptcha_site_key = config.RECAPTCHA_SITE_KEY - if attempt_count >= config.CAPTCHA_AFTER_ATTEMPTS and recaptcha_site_key: - if not captcha_response: - # Timing-Attack Schutz - elapsed = time.time() - start_time - if elapsed < 1.0: - time.sleep(1.0 - elapsed) - return render_template("login.html", - error="CAPTCHA ERFORDERLICH!", - show_captcha=True, - error_type="captcha", - attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - attempt_count), - recaptcha_site_key=recaptcha_site_key) - - # CAPTCHA validieren - if not verify_recaptcha(captcha_response): - # Timing-Attack Schutz - elapsed = time.time() - start_time - if elapsed < 1.0: - time.sleep(1.0 - elapsed) - return render_template("login.html", - error="CAPTCHA UNGÜLTIG! Bitte erneut versuchen.", - show_captcha=True, - error_type="captcha", - attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - attempt_count), - recaptcha_site_key=recaptcha_site_key) - - # Check user in database first, fallback to env vars - user = get_user_by_username(username) - login_success = False - needs_2fa = False - - if user: - # Database user authentication - if verify_password(password, user['password_hash']): - login_success = True - needs_2fa = user['totp_enabled'] - else: - # Fallback to environment variables for backward compatibility - if username in config.ADMIN_USERS and password == config.ADMIN_USERS[username]: - login_success = True - - # Timing-Attack Schutz - Mindestens 1 Sekunde warten - elapsed = time.time() - start_time - if elapsed < 1.0: - time.sleep(1.0 - elapsed) - - if login_success: - # Erfolgreicher Login - if needs_2fa: - # Store temporary session for 2FA verification - session['temp_username'] = username - session['temp_user_id'] = user['id'] - session['awaiting_2fa'] = True - return redirect(url_for('verify_2fa')) - else: - # Complete login without 2FA - session.permanent = True # Aktiviert das Timeout - session['logged_in'] = True - session['username'] = username - session['user_id'] = user['id'] if user else None - session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() - reset_login_attempts(ip_address) - log_audit('LOGIN_SUCCESS', 'user', - additional_info=f"Erfolgreiche Anmeldung von IP: {ip_address}") - return redirect(url_for('dashboard')) - else: - # Fehlgeschlagener Login - error_message = record_failed_attempt(ip_address, username) - new_attempt_count = get_login_attempts(ip_address) - - # Prüfen ob jetzt gesperrt - is_now_blocked, _ = check_ip_blocked(ip_address) - if is_now_blocked: - log_audit('LOGIN_BLOCKED', 'security', - additional_info=f"IP {ip_address} wurde nach {config.MAX_LOGIN_ATTEMPTS} Versuchen gesperrt") - - return render_template("login.html", - error=error_message, - show_captcha=(new_attempt_count >= config.CAPTCHA_AFTER_ATTEMPTS and config.RECAPTCHA_SITE_KEY), - error_type="failed", - attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - new_attempt_count), - recaptcha_site_key=config.RECAPTCHA_SITE_KEY) - - # GET Request - return render_template("login.html", - show_captcha=(attempt_count >= config.CAPTCHA_AFTER_ATTEMPTS and config.RECAPTCHA_SITE_KEY), - attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - attempt_count), - recaptcha_site_key=config.RECAPTCHA_SITE_KEY) - -@app.route("/logout") -def logout(): - username = session.get('username', 'unknown') - log_audit('LOGOUT', 'user', additional_info=f"Abmeldung") - session.pop('logged_in', None) - session.pop('username', None) - session.pop('user_id', None) - session.pop('temp_username', None) - session.pop('temp_user_id', None) - session.pop('awaiting_2fa', None) - return redirect(url_for('login')) - -@app.route("/verify-2fa", methods=["GET", "POST"]) -def verify_2fa(): - if not session.get('awaiting_2fa'): - return redirect(url_for('login')) - - if request.method == "POST": - token = request.form.get('token', '').replace(' ', '') - username = session.get('temp_username') - user_id = session.get('temp_user_id') - - if not username or not user_id: - flash('Session expired. Please login again.', 'error') - return redirect(url_for('login')) - - user = get_user_by_username(username) - if not user: - flash('User not found.', 'error') - return redirect(url_for('login')) - - # Check if it's a backup code - if len(token) == 8 and token.isupper(): - # Try backup code - backup_codes = json.loads(user['backup_codes']) if user['backup_codes'] else [] - if verify_backup_code(token, backup_codes): - # Remove used backup code - code_hash = hash_backup_code(token) - backup_codes.remove(code_hash) - - conn = get_connection() - cur = conn.cursor() - cur.execute("UPDATE users SET backup_codes = %s WHERE id = %s", - (json.dumps(backup_codes), user_id)) - conn.commit() - cur.close() - conn.close() - - # Complete login - session.permanent = True - session['logged_in'] = True - session['username'] = username - session['user_id'] = user_id - session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() - session.pop('temp_username', None) - session.pop('temp_user_id', None) - session.pop('awaiting_2fa', None) - - flash('Login successful using backup code. Please generate new backup codes.', 'warning') - log_audit('LOGIN_2FA_BACKUP', 'user', additional_info=f"2FA login with backup code") - return redirect(url_for('dashboard')) - else: - # Try TOTP token - if verify_totp(user['totp_secret'], token): - # Complete login - session.permanent = True - session['logged_in'] = True - session['username'] = username - session['user_id'] = user_id - session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() - session.pop('temp_username', None) - session.pop('temp_user_id', None) - session.pop('awaiting_2fa', None) - - log_audit('LOGIN_2FA_SUCCESS', 'user', additional_info=f"2FA login successful") - return redirect(url_for('dashboard')) - - # Failed verification - conn = get_connection() - cur = conn.cursor() - cur.execute("UPDATE users SET failed_2fa_attempts = failed_2fa_attempts + 1, last_failed_2fa = %s WHERE id = %s", - (datetime.now(), user_id)) - conn.commit() - cur.close() - conn.close() - - flash('Invalid authentication code. Please try again.', 'error') - log_audit('LOGIN_2FA_FAILED', 'user', additional_info=f"Failed 2FA attempt") - - return render_template('verify_2fa.html') - -@app.route("/profile") -@login_required -def profile(): - user = get_user_by_username(session['username']) - if not user: - # For environment-based users, redirect with message - flash('Bitte führen Sie das Migrations-Script aus, um Passwort-Änderung und 2FA zu aktivieren.', 'info') - return redirect(url_for('dashboard')) - return render_template('profile.html', user=user) - -@app.route("/profile/change-password", methods=["POST"]) -@login_required -def change_password(): - current_password = request.form.get('current_password') - new_password = request.form.get('new_password') - confirm_password = request.form.get('confirm_password') - - user = get_user_by_username(session['username']) - - # Verify current password - if not verify_password(current_password, user['password_hash']): - flash('Current password is incorrect.', 'error') - return redirect(url_for('profile')) - - # Check new password - if new_password != confirm_password: - flash('New passwords do not match.', 'error') - return redirect(url_for('profile')) - - if len(new_password) < 8: - flash('Password must be at least 8 characters long.', 'error') - return redirect(url_for('profile')) - - # Update password - new_hash = hash_password(new_password) - conn = get_connection() - cur = conn.cursor() - cur.execute("UPDATE users SET password_hash = %s, last_password_change = %s WHERE id = %s", - (new_hash, datetime.now(), user['id'])) - conn.commit() - cur.close() - conn.close() - - log_audit('PASSWORD_CHANGE', 'user', entity_id=user['id'], - additional_info="Password changed successfully") - flash('Password changed successfully.', 'success') - return redirect(url_for('profile')) - -@app.route("/profile/setup-2fa") -@login_required -def setup_2fa(): - user = get_user_by_username(session['username']) - - if user['totp_enabled']: - flash('2FA is already enabled for your account.', 'info') - return redirect(url_for('profile')) - - # Generate new TOTP secret - totp_secret = generate_totp_secret() - session['temp_totp_secret'] = totp_secret - - # Generate QR code - qr_code = generate_qr_code(user['username'], totp_secret) - - return render_template('setup_2fa.html', - totp_secret=totp_secret, - qr_code=qr_code) - -@app.route("/profile/enable-2fa", methods=["POST"]) -@login_required -def enable_2fa(): - token = request.form.get('token', '').replace(' ', '') - totp_secret = session.get('temp_totp_secret') - - if not totp_secret: - flash('2FA setup session expired. Please try again.', 'error') - return redirect(url_for('setup_2fa')) - - # Verify the token - if not verify_totp(totp_secret, token): - flash('Invalid authentication code. Please try again.', 'error') - return redirect(url_for('setup_2fa')) - - # Generate backup codes - backup_codes = generate_backup_codes() - hashed_codes = [hash_backup_code(code) for code in backup_codes] - - # Enable 2FA - conn = get_connection() - cur = conn.cursor() - cur.execute(""" - UPDATE users - SET totp_secret = %s, totp_enabled = TRUE, backup_codes = %s - WHERE username = %s - """, (totp_secret, json.dumps(hashed_codes), session['username'])) - conn.commit() - cur.close() - conn.close() - - session.pop('temp_totp_secret', None) - - log_audit('2FA_ENABLED', 'user', additional_info="2FA enabled successfully") - - # Show backup codes - return render_template('backup_codes.html', backup_codes=backup_codes) - -@app.route("/profile/disable-2fa", methods=["POST"]) -@login_required -def disable_2fa(): - password = request.form.get('password') - user = get_user_by_username(session['username']) - - # Verify password - if not verify_password(password, user['password_hash']): - flash('Incorrect password.', 'error') - return redirect(url_for('profile')) - - # Disable 2FA - conn = get_connection() - cur = conn.cursor() - cur.execute(""" - UPDATE users - SET totp_enabled = FALSE, totp_secret = NULL, backup_codes = NULL - WHERE username = %s - """, (session['username'],)) - conn.commit() - cur.close() - conn.close() - - log_audit('2FA_DISABLED', 'user', additional_info="2FA disabled") - flash('2FA has been disabled for your account.', 'success') - return redirect(url_for('profile')) - -@app.route("/heartbeat", methods=['POST']) -@login_required -def heartbeat(): - """Endpoint für Session Keep-Alive - aktualisiert last_activity""" - # Aktualisiere last_activity nur wenn explizit angefordert - session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() - # Force session save - session.modified = True - - return jsonify({ - 'status': 'ok', - 'last_activity': session['last_activity'], - 'username': session.get('username') - }) - -@app.route("/api/generate-license-key", methods=['POST']) -@login_required -def api_generate_key(): - """API Endpoint zur Generierung eines neuen Lizenzschlüssels""" - try: - # Lizenztyp aus Request holen (default: full) - data = request.get_json() or {} - license_type = data.get('type', 'full') - - # Key generieren - key = generate_license_key(license_type) - - # Prüfen ob Key bereits existiert (sehr unwahrscheinlich aber sicher ist sicher) - conn = get_connection() - cur = conn.cursor() - - # Wiederhole bis eindeutiger Key gefunden - attempts = 0 - while attempts < 10: # Max 10 Versuche - cur.execute("SELECT 1 FROM licenses WHERE license_key = %s", (key,)) - if not cur.fetchone(): - break # Key ist eindeutig - key = generate_license_key(license_type) - attempts += 1 - - cur.close() - conn.close() - - # Log für Audit - log_audit('GENERATE_KEY', 'license', - additional_info={'type': license_type, 'key': key}) - - return jsonify({ - 'success': True, - 'key': key, - 'type': license_type - }) - - except Exception as e: - logging.error(f"Fehler bei Key-Generierung: {str(e)}") - return jsonify({ - 'success': False, - 'error': 'Fehler bei der Key-Generierung' - }), 500 - -@app.route("/api/customers", methods=['GET']) -@login_required -def api_customers(): - """API Endpoint für die Kundensuche mit Select2""" - try: - # Suchparameter - search = request.args.get('q', '').strip() - page = request.args.get('page', 1, type=int) - per_page = 20 - customer_id = request.args.get('id', type=int) - - conn = get_connection() - cur = conn.cursor() - - # Einzelnen Kunden per ID abrufen - if customer_id: - cur.execute(""" - SELECT c.id, c.name, c.email, - COUNT(l.id) as license_count - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - WHERE c.id = %s - GROUP BY c.id, c.name, c.email - """, (customer_id,)) - - customer = cur.fetchone() - results = [] - if customer: - results.append({ - 'id': customer[0], - 'text': f"{customer[1]} ({customer[2]})", - 'name': customer[1], - 'email': customer[2], - 'license_count': customer[3] - }) - - cur.close() - conn.close() - - return jsonify({ - 'results': results, - 'pagination': {'more': False} - }) - - # SQL Query mit optionaler Suche - elif search: - cur.execute(""" - SELECT c.id, c.name, c.email, - COUNT(l.id) as license_count - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - WHERE LOWER(c.name) LIKE LOWER(%s) - OR LOWER(c.email) LIKE LOWER(%s) - GROUP BY c.id, c.name, c.email - ORDER BY c.name - LIMIT %s OFFSET %s - """, (f'%{search}%', f'%{search}%', per_page, (page - 1) * per_page)) - else: - cur.execute(""" - SELECT c.id, c.name, c.email, - COUNT(l.id) as license_count - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - GROUP BY c.id, c.name, c.email - ORDER BY c.name - LIMIT %s OFFSET %s - """, (per_page, (page - 1) * per_page)) - - customers = cur.fetchall() - - # Format für Select2 - results = [] - for customer in customers: - results.append({ - 'id': customer[0], - 'text': f"{customer[1]} - {customer[2]} ({customer[3]} Lizenzen)", - 'name': customer[1], - 'email': customer[2], - 'license_count': customer[3] - }) - - # Gesamtanzahl für Pagination - if search: - cur.execute(""" - SELECT COUNT(*) FROM customers - WHERE LOWER(name) LIKE LOWER(%s) - OR LOWER(email) LIKE LOWER(%s) - """, (f'%{search}%', f'%{search}%')) - else: - cur.execute("SELECT COUNT(*) FROM customers") - - total_count = cur.fetchone()[0] - - cur.close() - conn.close() - - # Select2 Response Format - return jsonify({ - 'results': results, - 'pagination': { - 'more': (page * per_page) < total_count - } - }) - - except Exception as e: - logging.error(f"Fehler bei Kundensuche: {str(e)}") - return jsonify({ - 'results': [], - 'error': 'Fehler bei der Kundensuche' - }), 500 - -@app.route("/") -@login_required -def dashboard(): - conn = get_connection() - cur = conn.cursor() - - # Statistiken abrufen - # Gesamtanzahl Kunden (ohne Testdaten) - cur.execute("SELECT COUNT(*) FROM customers WHERE is_test = FALSE") - total_customers = cur.fetchone()[0] - - # Gesamtanzahl Lizenzen (ohne Testdaten) - cur.execute("SELECT COUNT(*) FROM licenses WHERE is_test = FALSE") - total_licenses = cur.fetchone()[0] - - # Aktive Lizenzen (nicht abgelaufen und is_active = true, ohne Testdaten) - cur.execute(""" - SELECT COUNT(*) FROM licenses - WHERE valid_until >= CURRENT_DATE AND is_active = TRUE AND is_test = FALSE - """) - active_licenses = cur.fetchone()[0] - - # Aktive Sessions - cur.execute("SELECT COUNT(*) FROM sessions WHERE is_active = TRUE") - active_sessions_count = cur.fetchone()[0] - - # Abgelaufene Lizenzen (ohne Testdaten) - cur.execute(""" - SELECT COUNT(*) FROM licenses - WHERE valid_until < CURRENT_DATE AND is_test = FALSE - """) - expired_licenses = cur.fetchone()[0] - - # Deaktivierte Lizenzen (ohne Testdaten) - cur.execute(""" - SELECT COUNT(*) FROM licenses - WHERE is_active = FALSE AND is_test = FALSE - """) - inactive_licenses = cur.fetchone()[0] - - # Lizenzen die in den nächsten 30 Tagen ablaufen (ohne Testdaten) - cur.execute(""" - SELECT COUNT(*) FROM licenses - WHERE valid_until >= CURRENT_DATE - AND valid_until < CURRENT_DATE + INTERVAL '30 days' - AND is_active = TRUE - AND is_test = FALSE - """) - expiring_soon = cur.fetchone()[0] - - # Testlizenzen vs Vollversionen (ohne Testdaten) - cur.execute(""" - SELECT license_type, COUNT(*) - FROM licenses - WHERE is_test = FALSE - GROUP BY license_type - """) - license_types = dict(cur.fetchall()) - - # Anzahl Testdaten - cur.execute("SELECT COUNT(*) FROM licenses WHERE is_test = TRUE") - test_data_count = cur.fetchone()[0] - - # Anzahl Test-Kunden - cur.execute("SELECT COUNT(*) FROM customers WHERE is_test = TRUE") - test_customers_count = cur.fetchone()[0] - - # Anzahl Test-Ressourcen - cur.execute("SELECT COUNT(*) FROM resource_pools WHERE is_test = TRUE") - test_resources_count = cur.fetchone()[0] - - # Letzte 5 erstellten Lizenzen (ohne Testdaten) - cur.execute(""" - SELECT l.id, l.license_key, c.name, l.valid_until, - CASE - WHEN l.is_active = FALSE THEN 'deaktiviert' - WHEN l.valid_until < CURRENT_DATE THEN 'abgelaufen' - WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'läuft bald ab' - ELSE 'aktiv' - END as status - FROM licenses l - JOIN customers c ON l.customer_id = c.id - WHERE l.is_test = FALSE - ORDER BY l.id DESC - LIMIT 5 - """) - recent_licenses = cur.fetchall() - - # Bald ablaufende Lizenzen (nächste 30 Tage, ohne Testdaten) - cur.execute(""" - SELECT l.id, l.license_key, c.name, l.valid_until, - l.valid_until - CURRENT_DATE as days_left - FROM licenses l - JOIN customers c ON l.customer_id = c.id - WHERE l.valid_until >= CURRENT_DATE - AND l.valid_until < CURRENT_DATE + INTERVAL '30 days' - AND l.is_active = TRUE - AND l.is_test = FALSE - ORDER BY l.valid_until - LIMIT 10 - """) - expiring_licenses = cur.fetchall() - - # Letztes Backup - cur.execute(""" - SELECT created_at, filesize, duration_seconds, backup_type, status - FROM backup_history - ORDER BY created_at DESC - LIMIT 1 - """) - last_backup_info = cur.fetchone() - - # Sicherheitsstatistiken - # Gesperrte IPs - cur.execute(""" - SELECT COUNT(*) FROM login_attempts - WHERE blocked_until IS NOT NULL AND blocked_until > CURRENT_TIMESTAMP - """) - blocked_ips_count = cur.fetchone()[0] - - # Fehlversuche heute - cur.execute(""" - SELECT COALESCE(SUM(attempt_count), 0) FROM login_attempts - WHERE last_attempt::date = CURRENT_DATE - """) - failed_attempts_today = cur.fetchone()[0] - - # Letzte 5 Sicherheitsereignisse - cur.execute(""" - SELECT - la.ip_address, - la.attempt_count, - la.last_attempt, - la.blocked_until, - la.last_username_tried, - la.last_error_message - FROM login_attempts la - ORDER BY la.last_attempt DESC - LIMIT 5 - """) - recent_security_events = [] - for event in cur.fetchall(): - recent_security_events.append({ - 'ip_address': event[0], - 'attempt_count': event[1], - 'last_attempt': event[2].strftime('%d.%m %H:%M'), - 'blocked_until': event[3].strftime('%d.%m %H:%M') if event[3] and event[3] > datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None) else None, - 'username_tried': event[4], - 'error_message': event[5] - }) - - # Sicherheitslevel berechnen - if blocked_ips_count > 5 or failed_attempts_today > 50: - security_level = 'danger' - security_level_text = 'KRITISCH' - elif blocked_ips_count > 2 or failed_attempts_today > 20: - security_level = 'warning' - security_level_text = 'ERHÖHT' - else: - security_level = 'success' - security_level_text = 'NORMAL' - - # Resource Pool Statistiken (nur Live-Daten, keine Testdaten) - cur.execute(""" - SELECT - resource_type, - COUNT(*) FILTER (WHERE status = 'available') as available, - COUNT(*) FILTER (WHERE status = 'allocated') as allocated, - COUNT(*) FILTER (WHERE status = 'quarantine') as quarantine, - COUNT(*) as total - FROM resource_pools - WHERE is_test = FALSE - GROUP BY resource_type - """) - - resource_stats = {} - resource_warning = None - - for row in cur.fetchall(): - available_percent = round((row[1] / row[4] * 100) if row[4] > 0 else 0, 1) - resource_stats[row[0]] = { - 'available': row[1], - 'allocated': row[2], - 'quarantine': row[3], - 'total': row[4], - 'available_percent': available_percent, - 'warning_level': 'danger' if row[1] < 50 else 'warning' if row[1] < 100 else 'success' - } - - # Warnung bei niedrigem Bestand - if row[1] < 50: - if not resource_warning: - resource_warning = f"Niedriger Bestand bei {row[0].upper()}: nur noch {row[1]} verfügbar!" - else: - resource_warning += f" | {row[0].upper()}: {row[1]}" - - cur.close() - conn.close() - - stats = { - 'total_customers': total_customers, - 'total_licenses': total_licenses, - 'active_licenses': active_licenses, - 'expired_licenses': expired_licenses, - 'inactive_licenses': inactive_licenses, - 'expiring_soon': expiring_soon, - 'full_licenses': license_types.get('full', 0), - 'test_licenses': license_types.get('test', 0), - 'test_data_count': test_data_count, - 'test_customers_count': test_customers_count, - 'test_resources_count': test_resources_count, - 'recent_licenses': recent_licenses, - 'expiring_licenses': expiring_licenses, - 'active_sessions': active_sessions_count, - 'last_backup': last_backup_info, - # Sicherheitsstatistiken - 'blocked_ips_count': blocked_ips_count, - 'failed_attempts_today': failed_attempts_today, - 'recent_security_events': recent_security_events, - 'security_level': security_level, - 'security_level_text': security_level_text, - 'resource_stats': resource_stats - } - - return render_template("dashboard.html", - stats=stats, - resource_stats=resource_stats, - resource_warning=resource_warning, - username=session.get('username')) - -@app.route("/create", methods=["GET", "POST"]) -@login_required -def create_license(): - if request.method == "POST": - customer_id = request.form.get("customer_id") - license_key = request.form["license_key"].upper() # Immer Großbuchstaben - license_type = request.form["license_type"] - valid_from = request.form["valid_from"] - is_test = request.form.get("is_test") == "on" # Checkbox value - - # Berechne valid_until basierend auf Laufzeit - duration = int(request.form.get("duration", 1)) - duration_type = request.form.get("duration_type", "years") - - from datetime import datetime, timedelta - from dateutil.relativedelta import relativedelta - - start_date = datetime.strptime(valid_from, "%Y-%m-%d") - - if duration_type == "days": - end_date = start_date + timedelta(days=duration) - elif duration_type == "months": - end_date = start_date + relativedelta(months=duration) - else: # years - end_date = start_date + relativedelta(years=duration) - - # Ein Tag abziehen, da der Starttag mitgezählt wird - end_date = end_date - timedelta(days=1) - valid_until = end_date.strftime("%Y-%m-%d") - - # Validiere License Key Format - if not validate_license_key(license_key): - flash('Ungültiges License Key Format! Erwartet: AF-YYYYMMFT-XXXX-YYYY-ZZZZ', 'error') - return redirect(url_for('create_license')) - - # Resource counts - domain_count = int(request.form.get("domain_count", 1)) - ipv4_count = int(request.form.get("ipv4_count", 1)) - phone_count = int(request.form.get("phone_count", 1)) - device_limit = int(request.form.get("device_limit", 3)) - - conn = get_connection() - cur = conn.cursor() - - try: - # Prüfe ob neuer Kunde oder bestehender - if customer_id == "new": - # Neuer Kunde - name = request.form.get("customer_name") - email = request.form.get("email") - - if not name: - flash('Kundenname ist erforderlich!', 'error') - return redirect(url_for('create_license')) - - # Prüfe ob E-Mail bereits existiert - if email: - cur.execute("SELECT id, name FROM customers WHERE LOWER(email) = LOWER(%s)", (email,)) - existing = cur.fetchone() - if existing: - flash(f'E-Mail bereits vergeben für Kunde: {existing[1]}', 'error') - return redirect(url_for('create_license')) - - # Kunde einfügen (erbt Test-Status von Lizenz) - cur.execute(""" - INSERT INTO customers (name, email, is_test, created_at) - VALUES (%s, %s, %s, NOW()) - RETURNING id - """, (name, email, is_test)) - customer_id = cur.fetchone()[0] - customer_info = {'name': name, 'email': email, 'is_test': is_test} - - # Audit-Log für neuen Kunden - log_audit('CREATE', 'customer', customer_id, - new_values={'name': name, 'email': email, 'is_test': is_test}) - else: - # Bestehender Kunde - hole Infos für Audit-Log - cur.execute("SELECT name, email, is_test FROM customers WHERE id = %s", (customer_id,)) - customer_data = cur.fetchone() - if not customer_data: - flash('Kunde nicht gefunden!', 'error') - return redirect(url_for('create_license')) - customer_info = {'name': customer_data[0], 'email': customer_data[1]} - - # Wenn Kunde Test-Kunde ist, Lizenz auch als Test markieren - if customer_data[2]: # is_test des Kunden - is_test = True - - # Lizenz hinzufügen - cur.execute(""" - INSERT INTO licenses (license_key, customer_id, license_type, valid_from, valid_until, is_active, - domain_count, ipv4_count, phone_count, device_limit, is_test) - VALUES (%s, %s, %s, %s, %s, TRUE, %s, %s, %s, %s, %s) - RETURNING id - """, (license_key, customer_id, license_type, valid_from, valid_until, - domain_count, ipv4_count, phone_count, device_limit, is_test)) - license_id = cur.fetchone()[0] - - # Ressourcen zuweisen - try: - # Prüfe Verfügbarkeit - cur.execute(""" - SELECT - (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s) as domains, - (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s) as ipv4s, - (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s) as phones - """, (is_test, is_test, is_test)) - available = cur.fetchone() - - if available[0] < domain_count: - raise ValueError(f"Nicht genügend Domains verfügbar (benötigt: {domain_count}, verfügbar: {available[0]})") - if available[1] < ipv4_count: - raise ValueError(f"Nicht genügend IPv4-Adressen verfügbar (benötigt: {ipv4_count}, verfügbar: {available[1]})") - if available[2] < phone_count: - raise ValueError(f"Nicht genügend Telefonnummern verfügbar (benötigt: {phone_count}, verfügbar: {available[2]})") - - # Domains zuweisen - if domain_count > 0: - cur.execute(""" - SELECT id FROM resource_pools - WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s - LIMIT %s FOR UPDATE - """, (is_test, domain_count)) - for (resource_id,) in cur.fetchall(): - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], resource_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, resource_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (resource_id, license_id, session['username'], get_client_ip())) - - # IPv4s zuweisen - if ipv4_count > 0: - cur.execute(""" - SELECT id FROM resource_pools - WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s - LIMIT %s FOR UPDATE - """, (is_test, ipv4_count)) - for (resource_id,) in cur.fetchall(): - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], resource_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, resource_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (resource_id, license_id, session['username'], get_client_ip())) - - # Telefonnummern zuweisen - if phone_count > 0: - cur.execute(""" - SELECT id FROM resource_pools - WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s - LIMIT %s FOR UPDATE - """, (is_test, phone_count)) - for (resource_id,) in cur.fetchall(): - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], resource_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, resource_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (resource_id, license_id, session['username'], get_client_ip())) - - except ValueError as e: - conn.rollback() - flash(str(e), 'error') - return redirect(url_for('create_license')) - - conn.commit() - - # Audit-Log - log_audit('CREATE', 'license', license_id, - new_values={ - 'license_key': license_key, - 'customer_name': customer_info['name'], - 'customer_email': customer_info['email'], - 'license_type': license_type, - 'valid_from': valid_from, - 'valid_until': valid_until, - 'device_limit': device_limit, - 'is_test': is_test - }) - - flash(f'Lizenz {license_key} erfolgreich erstellt!', 'success') - - except Exception as e: - conn.rollback() - logging.error(f"Fehler beim Erstellen der Lizenz: {str(e)}") - flash('Fehler beim Erstellen der Lizenz!', 'error') - finally: - cur.close() - conn.close() - - # Preserve show_test parameter if present - redirect_url = "/create" - if request.args.get('show_test') == 'true': - redirect_url += "?show_test=true" - return redirect(redirect_url) - - # Unterstützung für vorausgewählten Kunden - preselected_customer_id = request.args.get('customer_id', type=int) - return render_template("index.html", username=session.get('username'), preselected_customer_id=preselected_customer_id) - -@app.route("/batch", methods=["GET", "POST"]) -@login_required -def batch_licenses(): - """Batch-Generierung mehrerer Lizenzen für einen Kunden""" - if request.method == "POST": - # Formulardaten - customer_id = request.form.get("customer_id") - license_type = request.form["license_type"] - quantity = int(request.form["quantity"]) - valid_from = request.form["valid_from"] - is_test = request.form.get("is_test") == "on" # Checkbox value - - # Berechne valid_until basierend auf Laufzeit - duration = int(request.form.get("duration", 1)) - duration_type = request.form.get("duration_type", "years") - - from datetime import datetime, timedelta - from dateutil.relativedelta import relativedelta - - start_date = datetime.strptime(valid_from, "%Y-%m-%d") - - if duration_type == "days": - end_date = start_date + timedelta(days=duration) - elif duration_type == "months": - end_date = start_date + relativedelta(months=duration) - else: # years - end_date = start_date + relativedelta(years=duration) - - # Ein Tag abziehen, da der Starttag mitgezählt wird - end_date = end_date - timedelta(days=1) - valid_until = end_date.strftime("%Y-%m-%d") - - # Resource counts - domain_count = int(request.form.get("domain_count", 1)) - ipv4_count = int(request.form.get("ipv4_count", 1)) - phone_count = int(request.form.get("phone_count", 1)) - device_limit = int(request.form.get("device_limit", 3)) - - # Sicherheitslimit - if quantity < 1 or quantity > 100: - flash('Anzahl muss zwischen 1 und 100 liegen!', 'error') - return redirect(url_for('batch_licenses')) - - conn = get_connection() - cur = conn.cursor() - - try: - # Prüfe ob neuer Kunde oder bestehender - if customer_id == "new": - # Neuer Kunde - name = request.form.get("customer_name") - email = request.form.get("email") - - if not name: - flash('Kundenname ist erforderlich!', 'error') - return redirect(url_for('batch_licenses')) - - # Prüfe ob E-Mail bereits existiert - if email: - cur.execute("SELECT id, name FROM customers WHERE LOWER(email) = LOWER(%s)", (email,)) - existing = cur.fetchone() - if existing: - flash(f'E-Mail bereits vergeben für Kunde: {existing[1]}', 'error') - return redirect(url_for('batch_licenses')) - - # Kunde einfügen (erbt Test-Status von Lizenz) - cur.execute(""" - INSERT INTO customers (name, email, is_test, created_at) - VALUES (%s, %s, %s, NOW()) - RETURNING id - """, (name, email, is_test)) - customer_id = cur.fetchone()[0] - - # Audit-Log für neuen Kunden - log_audit('CREATE', 'customer', customer_id, - new_values={'name': name, 'email': email, 'is_test': is_test}) - else: - # Bestehender Kunde - hole Infos - cur.execute("SELECT name, email, is_test FROM customers WHERE id = %s", (customer_id,)) - customer_data = cur.fetchone() - if not customer_data: - flash('Kunde nicht gefunden!', 'error') - return redirect(url_for('batch_licenses')) - name = customer_data[0] - email = customer_data[1] - - # Wenn Kunde Test-Kunde ist, Lizenzen auch als Test markieren - if customer_data[2]: # is_test des Kunden - is_test = True - - # Prüfe Ressourcen-Verfügbarkeit für gesamten Batch - total_domains_needed = domain_count * quantity - total_ipv4s_needed = ipv4_count * quantity - total_phones_needed = phone_count * quantity - - cur.execute(""" - SELECT - (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s) as domains, - (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s) as ipv4s, - (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s) as phones - """, (is_test, is_test, is_test)) - available = cur.fetchone() - - if available[0] < total_domains_needed: - flash(f"Nicht genügend Domains verfügbar (benötigt: {total_domains_needed}, verfügbar: {available[0]})", 'error') - return redirect(url_for('batch_licenses')) - if available[1] < total_ipv4s_needed: - flash(f"Nicht genügend IPv4-Adressen verfügbar (benötigt: {total_ipv4s_needed}, verfügbar: {available[1]})", 'error') - return redirect(url_for('batch_licenses')) - if available[2] < total_phones_needed: - flash(f"Nicht genügend Telefonnummern verfügbar (benötigt: {total_phones_needed}, verfügbar: {available[2]})", 'error') - return redirect(url_for('batch_licenses')) - - # Lizenzen generieren und speichern - generated_licenses = [] - for i in range(quantity): - # Eindeutigen Key generieren - attempts = 0 - while attempts < 10: - license_key = generate_license_key(license_type) - cur.execute("SELECT 1 FROM licenses WHERE license_key = %s", (license_key,)) - if not cur.fetchone(): - break - attempts += 1 - - # Lizenz einfügen - cur.execute(""" - INSERT INTO licenses (license_key, customer_id, license_type, is_test, - valid_from, valid_until, is_active, - domain_count, ipv4_count, phone_count, device_limit) - VALUES (%s, %s, %s, %s, %s, %s, true, %s, %s, %s, %s) - RETURNING id - """, (license_key, customer_id, license_type, is_test, valid_from, valid_until, - domain_count, ipv4_count, phone_count, device_limit)) - license_id = cur.fetchone()[0] - - # Ressourcen für diese Lizenz zuweisen - # Domains - if domain_count > 0: - cur.execute(""" - SELECT id FROM resource_pools - WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s - LIMIT %s FOR UPDATE - """, (is_test, domain_count)) - for (resource_id,) in cur.fetchall(): - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], resource_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, resource_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (resource_id, license_id, session['username'], get_client_ip())) - - # IPv4s - if ipv4_count > 0: - cur.execute(""" - SELECT id FROM resource_pools - WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s - LIMIT %s FOR UPDATE - """, (is_test, ipv4_count)) - for (resource_id,) in cur.fetchall(): - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], resource_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, resource_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (resource_id, license_id, session['username'], get_client_ip())) - - # Telefonnummern - if phone_count > 0: - cur.execute(""" - SELECT id FROM resource_pools - WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s - LIMIT %s FOR UPDATE - """, (is_test, phone_count)) - for (resource_id,) in cur.fetchall(): - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], resource_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, resource_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (resource_id, license_id, session['username'], get_client_ip())) - - generated_licenses.append({ - 'id': license_id, - 'key': license_key, - 'type': license_type - }) - - conn.commit() - - # Audit-Log - log_audit('CREATE_BATCH', 'license', - new_values={'customer': name, 'quantity': quantity, 'type': license_type}, - additional_info=f"Batch-Generierung von {quantity} Lizenzen") - - # Session für Export speichern - session['batch_export'] = { - 'customer': name, - 'email': email, - 'licenses': generated_licenses, - 'valid_from': valid_from, - 'valid_until': valid_until, - 'timestamp': datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() - } - - flash(f'{quantity} Lizenzen erfolgreich generiert!', 'success') - return render_template("batch_result.html", - customer=name, - email=email, - licenses=generated_licenses, - valid_from=valid_from, - valid_until=valid_until) - - except Exception as e: - conn.rollback() - logging.error(f"Fehler bei Batch-Generierung: {str(e)}") - flash('Fehler bei der Batch-Generierung!', 'error') - return redirect(url_for('batch_licenses')) - finally: - cur.close() - conn.close() - - # GET Request - return render_template("batch_form.html") - -@app.route("/batch/export") -@login_required -def export_batch(): - """Exportiert die zuletzt generierten Batch-Lizenzen""" - batch_data = session.get('batch_export') - if not batch_data: - flash('Keine Batch-Daten zum Exportieren vorhanden!', 'error') - return redirect(url_for('batch_licenses')) - - # CSV generieren - output = io.StringIO() - output.write('\ufeff') # UTF-8 BOM für Excel - - # Header - output.write(f"Kunde: {batch_data['customer']}\n") - output.write(f"E-Mail: {batch_data['email']}\n") - output.write(f"Generiert am: {datetime.fromisoformat(batch_data['timestamp']).strftime('%d.%m.%Y %H:%M')}\n") - output.write(f"Gültig von: {batch_data['valid_from']} bis {batch_data['valid_until']}\n") - output.write("\n") - output.write("Nr;Lizenzschlüssel;Typ\n") - - # Lizenzen - for i, license in enumerate(batch_data['licenses'], 1): - typ_text = "Vollversion" if license['type'] == 'full' else "Testversion" - output.write(f"{i};{license['key']};{typ_text}\n") - - output.seek(0) - - # Audit-Log - log_audit('EXPORT', 'batch_licenses', - additional_info=f"Export von {len(batch_data['licenses'])} Batch-Lizenzen") - - return send_file( - io.BytesIO(output.getvalue().encode('utf-8-sig')), - mimetype='text/csv', - as_attachment=True, - download_name=f"batch_licenses_{batch_data['customer'].replace(' ', '_')}_{datetime.now(ZoneInfo('Europe/Berlin')).strftime('%Y%m%d_%H%M%S')}.csv" - ) - -@app.route("/licenses") -@login_required -def licenses(): - # Redirect zur kombinierten Ansicht - return redirect("/customers-licenses") - -@app.route("/license/edit/", methods=["GET", "POST"]) -@login_required -def edit_license(license_id): - conn = get_connection() - cur = conn.cursor() - - if request.method == "POST": - # Alte Werte für Audit-Log abrufen - cur.execute(""" - SELECT license_key, license_type, valid_from, valid_until, is_active, is_test, device_limit - FROM licenses WHERE id = %s - """, (license_id,)) - old_license = cur.fetchone() - - # Update license - license_key = request.form["license_key"] - license_type = request.form["license_type"] - valid_from = request.form["valid_from"] - valid_until = request.form["valid_until"] - is_active = request.form.get("is_active") == "on" - is_test = request.form.get("is_test") == "on" - device_limit = int(request.form.get("device_limit", 3)) - - cur.execute(""" - UPDATE licenses - SET license_key = %s, license_type = %s, valid_from = %s, - valid_until = %s, is_active = %s, is_test = %s, device_limit = %s - WHERE id = %s - """, (license_key, license_type, valid_from, valid_until, is_active, is_test, device_limit, license_id)) - - conn.commit() - - # Audit-Log - log_audit('UPDATE', 'license', license_id, - old_values={ - 'license_key': old_license[0], - 'license_type': old_license[1], - 'valid_from': str(old_license[2]), - 'valid_until': str(old_license[3]), - 'is_active': old_license[4], - 'is_test': old_license[5], - 'device_limit': old_license[6] - }, - new_values={ - 'license_key': license_key, - 'license_type': license_type, - 'valid_from': valid_from, - 'valid_until': valid_until, - 'is_active': is_active, - 'is_test': is_test, - 'device_limit': device_limit - }) - - cur.close() - conn.close() - - # Redirect zurück zu customers-licenses mit beibehaltenen Parametern - redirect_url = "/customers-licenses" - - # Behalte show_test Parameter bei (aus Form oder GET-Parameter) - show_test = request.form.get('show_test') or request.args.get('show_test') - if show_test == 'true': - redirect_url += "?show_test=true" - - # Behalte customer_id bei wenn vorhanden - if request.referrer and 'customer_id=' in request.referrer: - import re - match = re.search(r'customer_id=(\d+)', request.referrer) - if match: - connector = "&" if "?" in redirect_url else "?" - redirect_url += f"{connector}customer_id={match.group(1)}" - - return redirect(redirect_url) - - # Get license data - cur.execute(""" - SELECT l.id, l.license_key, c.name, c.email, l.license_type, - l.valid_from, l.valid_until, l.is_active, c.id, l.is_test, l.device_limit - FROM licenses l - JOIN customers c ON l.customer_id = c.id - WHERE l.id = %s - """, (license_id,)) - - license = cur.fetchone() - cur.close() - conn.close() - - if not license: - return redirect("/licenses") - - return render_template("edit_license.html", license=license, username=session.get('username')) - -@app.route("/license/delete/", methods=["POST"]) -@login_required -def delete_license(license_id): - conn = get_connection() - cur = conn.cursor() - - # Lizenzdetails für Audit-Log abrufen - cur.execute(""" - SELECT l.license_key, c.name, l.license_type - FROM licenses l - JOIN customers c ON l.customer_id = c.id - WHERE l.id = %s - """, (license_id,)) - license_info = cur.fetchone() - - cur.execute("DELETE FROM licenses WHERE id = %s", (license_id,)) - - conn.commit() - - # Audit-Log - if license_info: - log_audit('DELETE', 'license', license_id, - old_values={ - 'license_key': license_info[0], - 'customer_name': license_info[1], - 'license_type': license_info[2] - }) - - cur.close() - conn.close() - - return redirect("/licenses") - -@app.route("/customers") -@login_required -def customers(): - # Redirect zur kombinierten Ansicht - return redirect("/customers-licenses") - -@app.route("/customer/edit/", methods=["GET", "POST"]) -@login_required -def edit_customer(customer_id): - conn = get_connection() - cur = conn.cursor() - - if request.method == "POST": - # Alte Werte für Audit-Log abrufen - cur.execute("SELECT name, email, is_test FROM customers WHERE id = %s", (customer_id,)) - old_customer = cur.fetchone() - - # Update customer - name = request.form["name"] - email = request.form["email"] - is_test = request.form.get("is_test") == "on" - - cur.execute(""" - UPDATE customers - SET name = %s, email = %s, is_test = %s - WHERE id = %s - """, (name, email, is_test, customer_id)) - - conn.commit() - - # Audit-Log - log_audit('UPDATE', 'customer', customer_id, - old_values={ - 'name': old_customer[0], - 'email': old_customer[1], - 'is_test': old_customer[2] - }, - new_values={ - 'name': name, - 'email': email, - 'is_test': is_test - }) - - cur.close() - conn.close() - - # Redirect zurück zu customers-licenses mit beibehaltenen Parametern - redirect_url = "/customers-licenses" - - # Behalte show_test Parameter bei (aus Form oder GET-Parameter) - show_test = request.form.get('show_test') or request.args.get('show_test') - if show_test == 'true': - redirect_url += "?show_test=true" - - # Behalte customer_id bei (immer der aktuelle Kunde) - connector = "&" if "?" in redirect_url else "?" - redirect_url += f"{connector}customer_id={customer_id}" - - return redirect(redirect_url) - - # Get customer data with licenses - cur.execute(""" - SELECT id, name, email, created_at, is_test FROM customers WHERE id = %s - """, (customer_id,)) - - customer = cur.fetchone() - if not customer: - cur.close() - conn.close() - return "Kunde nicht gefunden", 404 - - - # Get customer's licenses - cur.execute(""" - SELECT id, license_key, license_type, valid_from, valid_until, is_active - FROM licenses - WHERE customer_id = %s - ORDER BY valid_until DESC - """, (customer_id,)) - - licenses = cur.fetchall() - - cur.close() - conn.close() - - if not customer: - return redirect("/customers-licenses") - - return render_template("edit_customer.html", customer=customer, licenses=licenses, username=session.get('username')) - -@app.route("/customer/create", methods=["GET", "POST"]) -@login_required -def create_customer(): - """Erstellt einen neuen Kunden ohne Lizenz""" - if request.method == "POST": - name = request.form.get('name') - email = request.form.get('email') - is_test = request.form.get('is_test') == 'on' - - if not name or not email: - flash("Name und E-Mail sind Pflichtfelder!", "error") - return render_template("create_customer.html", username=session.get('username')) - - conn = get_connection() - cur = conn.cursor() - - try: - # Prüfen ob E-Mail bereits existiert - cur.execute("SELECT id, name FROM customers WHERE email = %s", (email,)) - existing = cur.fetchone() - if existing: - flash(f"Ein Kunde mit der E-Mail '{email}' existiert bereits: {existing[1]}", "error") - return render_template("create_customer.html", username=session.get('username')) - - # Kunde erstellen - cur.execute(""" - INSERT INTO customers (name, email, created_at, is_test) - VALUES (%s, %s, %s, %s) RETURNING id - """, (name, email, datetime.now(), is_test)) - - customer_id = cur.fetchone()[0] - conn.commit() - - # Audit-Log - log_audit('CREATE', 'customer', customer_id, - new_values={ - 'name': name, - 'email': email, - 'is_test': is_test - }) - - flash(f"Kunde '{name}' wurde erfolgreich angelegt!", "success") - return redirect(f"/customer/edit/{customer_id}") - - except Exception as e: - conn.rollback() - flash(f"Fehler beim Anlegen des Kunden: {str(e)}", "error") - return render_template("create_customer.html", username=session.get('username')) - finally: - cur.close() - conn.close() - - # GET Request - Formular anzeigen - return render_template("create_customer.html", username=session.get('username')) - -@app.route("/customer/delete/", methods=["POST"]) -@login_required -def delete_customer(customer_id): - conn = get_connection() - cur = conn.cursor() - - # Prüfen ob Kunde Lizenzen hat - cur.execute("SELECT COUNT(*) FROM licenses WHERE customer_id = %s", (customer_id,)) - license_count = cur.fetchone()[0] - - if license_count > 0: - # Kunde hat Lizenzen - nicht löschen - cur.close() - conn.close() - return redirect("/customers") - - # Kundendetails für Audit-Log abrufen - cur.execute("SELECT name, email FROM customers WHERE id = %s", (customer_id,)) - customer_info = cur.fetchone() - - # Kunde löschen wenn keine Lizenzen vorhanden - cur.execute("DELETE FROM customers WHERE id = %s", (customer_id,)) - - conn.commit() - - # Audit-Log - if customer_info: - log_audit('DELETE', 'customer', customer_id, - old_values={ - 'name': customer_info[0], - 'email': customer_info[1] - }) - - cur.close() - conn.close() - - return redirect("/customers") - -@app.route("/customers-licenses") -@login_required -def customers_licenses(): - """Kombinierte Ansicht für Kunden und deren Lizenzen""" - conn = get_connection() - cur = conn.cursor() - - # Hole alle Kunden mit Lizenzstatistiken (inkl. Testkunden wenn gewünscht) - show_test = request.args.get('show_test', 'false').lower() == 'true' - - query = """ - SELECT - c.id, - c.name, - c.email, - c.created_at, - COUNT(l.id) as total_licenses, - COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE THEN 1 END) as active_licenses, - COUNT(CASE WHEN l.valid_until < CURRENT_DATE THEN 1 END) as expired_licenses - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - """ - - if not show_test: - query += " WHERE c.is_test = FALSE" - - query += """ - GROUP BY c.id, c.name, c.email, c.created_at - ORDER BY c.name - """ - - cur.execute(query) - customers = cur.fetchall() - - # Hole ausgewählten Kunden nur wenn explizit in URL angegeben - selected_customer_id = request.args.get('customer_id', type=int) - licenses = [] - selected_customer = None - - if customers and selected_customer_id: - # Hole Daten des ausgewählten Kunden - for customer in customers: - if customer[0] == selected_customer_id: - selected_customer = customer - break - - # Hole Lizenzen des ausgewählten Kunden - if selected_customer: - cur.execute(""" - SELECT - l.id, - l.license_key, - l.license_type, - l.valid_from, - l.valid_until, - l.is_active, - CASE - WHEN l.is_active = FALSE THEN 'deaktiviert' - WHEN l.valid_until < CURRENT_DATE THEN 'abgelaufen' - WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'läuft bald ab' - ELSE 'aktiv' - END as status, - l.domain_count, - l.ipv4_count, - l.phone_count, - l.device_limit, - (SELECT COUNT(*) FROM device_registrations WHERE license_id = l.id AND is_active = TRUE) as active_devices, - -- Actual resource counts - (SELECT COUNT(*) FROM license_resources lr - JOIN resource_pools rp ON lr.resource_id = rp.id - WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'domain') as actual_domain_count, - (SELECT COUNT(*) FROM license_resources lr - JOIN resource_pools rp ON lr.resource_id = rp.id - WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'ipv4') as actual_ipv4_count, - (SELECT COUNT(*) FROM license_resources lr - JOIN resource_pools rp ON lr.resource_id = rp.id - WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'phone') as actual_phone_count - FROM licenses l - WHERE l.customer_id = %s - ORDER BY l.created_at DESC, l.id DESC - """, (selected_customer_id,)) - licenses = cur.fetchall() - - cur.close() - conn.close() - - return render_template("customers_licenses.html", - customers=customers, - selected_customer=selected_customer, - selected_customer_id=selected_customer_id, - licenses=licenses, - show_test=show_test) - -@app.route("/api/customer//licenses") -@login_required -def api_customer_licenses(customer_id): - """API-Endpoint für AJAX-Abruf der Lizenzen eines Kunden""" - conn = get_connection() - cur = conn.cursor() - - # Hole Lizenzen des Kunden - cur.execute(""" - SELECT - l.id, - l.license_key, - l.license_type, - l.valid_from, - l.valid_until, - l.is_active, - CASE - WHEN l.is_active = FALSE THEN 'deaktiviert' - WHEN l.valid_until < CURRENT_DATE THEN 'abgelaufen' - WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'läuft bald ab' - ELSE 'aktiv' - END as status, - l.domain_count, - l.ipv4_count, - l.phone_count, - l.device_limit, - (SELECT COUNT(*) FROM device_registrations WHERE license_id = l.id AND is_active = TRUE) as active_devices, - -- Actual resource counts - (SELECT COUNT(*) FROM license_resources lr - JOIN resource_pools rp ON lr.resource_id = rp.id - WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'domain') as actual_domain_count, - (SELECT COUNT(*) FROM license_resources lr - JOIN resource_pools rp ON lr.resource_id = rp.id - WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'ipv4') as actual_ipv4_count, - (SELECT COUNT(*) FROM license_resources lr - JOIN resource_pools rp ON lr.resource_id = rp.id - WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'phone') as actual_phone_count - FROM licenses l - WHERE l.customer_id = %s - ORDER BY l.created_at DESC, l.id DESC - """, (customer_id,)) - - licenses = [] - for row in cur.fetchall(): - license_id = row[0] - - # Hole die konkreten zugewiesenen Ressourcen für diese Lizenz - cur.execute(""" - SELECT rp.id, rp.resource_type, rp.resource_value, lr.assigned_at - FROM resource_pools rp - JOIN license_resources lr ON rp.id = lr.resource_id - WHERE lr.license_id = %s AND lr.is_active = true - ORDER BY rp.resource_type, rp.resource_value - """, (license_id,)) - - resources = { - 'domains': [], - 'ipv4s': [], - 'phones': [] - } - - for res_row in cur.fetchall(): - resource_info = { - 'id': res_row[0], - 'value': res_row[2], - 'assigned_at': res_row[3].strftime('%d.%m.%Y') if res_row[3] else '' - } - - if res_row[1] == 'domain': - resources['domains'].append(resource_info) - elif res_row[1] == 'ipv4': - resources['ipv4s'].append(resource_info) - elif res_row[1] == 'phone': - resources['phones'].append(resource_info) - - licenses.append({ - 'id': row[0], - 'license_key': row[1], - 'license_type': row[2], - 'valid_from': row[3].strftime('%d.%m.%Y') if row[3] else '', - 'valid_until': row[4].strftime('%d.%m.%Y') if row[4] else '', - 'is_active': row[5], - 'status': row[6], - 'domain_count': row[7], # limit - 'ipv4_count': row[8], # limit - 'phone_count': row[9], # limit - 'device_limit': row[10], - 'active_devices': row[11], - 'actual_domain_count': row[12], # actual count - 'actual_ipv4_count': row[13], # actual count - 'actual_phone_count': row[14], # actual count - 'resources': resources - }) - - cur.close() - conn.close() - - return jsonify({ - 'success': True, - 'licenses': licenses, - 'count': len(licenses) - }) - -@app.route("/api/customer//quick-stats") -@login_required -def api_customer_quick_stats(customer_id): - """API-Endpoint für Schnellstatistiken eines Kunden""" - conn = get_connection() - cur = conn.cursor() - - # Hole Kundenstatistiken - cur.execute(""" - SELECT - COUNT(l.id) as total_licenses, - COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE THEN 1 END) as active_licenses, - COUNT(CASE WHEN l.valid_until < CURRENT_DATE THEN 1 END) as expired_licenses, - COUNT(CASE WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' AND l.valid_until >= CURRENT_DATE THEN 1 END) as expiring_soon - FROM licenses l - WHERE l.customer_id = %s - """, (customer_id,)) - - stats = cur.fetchone() - - cur.close() - conn.close() - - return jsonify({ - 'success': True, - 'stats': { - 'total': stats[0], - 'active': stats[1], - 'expired': stats[2], - 'expiring_soon': stats[3] - } - }) - -@app.route("/api/license//quick-edit", methods=['POST']) -@login_required -def api_license_quick_edit(license_id): - """API-Endpoint für schnelle Lizenz-Bearbeitung""" - conn = get_connection() - cur = conn.cursor() - - try: - data = request.get_json() - - # Hole alte Werte für Audit-Log - cur.execute(""" - SELECT is_active, valid_until, license_type - FROM licenses WHERE id = %s - """, (license_id,)) - old_values = cur.fetchone() - - if not old_values: - return jsonify({'success': False, 'error': 'Lizenz nicht gefunden'}), 404 - - # Update-Felder vorbereiten - updates = [] - params = [] - new_values = {} - - if 'is_active' in data: - updates.append("is_active = %s") - params.append(data['is_active']) - new_values['is_active'] = data['is_active'] - - if 'valid_until' in data: - updates.append("valid_until = %s") - params.append(data['valid_until']) - new_values['valid_until'] = data['valid_until'] - - if 'license_type' in data: - updates.append("license_type = %s") - params.append(data['license_type']) - new_values['license_type'] = data['license_type'] - - if updates: - params.append(license_id) - cur.execute(f""" - UPDATE licenses - SET {', '.join(updates)} - WHERE id = %s - """, params) - - conn.commit() - - # Audit-Log - log_audit('UPDATE', 'license', license_id, - old_values={ - 'is_active': old_values[0], - 'valid_until': old_values[1].isoformat() if old_values[1] else None, - 'license_type': old_values[2] - }, - new_values=new_values) - - cur.close() - conn.close() - - return jsonify({'success': True}) - - except Exception as e: - conn.rollback() - cur.close() - conn.close() - return jsonify({'success': False, 'error': str(e)}), 500 - -@app.route("/api/license//resources") -@login_required -def api_license_resources(license_id): - """API-Endpoint für detaillierte Ressourcen-Informationen einer Lizenz""" - conn = get_connection() - cur = conn.cursor() - - try: - # Hole die konkreten zugewiesenen Ressourcen für diese Lizenz - cur.execute(""" - SELECT rp.id, rp.resource_type, rp.resource_value, lr.assigned_at - FROM resource_pools rp - JOIN license_resources lr ON rp.id = lr.resource_id - WHERE lr.license_id = %s AND lr.is_active = true - ORDER BY rp.resource_type, rp.resource_value - """, (license_id,)) - - resources = { - 'domains': [], - 'ipv4s': [], - 'phones': [] - } - - for row in cur.fetchall(): - resource_info = { - 'id': row[0], - 'value': row[2], - 'assigned_at': row[3].strftime('%d.%m.%Y') if row[3] else '' - } - - if row[1] == 'domain': - resources['domains'].append(resource_info) - elif row[1] == 'ipv4': - resources['ipv4s'].append(resource_info) - elif row[1] == 'phone': - resources['phones'].append(resource_info) - - cur.close() - conn.close() - - return jsonify({ - 'success': True, - 'resources': resources - }) - - except Exception as e: - cur.close() - conn.close() - return jsonify({'success': False, 'error': str(e)}), 500 - -@app.route("/sessions") -@login_required -def sessions(): - conn = get_connection() - cur = conn.cursor() - - # Sortierparameter - active_sort = request.args.get('active_sort', 'last_heartbeat') - active_order = request.args.get('active_order', 'desc') - ended_sort = request.args.get('ended_sort', 'ended_at') - ended_order = request.args.get('ended_order', 'desc') - - # Whitelist für erlaubte Sortierfelder - Aktive Sessions - active_sort_fields = { - 'customer': 'c.name', - 'license': 'l.license_key', - 'ip': 's.ip_address', - 'started': 's.started_at', - 'last_heartbeat': 's.last_heartbeat', - 'inactive': 'minutes_inactive' - } - - # Whitelist für erlaubte Sortierfelder - Beendete Sessions - ended_sort_fields = { - 'customer': 'c.name', - 'license': 'l.license_key', - 'ip': 's.ip_address', - 'started': 's.started_at', - 'ended_at': 's.ended_at', - 'duration': 'duration_minutes' - } - - # Validierung - if active_sort not in active_sort_fields: - active_sort = 'last_heartbeat' - if ended_sort not in ended_sort_fields: - ended_sort = 'ended_at' - if active_order not in ['asc', 'desc']: - active_order = 'desc' - if ended_order not in ['asc', 'desc']: - ended_order = 'desc' - - # Aktive Sessions abrufen - cur.execute(f""" - SELECT s.id, s.session_id, l.license_key, c.name, s.ip_address, - s.user_agent, s.started_at, s.last_heartbeat, - EXTRACT(EPOCH FROM (NOW() - s.last_heartbeat))/60 as minutes_inactive - FROM sessions s - JOIN licenses l ON s.license_id = l.id - JOIN customers c ON l.customer_id = c.id - WHERE s.is_active = TRUE - ORDER BY {active_sort_fields[active_sort]} {active_order.upper()} - """) - active_sessions = cur.fetchall() - - # Inaktive Sessions der letzten 24 Stunden - cur.execute(f""" - SELECT s.id, s.session_id, l.license_key, c.name, s.ip_address, - s.started_at, s.ended_at, - EXTRACT(EPOCH FROM (s.ended_at - s.started_at))/60 as duration_minutes - FROM sessions s - JOIN licenses l ON s.license_id = l.id - JOIN customers c ON l.customer_id = c.id - WHERE s.is_active = FALSE - AND s.ended_at > NOW() - INTERVAL '24 hours' - ORDER BY {ended_sort_fields[ended_sort]} {ended_order.upper()} - LIMIT 50 - """) - recent_sessions = cur.fetchall() - - cur.close() - conn.close() - - return render_template("sessions.html", - active_sessions=active_sessions, - recent_sessions=recent_sessions, - active_sort=active_sort, - active_order=active_order, - ended_sort=ended_sort, - ended_order=ended_order, - username=session.get('username')) - -@app.route("/session/end/", methods=["POST"]) -@login_required -def end_session(session_id): - conn = get_connection() - cur = conn.cursor() - - # Session beenden - cur.execute(""" - UPDATE sessions - SET is_active = FALSE, ended_at = NOW() - WHERE id = %s AND is_active = TRUE - """, (session_id,)) - - conn.commit() - cur.close() - conn.close() - - return redirect("/sessions") - -@app.route("/export/licenses") -@login_required -def export_licenses(): - conn = get_connection() - cur = conn.cursor() - - # Alle Lizenzen mit Kundeninformationen abrufen (ohne Testdaten, außer explizit gewünscht) - include_test = request.args.get('include_test', 'false').lower() == 'true' - customer_id = request.args.get('customer_id', type=int) - - query = """ - SELECT l.id, l.license_key, c.name as customer_name, c.email as customer_email, - l.license_type, l.valid_from, l.valid_until, l.is_active, l.is_test, - CASE - WHEN l.is_active = FALSE THEN 'Deaktiviert' - WHEN l.valid_until < CURRENT_DATE THEN 'Abgelaufen' - WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'Läuft bald ab' - ELSE 'Aktiv' - END as status - FROM licenses l - JOIN customers c ON l.customer_id = c.id - """ - - # Build WHERE clause - where_conditions = [] - params = [] - - if not include_test: - where_conditions.append("l.is_test = FALSE") - - if customer_id: - where_conditions.append("l.customer_id = %s") - params.append(customer_id) - - if where_conditions: - query += " WHERE " + " AND ".join(where_conditions) - - query += " ORDER BY l.id" - - cur.execute(query, params) - - # Spaltennamen - columns = ['ID', 'Lizenzschlüssel', 'Kunde', 'E-Mail', 'Typ', - 'Gültig von', 'Gültig bis', 'Aktiv', 'Testdaten', 'Status'] - - # Daten in DataFrame - data = cur.fetchall() - df = pd.DataFrame(data, columns=columns) - - # Datumsformatierung - df['Gültig von'] = pd.to_datetime(df['Gültig von']).dt.strftime('%d.%m.%Y') - df['Gültig bis'] = pd.to_datetime(df['Gültig bis']).dt.strftime('%d.%m.%Y') - - # Typ und Aktiv Status anpassen - df['Typ'] = df['Typ'].replace({'full': 'Vollversion', 'test': 'Testversion'}) - df['Aktiv'] = df['Aktiv'].replace({True: 'Ja', False: 'Nein'}) - df['Testdaten'] = df['Testdaten'].replace({True: 'Ja', False: 'Nein'}) - - cur.close() - conn.close() - - # Export Format - export_format = request.args.get('format', 'excel') - - # Audit-Log - log_audit('EXPORT', 'license', - additional_info=f"Export aller Lizenzen als {export_format.upper()}") - filename = f'lizenzen_export_{datetime.now(ZoneInfo("Europe/Berlin")).strftime("%Y%m%d_%H%M%S")}' - - if export_format == 'csv': - # CSV Export - output = io.StringIO() - df.to_csv(output, index=False, encoding='utf-8-sig', sep=';') - output.seek(0) - - return send_file( - io.BytesIO(output.getvalue().encode('utf-8-sig')), - mimetype='text/csv', - as_attachment=True, - download_name=f'{filename}.csv' - ) - else: - # Excel Export - output = io.BytesIO() - with pd.ExcelWriter(output, engine='openpyxl') as writer: - df.to_excel(writer, sheet_name='Lizenzen', index=False) - - # Formatierung - worksheet = writer.sheets['Lizenzen'] - for column in worksheet.columns: - max_length = 0 - column_letter = column[0].column_letter - for cell in column: - try: - if len(str(cell.value)) > max_length: - max_length = len(str(cell.value)) - except: - pass - adjusted_width = min(max_length + 2, 50) - worksheet.column_dimensions[column_letter].width = adjusted_width - - output.seek(0) - - return send_file( - output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=f'{filename}.xlsx' - ) - -@app.route("/export/audit") -@login_required -def export_audit(): - conn = get_connection() - cur = conn.cursor() - - # Holen der Filter-Parameter - filter_user = request.args.get('user', '') - filter_action = request.args.get('action', '') - filter_entity = request.args.get('entity', '') - export_format = request.args.get('format', 'excel') - - # SQL Query mit Filtern - query = """ - SELECT id, timestamp, username, action, entity_type, entity_id, - old_values, new_values, ip_address, user_agent, additional_info - FROM audit_log - WHERE 1=1 - """ - params = [] - - if filter_user: - query += " AND username ILIKE %s" - params.append(f'%{filter_user}%') - - if filter_action: - query += " AND action = %s" - params.append(filter_action) - - if filter_entity: - query += " AND entity_type = %s" - params.append(filter_entity) - - query += " ORDER BY timestamp DESC" - - cur.execute(query, params) - audit_logs = cur.fetchall() - cur.close() - conn.close() - - # Daten für Export vorbereiten - data = [] - for log in audit_logs: - action_text = { - 'CREATE': 'Erstellt', - 'UPDATE': 'Bearbeitet', - 'DELETE': 'Gelöscht', - 'LOGIN': 'Anmeldung', - 'LOGOUT': 'Abmeldung', - 'AUTO_LOGOUT': 'Auto-Logout', - 'EXPORT': 'Export', - 'GENERATE_KEY': 'Key generiert', - 'CREATE_BATCH': 'Batch erstellt', - 'BACKUP': 'Backup erstellt', - 'LOGIN_2FA_SUCCESS': '2FA-Anmeldung', - 'LOGIN_2FA_BACKUP': '2FA-Backup-Code', - 'LOGIN_2FA_FAILED': '2FA-Fehlgeschlagen', - 'LOGIN_BLOCKED': 'Login-Blockiert', - 'RESTORE': 'Wiederhergestellt', - 'PASSWORD_CHANGE': 'Passwort geändert', - '2FA_ENABLED': '2FA aktiviert', - '2FA_DISABLED': '2FA deaktiviert' - }.get(log[3], log[3]) - - data.append({ - 'ID': log[0], - 'Zeitstempel': log[1].strftime('%d.%m.%Y %H:%M:%S'), - 'Benutzer': log[2], - 'Aktion': action_text, - 'Entität': log[4], - 'Entität-ID': log[5] or '', - 'IP-Adresse': log[8] or '', - 'Zusatzinfo': log[10] or '' - }) - - # DataFrame erstellen - df = pd.DataFrame(data) - - # Timestamp für Dateiname - timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') - filename = f'audit_log_export_{timestamp}' - - # Audit Log für Export - log_audit('EXPORT', 'audit_log', - additional_info=f"{export_format.upper()} Export mit {len(data)} Einträgen") - - if export_format == 'csv': - # CSV Export - output = io.StringIO() - # UTF-8 BOM für Excel - output.write('\ufeff') - df.to_csv(output, index=False, sep=';', encoding='utf-8') - output.seek(0) - - return send_file( - io.BytesIO(output.getvalue().encode('utf-8')), - mimetype='text/csv;charset=utf-8', - as_attachment=True, - download_name=f'{filename}.csv' - ) - else: - # Excel Export - output = BytesIO() - with pd.ExcelWriter(output, engine='openpyxl') as writer: - df.to_excel(writer, index=False, sheet_name='Audit Log') - - # Spaltenbreiten anpassen - worksheet = writer.sheets['Audit Log'] - for idx, col in enumerate(df.columns): - max_length = max( - df[col].astype(str).map(len).max(), - len(col) - ) + 2 - worksheet.column_dimensions[get_column_letter(idx + 1)].width = min(max_length, 50) - - output.seek(0) - - return send_file( - output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=f'{filename}.xlsx' - ) - -@app.route("/export/customers") -@login_required -def export_customers(): - conn = get_connection() - cur = conn.cursor() - - # Check if test data should be included - include_test = request.args.get('include_test', 'false').lower() == 'true' - - # Build query based on test data filter - if include_test: - # Include all customers - query = """ - SELECT c.id, c.name, c.email, c.created_at, c.is_test, - COUNT(l.id) as total_licenses, - COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE THEN 1 END) as active_licenses, - COUNT(CASE WHEN l.valid_until < CURRENT_DATE THEN 1 END) as expired_licenses - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - GROUP BY c.id, c.name, c.email, c.created_at, c.is_test - ORDER BY c.id - """ - else: - # Exclude test customers and test licenses - query = """ - SELECT c.id, c.name, c.email, c.created_at, c.is_test, - COUNT(CASE WHEN l.is_test = FALSE THEN 1 END) as total_licenses, - COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE AND l.is_test = FALSE THEN 1 END) as active_licenses, - COUNT(CASE WHEN l.valid_until < CURRENT_DATE AND l.is_test = FALSE THEN 1 END) as expired_licenses - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - WHERE c.is_test = FALSE - GROUP BY c.id, c.name, c.email, c.created_at, c.is_test - ORDER BY c.id - """ - - cur.execute(query) - - # Spaltennamen - columns = ['ID', 'Name', 'E-Mail', 'Erstellt am', 'Testdaten', - 'Lizenzen gesamt', 'Aktive Lizenzen', 'Abgelaufene Lizenzen'] - - # Daten in DataFrame - data = cur.fetchall() - df = pd.DataFrame(data, columns=columns) - - # Datumsformatierung - df['Erstellt am'] = pd.to_datetime(df['Erstellt am']).dt.strftime('%d.%m.%Y %H:%M') - - # Testdaten formatting - df['Testdaten'] = df['Testdaten'].replace({True: 'Ja', False: 'Nein'}) - - cur.close() - conn.close() - - # Export Format - export_format = request.args.get('format', 'excel') - - # Audit-Log - log_audit('EXPORT', 'customer', - additional_info=f"Export aller Kunden als {export_format.upper()}") - filename = f'kunden_export_{datetime.now(ZoneInfo("Europe/Berlin")).strftime("%Y%m%d_%H%M%S")}' - - if export_format == 'csv': - # CSV Export - output = io.StringIO() - df.to_csv(output, index=False, encoding='utf-8-sig', sep=';') - output.seek(0) - - return send_file( - io.BytesIO(output.getvalue().encode('utf-8-sig')), - mimetype='text/csv', - as_attachment=True, - download_name=f'{filename}.csv' - ) - else: - # Excel Export - output = io.BytesIO() - with pd.ExcelWriter(output, engine='openpyxl') as writer: - df.to_excel(writer, sheet_name='Kunden', index=False) - - # Formatierung - worksheet = writer.sheets['Kunden'] - for column in worksheet.columns: - max_length = 0 - column_letter = column[0].column_letter - for cell in column: - try: - if len(str(cell.value)) > max_length: - max_length = len(str(cell.value)) - except: - pass - adjusted_width = min(max_length + 2, 50) - worksheet.column_dimensions[column_letter].width = adjusted_width - - output.seek(0) - - return send_file( - output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=f'{filename}.xlsx' - ) - -@app.route("/export/sessions") -@login_required -def export_sessions(): - conn = get_connection() - cur = conn.cursor() - - # Holen des Session-Typs (active oder ended) - session_type = request.args.get('type', 'active') - export_format = request.args.get('format', 'excel') - - # Daten je nach Typ abrufen - if session_type == 'active': - # Aktive Lizenz-Sessions - cur.execute(""" - SELECT s.id, l.license_key, c.name as customer_name, s.session_id, - s.started_at, s.last_heartbeat, - EXTRACT(EPOCH FROM (NOW() - s.started_at))::INT as duration_seconds, - s.ip_address, s.user_agent - FROM sessions s - JOIN licenses l ON s.license_id = l.id - JOIN customers c ON l.customer_id = c.id - WHERE s.is_active = true - ORDER BY s.last_heartbeat DESC - """) - sessions = cur.fetchall() - - # Daten für Export vorbereiten - data = [] - for sess in sessions: - duration = sess[6] - hours = duration // 3600 - minutes = (duration % 3600) // 60 - seconds = duration % 60 - - data.append({ - 'Session-ID': sess[0], - 'Lizenzschlüssel': sess[1], - 'Kunde': sess[2], - 'Session-ID (Tech)': sess[3], - 'Startzeit': sess[4].strftime('%d.%m.%Y %H:%M:%S'), - 'Letzte Aktivität': sess[5].strftime('%d.%m.%Y %H:%M:%S'), - 'Dauer': f"{hours}h {minutes}m {seconds}s", - 'IP-Adresse': sess[7], - 'Browser': sess[8] - }) - - sheet_name = 'Aktive Sessions' - filename_prefix = 'aktive_sessions' - else: - # Beendete Lizenz-Sessions - cur.execute(""" - SELECT s.id, l.license_key, c.name as customer_name, s.session_id, - s.started_at, s.ended_at, - EXTRACT(EPOCH FROM (s.ended_at - s.started_at))::INT as duration_seconds, - s.ip_address, s.user_agent - FROM sessions s - JOIN licenses l ON s.license_id = l.id - JOIN customers c ON l.customer_id = c.id - WHERE s.is_active = false AND s.ended_at IS NOT NULL - ORDER BY s.ended_at DESC - LIMIT 1000 - """) - sessions = cur.fetchall() - - # Daten für Export vorbereiten - data = [] - for sess in sessions: - duration = sess[6] if sess[6] else 0 - hours = duration // 3600 - minutes = (duration % 3600) // 60 - seconds = duration % 60 - - data.append({ - 'Session-ID': sess[0], - 'Lizenzschlüssel': sess[1], - 'Kunde': sess[2], - 'Session-ID (Tech)': sess[3], - 'Startzeit': sess[4].strftime('%d.%m.%Y %H:%M:%S'), - 'Endzeit': sess[5].strftime('%d.%m.%Y %H:%M:%S') if sess[5] else '', - 'Dauer': f"{hours}h {minutes}m {seconds}s", - 'IP-Adresse': sess[7], - 'Browser': sess[8] - }) - - sheet_name = 'Beendete Sessions' - filename_prefix = 'beendete_sessions' - - cur.close() - conn.close() - - # DataFrame erstellen - df = pd.DataFrame(data) - - # Timestamp für Dateiname - timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') - filename = f'{filename_prefix}_export_{timestamp}' - - # Audit Log für Export - log_audit('EXPORT', 'sessions', - additional_info=f"{export_format.upper()} Export von {session_type} Sessions mit {len(data)} Einträgen") - - if export_format == 'csv': - # CSV Export - output = io.StringIO() - # UTF-8 BOM für Excel - output.write('\ufeff') - df.to_csv(output, index=False, sep=';', encoding='utf-8') - output.seek(0) - - return send_file( - io.BytesIO(output.getvalue().encode('utf-8')), - mimetype='text/csv;charset=utf-8', - as_attachment=True, - download_name=f'{filename}.csv' - ) - else: - # Excel Export - output = BytesIO() - with pd.ExcelWriter(output, engine='openpyxl') as writer: - df.to_excel(writer, index=False, sheet_name=sheet_name) - - # Spaltenbreiten anpassen - worksheet = writer.sheets[sheet_name] - for idx, col in enumerate(df.columns): - max_length = max( - df[col].astype(str).map(len).max(), - len(col) - ) + 2 - worksheet.column_dimensions[get_column_letter(idx + 1)].width = min(max_length, 50) - - output.seek(0) - - return send_file( - output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=f'{filename}.xlsx' - ) - -@app.route("/export/resources") -@login_required -def export_resources(): - conn = get_connection() - cur = conn.cursor() - - # Holen der Filter-Parameter - filter_type = request.args.get('type', '') - filter_status = request.args.get('status', '') - search_query = request.args.get('search', '') - show_test = request.args.get('show_test', 'false').lower() == 'true' - export_format = request.args.get('format', 'excel') - - # SQL Query mit Filtern - query = """ - SELECT r.id, r.resource_type, r.resource_value, r.status, r.allocated_to_license, - r.created_at, r.status_changed_at, - l.license_key, c.name as customer_name, c.email as customer_email, - l.license_type - FROM resource_pools r - LEFT JOIN licenses l ON r.allocated_to_license = l.id - LEFT JOIN customers c ON l.customer_id = c.id - WHERE 1=1 - """ - params = [] - - # Filter für Testdaten - if not show_test: - query += " AND (r.is_test = false OR r.is_test IS NULL)" - - # Filter für Ressourcentyp - if filter_type: - query += " AND r.resource_type = %s" - params.append(filter_type) - - # Filter für Status - if filter_status: - query += " AND r.status = %s" - params.append(filter_status) - - # Suchfilter - if search_query: - query += " AND (r.resource_value ILIKE %s OR l.license_key ILIKE %s OR c.name ILIKE %s)" - params.extend([f'%{search_query}%', f'%{search_query}%', f'%{search_query}%']) - - query += " ORDER BY r.id DESC" - - cur.execute(query, params) - resources = cur.fetchall() - cur.close() - conn.close() - - # Daten für Export vorbereiten - data = [] - for res in resources: - status_text = { - 'available': 'Verfügbar', - 'allocated': 'Zugewiesen', - 'quarantine': 'Quarantäne' - }.get(res[3], res[3]) - - type_text = { - 'domain': 'Domain', - 'ipv4': 'IPv4', - 'phone': 'Telefon' - }.get(res[1], res[1]) - - data.append({ - 'ID': res[0], - 'Typ': type_text, - 'Ressource': res[2], - 'Status': status_text, - 'Lizenzschlüssel': res[7] or '', - 'Kunde': res[8] or '', - 'Kunden-Email': res[9] or '', - 'Lizenztyp': res[10] or '', - 'Erstellt am': res[5].strftime('%d.%m.%Y %H:%M:%S') if res[5] else '', - 'Zugewiesen am': res[6].strftime('%d.%m.%Y %H:%M:%S') if res[6] else '' - }) - - # DataFrame erstellen - df = pd.DataFrame(data) - - # Timestamp für Dateiname - timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') - filename = f'resources_export_{timestamp}' - - # Audit Log für Export - log_audit('EXPORT', 'resources', - additional_info=f"{export_format.upper()} Export mit {len(data)} Ressourcen") - - if export_format == 'csv': - # CSV Export - output = io.StringIO() - # UTF-8 BOM für Excel - output.write('\ufeff') - df.to_csv(output, index=False, sep=';', encoding='utf-8') - output.seek(0) - - return send_file( - io.BytesIO(output.getvalue().encode('utf-8')), - mimetype='text/csv;charset=utf-8', - as_attachment=True, - download_name=f'{filename}.csv' - ) - else: - # Excel Export - output = BytesIO() - with pd.ExcelWriter(output, engine='openpyxl') as writer: - df.to_excel(writer, index=False, sheet_name='Resources') - - # Spaltenbreiten anpassen - worksheet = writer.sheets['Resources'] - for idx, col in enumerate(df.columns): - max_length = max( - df[col].astype(str).map(len).max(), - len(col) - ) + 2 - worksheet.column_dimensions[get_column_letter(idx + 1)].width = min(max_length, 50) - - output.seek(0) - - return send_file( - output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=f'{filename}.xlsx' - ) - -@app.route("/audit") -@login_required -def audit_log(): - conn = get_connection() - cur = conn.cursor() - - # Parameter - filter_user = request.args.get('user', '').strip() - filter_action = request.args.get('action', '').strip() - filter_entity = request.args.get('entity', '').strip() - page = request.args.get('page', 1, type=int) - sort = request.args.get('sort', 'timestamp') - order = request.args.get('order', 'desc') - per_page = 50 - - # Whitelist für erlaubte Sortierfelder - allowed_sort_fields = { - 'timestamp': 'timestamp', - 'username': 'username', - 'action': 'action', - 'entity': 'entity_type', - 'ip': 'ip_address' - } - - # Validierung - if sort not in allowed_sort_fields: - sort = 'timestamp' - if order not in ['asc', 'desc']: - order = 'desc' - - sort_field = allowed_sort_fields[sort] - - # SQL Query mit optionalen Filtern - query = """ - SELECT id, timestamp, username, action, entity_type, entity_id, - old_values, new_values, ip_address, user_agent, additional_info - FROM audit_log - WHERE 1=1 - """ - - params = [] - - # Filter - if filter_user: - query += " AND LOWER(username) LIKE LOWER(%s)" - params.append(f'%{filter_user}%') - - if filter_action: - query += " AND action = %s" - params.append(filter_action) - - if filter_entity: - query += " AND entity_type = %s" - params.append(filter_entity) - - # Gesamtanzahl für Pagination - count_query = "SELECT COUNT(*) FROM (" + query + ") as count_table" - cur.execute(count_query, params) - total = cur.fetchone()[0] - - # Pagination - offset = (page - 1) * per_page - query += f" ORDER BY {sort_field} {order.upper()} LIMIT %s OFFSET %s" - params.extend([per_page, offset]) - - cur.execute(query, params) - logs = cur.fetchall() - - # JSON-Werte parsen - parsed_logs = [] - for log in logs: - parsed_log = list(log) - # old_values und new_values sind bereits Dictionaries (JSONB) - # Keine Konvertierung nötig - parsed_logs.append(parsed_log) - - # Pagination Info - total_pages = (total + per_page - 1) // per_page - - cur.close() - conn.close() - - return render_template("audit_log.html", - logs=parsed_logs, - filter_user=filter_user, - filter_action=filter_action, - filter_entity=filter_entity, - page=page, - total_pages=total_pages, - total=total, - sort=sort, - order=order, - username=session.get('username')) - -@app.route("/backups") -@login_required -def backups(): - """Zeigt die Backup-Historie an""" - conn = get_connection() - cur = conn.cursor() - - # Letztes erfolgreiches Backup für Dashboard - cur.execute(""" - SELECT created_at, filesize, duration_seconds - FROM backup_history - WHERE status = 'success' - ORDER BY created_at DESC - LIMIT 1 - """) - last_backup = cur.fetchone() - - # Alle Backups abrufen - cur.execute(""" - SELECT id, filename, filesize, backup_type, status, error_message, - created_at, created_by, tables_count, records_count, - duration_seconds, is_encrypted - FROM backup_history - ORDER BY created_at DESC - """) - backups = cur.fetchall() - - cur.close() - conn.close() - - return render_template("backups.html", - backups=backups, - last_backup=last_backup, - username=session.get('username')) - -@app.route("/backup/create", methods=["POST"]) -@login_required -def create_backup_route(): - """Erstellt ein manuelles Backup""" - username = session.get('username') - success, result = create_backup(backup_type="manual", created_by=username) - - if success: - return jsonify({ - 'success': True, - 'message': f'Backup erfolgreich erstellt: {result}' - }) - else: - return jsonify({ - 'success': False, - 'message': f'Backup fehlgeschlagen: {result}' - }), 500 - -@app.route("/backup/restore/", methods=["POST"]) -@login_required -def restore_backup_route(backup_id): - """Stellt ein Backup wieder her""" - encryption_key = request.form.get('encryption_key') - - success, message = restore_backup(backup_id, encryption_key) - - if success: - return jsonify({ - 'success': True, - 'message': message - }) - else: - return jsonify({ - 'success': False, - 'message': message - }), 500 - -@app.route("/backup/download/") -@login_required -def download_backup(backup_id): - """Lädt eine Backup-Datei herunter""" - conn = get_connection() - cur = conn.cursor() - - cur.execute(""" - SELECT filename, filepath - FROM backup_history - WHERE id = %s - """, (backup_id,)) - backup_info = cur.fetchone() - - cur.close() - conn.close() - - if not backup_info: - return "Backup nicht gefunden", 404 - - filename, filepath = backup_info - filepath = Path(filepath) - - if not filepath.exists(): - return "Backup-Datei nicht gefunden", 404 - - # Audit-Log - log_audit('DOWNLOAD', 'backup', backup_id, - additional_info=f"Backup heruntergeladen: {filename}") - - return send_file(filepath, as_attachment=True, download_name=filename) - -@app.route("/backup/delete/", methods=["DELETE"]) -@login_required -def delete_backup(backup_id): - """Löscht ein Backup""" - conn = get_connection() - cur = conn.cursor() - - try: - # Backup-Informationen abrufen - cur.execute(""" - SELECT filename, filepath - FROM backup_history - WHERE id = %s - """, (backup_id,)) - backup_info = cur.fetchone() - - if not backup_info: - return jsonify({ - 'success': False, - 'message': 'Backup nicht gefunden' - }), 404 - - filename, filepath = backup_info - filepath = Path(filepath) - - # Datei löschen, wenn sie existiert - if filepath.exists(): - filepath.unlink() - - # Aus Datenbank löschen - cur.execute(""" - DELETE FROM backup_history - WHERE id = %s - """, (backup_id,)) - - conn.commit() - - # Audit-Log - log_audit('DELETE', 'backup', backup_id, - additional_info=f"Backup gelöscht: {filename}") - - return jsonify({ - 'success': True, - 'message': f'Backup "{filename}" wurde erfolgreich gelöscht' - }) - - except Exception as e: - conn.rollback() - return jsonify({ - 'success': False, - 'message': f'Fehler beim Löschen des Backups: {str(e)}' - }), 500 - finally: - cur.close() - conn.close() - -@app.route("/security/blocked-ips") -@login_required -def blocked_ips(): - """Zeigt alle gesperrten IPs an""" - conn = get_connection() - cur = conn.cursor() - - cur.execute(""" - SELECT - ip_address, - attempt_count, - first_attempt, - last_attempt, - blocked_until, - last_username_tried, - last_error_message - FROM login_attempts - WHERE blocked_until IS NOT NULL - ORDER BY blocked_until DESC - """) - - blocked_ips_list = [] - for ip in cur.fetchall(): - blocked_ips_list.append({ - 'ip_address': ip[0], - 'attempt_count': ip[1], - 'first_attempt': ip[2].strftime('%d.%m.%Y %H:%M'), - 'last_attempt': ip[3].strftime('%d.%m.%Y %H:%M'), - 'blocked_until': ip[4].strftime('%d.%m.%Y %H:%M'), - 'is_active': ip[4] > datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None), - 'last_username': ip[5], - 'last_error': ip[6] - }) - - cur.close() - conn.close() - - return render_template("blocked_ips.html", - blocked_ips=blocked_ips_list, - username=session.get('username')) - -@app.route("/security/unblock-ip", methods=["POST"]) -@login_required -def unblock_ip(): - """Entsperrt eine IP-Adresse""" - ip_address = request.form.get('ip_address') - - if ip_address: - conn = get_connection() - cur = conn.cursor() - - cur.execute(""" - UPDATE login_attempts - SET blocked_until = NULL - WHERE ip_address = %s - """, (ip_address,)) - - conn.commit() - cur.close() - conn.close() - - # Audit-Log - log_audit('UNBLOCK_IP', 'security', - additional_info=f"IP {ip_address} manuell entsperrt") - - return redirect(url_for('blocked_ips')) - -@app.route("/security/clear-attempts", methods=["POST"]) -@login_required -def clear_attempts(): - """Löscht alle Login-Versuche für eine IP""" - ip_address = request.form.get('ip_address') - - if ip_address: - reset_login_attempts(ip_address) - - # Audit-Log - log_audit('CLEAR_ATTEMPTS', 'security', - additional_info=f"Login-Versuche für IP {ip_address} zurückgesetzt") - - return redirect(url_for('blocked_ips')) - -# API Endpoints for License Management -@app.route("/api/license//toggle", methods=["POST"]) -@login_required -def toggle_license_api(license_id): - """Toggle license active status via API""" - try: - data = request.get_json() - is_active = data.get('is_active', False) - - conn = get_connection() - cur = conn.cursor() - - # Update license status - cur.execute(""" - UPDATE licenses - SET is_active = %s - WHERE id = %s - """, (is_active, license_id)) - - conn.commit() - - # Log the action - log_audit('UPDATE', 'license', license_id, - new_values={'is_active': is_active}, - additional_info=f"Lizenz {'aktiviert' if is_active else 'deaktiviert'} via Toggle") - - cur.close() - conn.close() - - return jsonify({'success': True, 'message': 'Status erfolgreich geändert'}) - except Exception as e: - return jsonify({'success': False, 'message': str(e)}), 500 - -@app.route("/api/licenses/bulk-activate", methods=["POST"]) -@login_required -def bulk_activate_licenses(): - """Activate multiple licenses at once""" - try: - data = request.get_json() - license_ids = data.get('ids', []) - - if not license_ids: - return jsonify({'success': False, 'message': 'Keine Lizenzen ausgewählt'}), 400 - - conn = get_connection() - cur = conn.cursor() - - # Update all selected licenses (nur Live-Daten) - cur.execute(""" - UPDATE licenses - SET is_active = TRUE - WHERE id = ANY(%s) AND is_test = FALSE - """, (license_ids,)) - - affected_rows = cur.rowcount - conn.commit() - - # Log the bulk action - log_audit('BULK_UPDATE', 'licenses', None, - new_values={'is_active': True, 'count': affected_rows}, - additional_info=f"{affected_rows} Lizenzen aktiviert") - - cur.close() - conn.close() - - return jsonify({'success': True, 'message': f'{affected_rows} Lizenzen aktiviert'}) - except Exception as e: - return jsonify({'success': False, 'message': str(e)}), 500 - -@app.route("/api/licenses/bulk-deactivate", methods=["POST"]) -@login_required -def bulk_deactivate_licenses(): - """Deactivate multiple licenses at once""" - try: - data = request.get_json() - license_ids = data.get('ids', []) - - if not license_ids: - return jsonify({'success': False, 'message': 'Keine Lizenzen ausgewählt'}), 400 - - conn = get_connection() - cur = conn.cursor() - - # Update all selected licenses (nur Live-Daten) - cur.execute(""" - UPDATE licenses - SET is_active = FALSE - WHERE id = ANY(%s) AND is_test = FALSE - """, (license_ids,)) - - affected_rows = cur.rowcount - conn.commit() - - # Log the bulk action - log_audit('BULK_UPDATE', 'licenses', None, - new_values={'is_active': False, 'count': affected_rows}, - additional_info=f"{affected_rows} Lizenzen deaktiviert") - - cur.close() - conn.close() - - return jsonify({'success': True, 'message': f'{affected_rows} Lizenzen deaktiviert'}) - except Exception as e: - return jsonify({'success': False, 'message': str(e)}), 500 - -@app.route("/api/license//devices") -@login_required -def get_license_devices(license_id): - """Hole alle registrierten Geräte einer Lizenz""" - try: - conn = get_connection() - cur = conn.cursor() - - # Prüfe ob Lizenz existiert und hole device_limit - cur.execute(""" - SELECT device_limit FROM licenses WHERE id = %s - """, (license_id,)) - license_data = cur.fetchone() - - if not license_data: - return jsonify({'success': False, 'message': 'Lizenz nicht gefunden'}), 404 - - device_limit = license_data[0] - - # Hole alle Geräte für diese Lizenz - cur.execute(""" - SELECT id, hardware_id, device_name, operating_system, - first_seen, last_seen, is_active, ip_address - FROM device_registrations - WHERE license_id = %s - ORDER BY is_active DESC, last_seen DESC - """, (license_id,)) - - devices = [] - for row in cur.fetchall(): - devices.append({ - 'id': row[0], - 'hardware_id': row[1], - 'device_name': row[2] or 'Unbekanntes Gerät', - 'operating_system': row[3] or 'Unbekannt', - 'first_seen': row[4].strftime('%d.%m.%Y %H:%M') if row[4] else '', - 'last_seen': row[5].strftime('%d.%m.%Y %H:%M') if row[5] else '', - 'is_active': row[6], - 'ip_address': row[7] or '-' - }) - - cur.close() - conn.close() - - return jsonify({ - 'success': True, - 'devices': devices, - 'device_limit': device_limit, - 'active_count': sum(1 for d in devices if d['is_active']) - }) - - except Exception as e: - logging.error(f"Fehler beim Abrufen der Geräte: {str(e)}") - return jsonify({'success': False, 'message': 'Fehler beim Abrufen der Geräte'}), 500 - -@app.route("/api/license//register-device", methods=["POST"]) -def register_device(license_id): - """Registriere ein neues Gerät für eine Lizenz""" - try: - data = request.get_json() - hardware_id = data.get('hardware_id') - device_name = data.get('device_name', '') - operating_system = data.get('operating_system', '') - - if not hardware_id: - return jsonify({'success': False, 'message': 'Hardware-ID fehlt'}), 400 - - conn = get_connection() - cur = conn.cursor() - - # Prüfe ob Lizenz existiert und aktiv ist - cur.execute(""" - SELECT device_limit, is_active, valid_until - FROM licenses - WHERE id = %s - """, (license_id,)) - license_data = cur.fetchone() - - if not license_data: - return jsonify({'success': False, 'message': 'Lizenz nicht gefunden'}), 404 - - device_limit, is_active, valid_until = license_data - - # Prüfe ob Lizenz aktiv und gültig ist - if not is_active: - return jsonify({'success': False, 'message': 'Lizenz ist deaktiviert'}), 403 - - if valid_until < datetime.now(ZoneInfo("Europe/Berlin")).date(): - return jsonify({'success': False, 'message': 'Lizenz ist abgelaufen'}), 403 - - # Prüfe ob Gerät bereits registriert ist - cur.execute(""" - SELECT id, is_active FROM device_registrations - WHERE license_id = %s AND hardware_id = %s - """, (license_id, hardware_id)) - existing_device = cur.fetchone() - - if existing_device: - device_id, is_device_active = existing_device - if is_device_active: - # Gerät ist bereits aktiv, update last_seen - cur.execute(""" - UPDATE device_registrations - SET last_seen = CURRENT_TIMESTAMP, - ip_address = %s, - user_agent = %s - WHERE id = %s - """, (get_client_ip(), request.headers.get('User-Agent', ''), device_id)) - conn.commit() - return jsonify({'success': True, 'message': 'Gerät bereits registriert', 'device_id': device_id}) - else: - # Gerät war deaktiviert, prüfe ob wir es reaktivieren können - cur.execute(""" - SELECT COUNT(*) FROM device_registrations - WHERE license_id = %s AND is_active = TRUE - """, (license_id,)) - active_count = cur.fetchone()[0] - - if active_count >= device_limit: - return jsonify({'success': False, 'message': f'Gerätelimit erreicht ({device_limit} Geräte)'}), 403 - - # Reaktiviere das Gerät - cur.execute(""" - UPDATE device_registrations - SET is_active = TRUE, - last_seen = CURRENT_TIMESTAMP, - deactivated_at = NULL, - deactivated_by = NULL, - ip_address = %s, - user_agent = %s - WHERE id = %s - """, (get_client_ip(), request.headers.get('User-Agent', ''), device_id)) - conn.commit() - return jsonify({'success': True, 'message': 'Gerät reaktiviert', 'device_id': device_id}) - - # Neues Gerät - prüfe Gerätelimit - cur.execute(""" - SELECT COUNT(*) FROM device_registrations - WHERE license_id = %s AND is_active = TRUE - """, (license_id,)) - active_count = cur.fetchone()[0] - - if active_count >= device_limit: - return jsonify({'success': False, 'message': f'Gerätelimit erreicht ({device_limit} Geräte)'}), 403 - - # Registriere neues Gerät - cur.execute(""" - INSERT INTO device_registrations - (license_id, hardware_id, device_name, operating_system, ip_address, user_agent) - VALUES (%s, %s, %s, %s, %s, %s) - RETURNING id - """, (license_id, hardware_id, device_name, operating_system, - get_client_ip(), request.headers.get('User-Agent', ''))) - device_id = cur.fetchone()[0] - - conn.commit() - - # Audit Log - log_audit('DEVICE_REGISTER', 'device', device_id, - new_values={'license_id': license_id, 'hardware_id': hardware_id}) - - cur.close() - conn.close() - - return jsonify({'success': True, 'message': 'Gerät erfolgreich registriert', 'device_id': device_id}) - - except Exception as e: - logging.error(f"Fehler bei Geräte-Registrierung: {str(e)}") - return jsonify({'success': False, 'message': 'Fehler bei der Registrierung'}), 500 - -@app.route("/api/license//deactivate-device/", methods=["POST"]) -@login_required -def deactivate_device(license_id, device_id): - """Deaktiviere ein registriertes Gerät""" - try: - conn = get_connection() - cur = conn.cursor() - - # Prüfe ob das Gerät zu dieser Lizenz gehört - cur.execute(""" - SELECT id FROM device_registrations - WHERE id = %s AND license_id = %s AND is_active = TRUE - """, (device_id, license_id)) - - if not cur.fetchone(): - return jsonify({'success': False, 'message': 'Gerät nicht gefunden oder bereits deaktiviert'}), 404 - - # Deaktiviere das Gerät - cur.execute(""" - UPDATE device_registrations - SET is_active = FALSE, - deactivated_at = CURRENT_TIMESTAMP, - deactivated_by = %s - WHERE id = %s - """, (session['username'], device_id)) - - conn.commit() - - # Audit Log - log_audit('DEVICE_DEACTIVATE', 'device', device_id, - old_values={'is_active': True}, - new_values={'is_active': False}) - - cur.close() - conn.close() - - return jsonify({'success': True, 'message': 'Gerät erfolgreich deaktiviert'}) - - except Exception as e: - logging.error(f"Fehler beim Deaktivieren des Geräts: {str(e)}") - return jsonify({'success': False, 'message': 'Fehler beim Deaktivieren'}), 500 - -@app.route("/api/licenses/bulk-delete", methods=["POST"]) -@login_required -def bulk_delete_licenses(): - """Delete multiple licenses at once""" - try: - data = request.get_json() - license_ids = data.get('ids', []) - - if not license_ids: - return jsonify({'success': False, 'message': 'Keine Lizenzen ausgewählt'}), 400 - - conn = get_connection() - cur = conn.cursor() - - # Get license info for audit log (nur Live-Daten) - cur.execute(""" - SELECT license_key - FROM licenses - WHERE id = ANY(%s) AND is_test = FALSE - """, (license_ids,)) - license_keys = [row[0] for row in cur.fetchall()] - - # Delete all selected licenses (nur Live-Daten) - cur.execute(""" - DELETE FROM licenses - WHERE id = ANY(%s) AND is_test = FALSE - """, (license_ids,)) - - affected_rows = cur.rowcount - conn.commit() - - # Log the bulk action - log_audit('BULK_DELETE', 'licenses', None, - old_values={'license_keys': license_keys, 'count': affected_rows}, - additional_info=f"{affected_rows} Lizenzen gelöscht") - - cur.close() - conn.close() - - return jsonify({'success': True, 'message': f'{affected_rows} Lizenzen gelöscht'}) - except Exception as e: - return jsonify({'success': False, 'message': str(e)}), 500 - -# ===================== RESOURCE POOL MANAGEMENT ===================== - -@app.route('/resources') -@login_required -def resources(): - """Resource Pool Hauptübersicht""" - conn = get_connection() - cur = conn.cursor() - - # Prüfe ob Testdaten angezeigt werden sollen (gleiche Logik wie bei Kunden) - show_test = request.args.get('show_test', 'false').lower() == 'true' - - # Statistiken abrufen - cur.execute(""" - SELECT - resource_type, - COUNT(*) FILTER (WHERE status = 'available') as available, - COUNT(*) FILTER (WHERE status = 'allocated') as allocated, - COUNT(*) FILTER (WHERE status = 'quarantine') as quarantine, - COUNT(*) as total - FROM resource_pools - WHERE is_test = %s - GROUP BY resource_type - """, (show_test,)) - - stats = {} - for row in cur.fetchall(): - stats[row[0]] = { - 'available': row[1], - 'allocated': row[2], - 'quarantine': row[3], - 'total': row[4], - 'available_percent': round((row[1] / row[4] * 100) if row[4] > 0 else 0, 1) - } - - # Letzte Aktivitäten (gefiltert nach Test/Live) - cur.execute(""" - SELECT - rh.action, - rh.action_by, - rh.action_at, - rp.resource_type, - rp.resource_value, - rh.details - FROM resource_history rh - JOIN resource_pools rp ON rh.resource_id = rp.id - WHERE rp.is_test = %s - ORDER BY rh.action_at DESC - LIMIT 10 - """, (show_test,)) - recent_activities = cur.fetchall() - - # Ressourcen-Liste mit Pagination - page = request.args.get('page', 1, type=int) - per_page = 50 - offset = (page - 1) * per_page - - resource_type = request.args.get('type', '') - status_filter = request.args.get('status', '') - search = request.args.get('search', '') - - # Sortierung - sort_by = request.args.get('sort', 'id') - sort_order = request.args.get('order', 'desc') - - # Base Query - query = """ - SELECT - rp.id, - rp.resource_type, - rp.resource_value, - rp.status, - rp.allocated_to_license, - l.license_key, - c.name as customer_name, - rp.status_changed_at, - rp.quarantine_reason, - rp.quarantine_until, - c.id as customer_id - FROM resource_pools rp - LEFT JOIN licenses l ON rp.allocated_to_license = l.id - LEFT JOIN customers c ON l.customer_id = c.id - WHERE rp.is_test = %s - """ - params = [show_test] - - if resource_type: - query += " AND rp.resource_type = %s" - params.append(resource_type) - - if status_filter: - query += " AND rp.status = %s" - params.append(status_filter) - - if search: - query += " AND rp.resource_value ILIKE %s" - params.append(f'%{search}%') - - # Count total - count_query = f"SELECT COUNT(*) FROM ({query}) as cnt" - cur.execute(count_query, params) - total = cur.fetchone()[0] - total_pages = (total + per_page - 1) // per_page - - # Get paginated results with dynamic sorting - sort_column_map = { - 'id': 'rp.id', - 'type': 'rp.resource_type', - 'resource': 'rp.resource_value', - 'status': 'rp.status', - 'assigned': 'c.name', - 'changed': 'rp.status_changed_at' - } - - sort_column = sort_column_map.get(sort_by, 'rp.id') - sort_direction = 'ASC' if sort_order == 'asc' else 'DESC' - - query += f" ORDER BY {sort_column} {sort_direction} LIMIT %s OFFSET %s" - params.extend([per_page, offset]) - - cur.execute(query, params) - resources = cur.fetchall() - - cur.close() - conn.close() - - return render_template('resources.html', - stats=stats, - resources=resources, - recent_activities=recent_activities, - page=page, - total_pages=total_pages, - total=total, - resource_type=resource_type, - status_filter=status_filter, - search=search, - show_test=show_test, - sort_by=sort_by, - sort_order=sort_order, - datetime=datetime, - timedelta=timedelta) - -@app.route('/resources/add', methods=['GET', 'POST']) -@login_required -def add_resources(): - """Ressourcen zum Pool hinzufügen""" - # Hole show_test Parameter für die Anzeige - show_test = request.args.get('show_test', 'false').lower() == 'true' - - if request.method == 'POST': - resource_type = request.form.get('resource_type') - resources_text = request.form.get('resources_text', '') - is_test = request.form.get('is_test') == 'on' # Checkbox für Testdaten - - # Parse resources (one per line) - resources = [r.strip() for r in resources_text.split('\n') if r.strip()] - - if not resources: - flash('Keine Ressourcen angegeben', 'error') - return redirect(url_for('add_resources', show_test=show_test)) - - conn = get_connection() - cur = conn.cursor() - - added = 0 - duplicates = 0 - - for resource_value in resources: - try: - cur.execute(""" - INSERT INTO resource_pools (resource_type, resource_value, status_changed_by, is_test) - VALUES (%s, %s, %s, %s) - ON CONFLICT (resource_type, resource_value) DO NOTHING - """, (resource_type, resource_value, session['username'], is_test)) - - if cur.rowcount > 0: - added += 1 - # Get the inserted ID - cur.execute("SELECT id FROM resource_pools WHERE resource_type = %s AND resource_value = %s", - (resource_type, resource_value)) - resource_id = cur.fetchone()[0] - - # Log in history - cur.execute(""" - INSERT INTO resource_history (resource_id, action, action_by, ip_address) - VALUES (%s, 'created', %s, %s) - """, (resource_id, session['username'], get_client_ip())) - else: - duplicates += 1 - - except Exception as e: - app.logger.error(f"Error adding resource {resource_value}: {e}") - - conn.commit() - cur.close() - conn.close() - - log_audit('CREATE', 'resource_pool', None, - new_values={'type': resource_type, 'added': added, 'duplicates': duplicates, 'is_test': is_test}, - additional_info=f"{added} {'Test-' if is_test else ''}Ressourcen hinzugefügt, {duplicates} Duplikate übersprungen") - - flash(f'{added} {"Test-" if is_test else ""}Ressourcen hinzugefügt, {duplicates} Duplikate übersprungen', 'success') - return redirect(url_for('resources', show_test=show_test)) - - return render_template('add_resources.html', show_test=show_test) - -@app.route('/resources/quarantine/', methods=['POST']) -@login_required -def quarantine_resource(resource_id): - """Ressource in Quarantäne setzen""" - reason = request.form.get('reason', 'review') - until_date = request.form.get('until_date') - notes = request.form.get('notes', '') - - conn = get_connection() - cur = conn.cursor() - - # Get current resource info - cur.execute("SELECT resource_type, resource_value, status FROM resource_pools WHERE id = %s", (resource_id,)) - resource = cur.fetchone() - - if not resource: - flash('Ressource nicht gefunden', 'error') - return redirect(url_for('resources')) - - old_status = resource[2] - - # Update resource - cur.execute(""" - UPDATE resource_pools - SET status = 'quarantine', - quarantine_reason = %s, - quarantine_until = %s, - notes = %s, - status_changed_at = CURRENT_TIMESTAMP, - status_changed_by = %s - WHERE id = %s - """, (reason, until_date if until_date else None, notes, session['username'], resource_id)) - - # Log in history - cur.execute(""" - INSERT INTO resource_history (resource_id, action, action_by, ip_address, details) - VALUES (%s, 'quarantined', %s, %s, %s) - """, (resource_id, session['username'], get_client_ip(), - Json({'reason': reason, 'until': until_date, 'notes': notes, 'old_status': old_status}))) - - conn.commit() - cur.close() - conn.close() - - log_audit('UPDATE', 'resource', resource_id, - old_values={'status': old_status}, - new_values={'status': 'quarantine', 'reason': reason}, - additional_info=f"Ressource {resource[0]}: {resource[1]} in Quarantäne") - - flash('Ressource in Quarantäne gesetzt', 'success') - - # Redirect mit allen aktuellen Filtern - return redirect(url_for('resources', - show_test=request.args.get('show_test', request.form.get('show_test', 'false')), - type=request.args.get('type', request.form.get('type', '')), - status=request.args.get('status', request.form.get('status', '')), - search=request.args.get('search', request.form.get('search', '')))) - -@app.route('/resources/release', methods=['POST']) -@login_required -def release_resources(): - """Ressourcen aus Quarantäne freigeben""" - resource_ids = request.form.getlist('resource_ids') - - if not resource_ids: - flash('Keine Ressourcen ausgewählt', 'error') - return redirect(url_for('resources')) - - conn = get_connection() - cur = conn.cursor() - - released = 0 - for resource_id in resource_ids: - cur.execute(""" - UPDATE resource_pools - SET status = 'available', - quarantine_reason = NULL, - quarantine_until = NULL, - allocated_to_license = NULL, - status_changed_at = CURRENT_TIMESTAMP, - status_changed_by = %s - WHERE id = %s AND status = 'quarantine' - """, (session['username'], resource_id)) - - if cur.rowcount > 0: - released += 1 - # Log in history - cur.execute(""" - INSERT INTO resource_history (resource_id, action, action_by, ip_address) - VALUES (%s, 'released', %s, %s) - """, (resource_id, session['username'], get_client_ip())) - - conn.commit() - cur.close() - conn.close() - - log_audit('UPDATE', 'resource_pool', None, - new_values={'released': released}, - additional_info=f"{released} Ressourcen aus Quarantäne freigegeben") - - flash(f'{released} Ressourcen freigegeben', 'success') - - # Redirect mit allen aktuellen Filtern - return redirect(url_for('resources', - show_test=request.args.get('show_test', request.form.get('show_test', 'false')), - type=request.args.get('type', request.form.get('type', '')), - status=request.args.get('status', request.form.get('status', '')), - search=request.args.get('search', request.form.get('search', '')))) - -@app.route('/api/resources/allocate', methods=['POST']) -@login_required -def allocate_resources_api(): - """API für Ressourcen-Zuweisung bei Lizenzerstellung""" - data = request.json - license_id = data.get('license_id') - domain_count = data.get('domain_count', 1) - ipv4_count = data.get('ipv4_count', 1) - phone_count = data.get('phone_count', 1) - - conn = get_connection() - cur = conn.cursor() - - try: - allocated = {'domains': [], 'ipv4s': [], 'phones': []} - - # Allocate domains - if domain_count > 0: - cur.execute(""" - SELECT id, resource_value FROM resource_pools - WHERE resource_type = 'domain' AND status = 'available' - LIMIT %s FOR UPDATE - """, (domain_count,)) - domains = cur.fetchall() - - if len(domains) < domain_count: - raise ValueError(f"Nicht genügend Domains verfügbar (benötigt: {domain_count}, verfügbar: {len(domains)})") - - for domain_id, domain_value in domains: - # Update resource status - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', - allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, - status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], domain_id)) - - # Create assignment - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, domain_id, session['username'])) - - # Log history - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (domain_id, license_id, session['username'], get_client_ip())) - - allocated['domains'].append(domain_value) - - # Allocate IPv4s (similar logic) - if ipv4_count > 0: - cur.execute(""" - SELECT id, resource_value FROM resource_pools - WHERE resource_type = 'ipv4' AND status = 'available' - LIMIT %s FOR UPDATE - """, (ipv4_count,)) - ipv4s = cur.fetchall() - - if len(ipv4s) < ipv4_count: - raise ValueError(f"Nicht genügend IPv4-Adressen verfügbar") - - for ipv4_id, ipv4_value in ipv4s: - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', - allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, - status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], ipv4_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, ipv4_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (ipv4_id, license_id, session['username'], get_client_ip())) - - allocated['ipv4s'].append(ipv4_value) - - # Allocate phones (similar logic) - if phone_count > 0: - cur.execute(""" - SELECT id, resource_value FROM resource_pools - WHERE resource_type = 'phone' AND status = 'available' - LIMIT %s FOR UPDATE - """, (phone_count,)) - phones = cur.fetchall() - - if len(phones) < phone_count: - raise ValueError(f"Nicht genügend Telefonnummern verfügbar") - - for phone_id, phone_value in phones: - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', - allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, - status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], phone_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, phone_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (phone_id, license_id, session['username'], get_client_ip())) - - allocated['phones'].append(phone_value) - - # Update license resource counts - cur.execute(""" - UPDATE licenses - SET domain_count = %s, - ipv4_count = %s, - phone_count = %s - WHERE id = %s - """, (domain_count, ipv4_count, phone_count, license_id)) - - conn.commit() - cur.close() - conn.close() - - return jsonify({ - 'success': True, - 'allocated': allocated - }) - - except Exception as e: - conn.rollback() - cur.close() - conn.close() - return jsonify({ - 'success': False, - 'error': str(e) - }), 400 - -@app.route('/api/resources/check-availability', methods=['GET']) -@login_required -def check_resource_availability(): - """Prüft verfügbare Ressourcen""" - resource_type = request.args.get('type', '') - count = request.args.get('count', 10, type=int) - show_test = request.args.get('show_test', 'false').lower() == 'true' - - conn = get_connection() - cur = conn.cursor() - - if resource_type: - # Spezifische Ressourcen für einen Typ - cur.execute(""" - SELECT id, resource_value - FROM resource_pools - WHERE status = 'available' - AND resource_type = %s - AND is_test = %s - ORDER BY resource_value - LIMIT %s - """, (resource_type, show_test, count)) - - resources = [] - for row in cur.fetchall(): - resources.append({ - 'id': row[0], - 'value': row[1] - }) - - cur.close() - conn.close() - - return jsonify({ - 'available': resources, - 'type': resource_type, - 'count': len(resources) - }) - else: - # Zusammenfassung aller Typen - cur.execute(""" - SELECT - resource_type, - COUNT(*) as available - FROM resource_pools - WHERE status = 'available' - AND is_test = %s - GROUP BY resource_type - """, (show_test,)) - - availability = {} - for row in cur.fetchall(): - availability[row[0]] = row[1] - - cur.close() - conn.close() - - return jsonify(availability) - -@app.route('/api/global-search', methods=['GET']) -@login_required -def global_search(): - """Global search API endpoint for searching customers and licenses""" - query = request.args.get('q', '').strip() - - if not query or len(query) < 2: - return jsonify({'customers': [], 'licenses': []}) - - conn = get_connection() - cur = conn.cursor() - - # Search pattern with wildcards - search_pattern = f'%{query}%' - - # Search customers - cur.execute(""" - SELECT id, name, email, company_name - FROM customers - WHERE (LOWER(name) LIKE LOWER(%s) - OR LOWER(email) LIKE LOWER(%s) - OR LOWER(company_name) LIKE LOWER(%s)) - AND is_test = FALSE - ORDER BY name - LIMIT 5 - """, (search_pattern, search_pattern, search_pattern)) - - customers = [] - for row in cur.fetchall(): - customers.append({ - 'id': row[0], - 'name': row[1], - 'email': row[2], - 'company_name': row[3] - }) - - # Search licenses - cur.execute(""" - SELECT l.id, l.license_key, c.name as customer_name - FROM licenses l - JOIN customers c ON l.customer_id = c.id - WHERE LOWER(l.license_key) LIKE LOWER(%s) - AND l.is_test = FALSE - ORDER BY l.created_at DESC - LIMIT 5 - """, (search_pattern,)) - - licenses = [] - for row in cur.fetchall(): - licenses.append({ - 'id': row[0], - 'license_key': row[1], - 'customer_name': row[2] - }) - - cur.close() - conn.close() - - return jsonify({ - 'customers': customers, - 'licenses': licenses - }) - -@app.route('/resources/history/') -@login_required -def resource_history(resource_id): - """Zeigt die komplette Historie einer Ressource""" - conn = get_connection() - cur = conn.cursor() - - # Get complete resource info using named columns - cur.execute(""" - SELECT id, resource_type, resource_value, status, allocated_to_license, - status_changed_at, status_changed_by, quarantine_reason, - quarantine_until, created_at, notes - FROM resource_pools - WHERE id = %s - """, (resource_id,)) - row = cur.fetchone() - - if not row: - flash('Ressource nicht gefunden', 'error') - return redirect(url_for('resources')) - - # Create resource object with named attributes - resource = { - 'id': row[0], - 'resource_type': row[1], - 'resource_value': row[2], - 'status': row[3], - 'allocated_to_license': row[4], - 'status_changed_at': row[5], - 'status_changed_by': row[6], - 'quarantine_reason': row[7], - 'quarantine_until': row[8], - 'created_at': row[9], - 'notes': row[10] - } - - # Get license info if allocated - license_info = None - if resource['allocated_to_license']: - cur.execute("SELECT license_key FROM licenses WHERE id = %s", - (resource['allocated_to_license'],)) - lic = cur.fetchone() - if lic: - license_info = {'license_key': lic[0]} - - # Get history with named columns - cur.execute(""" - SELECT - rh.action, - rh.action_by, - rh.action_at, - rh.details, - rh.license_id, - rh.ip_address - FROM resource_history rh - WHERE rh.resource_id = %s - ORDER BY rh.action_at DESC - """, (resource_id,)) - - history = [] - for row in cur.fetchall(): - history.append({ - 'action': row[0], - 'action_by': row[1], - 'action_at': row[2], - 'details': row[3], - 'license_id': row[4], - 'ip_address': row[5] - }) - - cur.close() - conn.close() - - # Convert to object-like for template - class ResourceObj: - def __init__(self, data): - for key, value in data.items(): - setattr(self, key, value) - - resource_obj = ResourceObj(resource) - history_objs = [ResourceObj(h) for h in history] - - return render_template('resource_history.html', - resource=resource_obj, - license_info=license_info, - history=history_objs) - -@app.route('/resources/metrics') -@login_required -def resources_metrics(): - """Dashboard für Resource Metrics und Reports""" - conn = get_connection() - cur = conn.cursor() - - # Overall stats with fallback values - cur.execute(""" - SELECT - COUNT(DISTINCT resource_id) as total_resources, - COALESCE(AVG(performance_score), 0) as avg_performance, - COALESCE(SUM(cost), 0) as total_cost, - COALESCE(SUM(revenue), 0) as total_revenue, - COALESCE(SUM(issues_count), 0) as total_issues - FROM resource_metrics - WHERE metric_date >= CURRENT_DATE - INTERVAL '30 days' - """) - row = cur.fetchone() - - # Calculate ROI - roi = 0 - if row[2] > 0: # if total_cost > 0 - roi = row[3] / row[2] # revenue / cost - - stats = { - 'total_resources': row[0] or 0, - 'avg_performance': row[1] or 0, - 'total_cost': row[2] or 0, - 'total_revenue': row[3] or 0, - 'total_issues': row[4] or 0, - 'roi': roi - } - - # Performance by type - cur.execute(""" - SELECT - rp.resource_type, - COALESCE(AVG(rm.performance_score), 0) as avg_score, - COUNT(DISTINCT rp.id) as resource_count - FROM resource_pools rp - LEFT JOIN resource_metrics rm ON rp.id = rm.resource_id - AND rm.metric_date >= CURRENT_DATE - INTERVAL '30 days' - GROUP BY rp.resource_type - ORDER BY rp.resource_type - """) - performance_by_type = cur.fetchall() - - # Utilization data - cur.execute(""" - SELECT - resource_type, - COUNT(*) FILTER (WHERE status = 'allocated') as allocated, - COUNT(*) as total, - ROUND(COUNT(*) FILTER (WHERE status = 'allocated') * 100.0 / COUNT(*), 1) as allocated_percent - FROM resource_pools - GROUP BY resource_type - """) - utilization_rows = cur.fetchall() - utilization_data = [ - { - 'type': row[0].upper(), - 'allocated': row[1], - 'total': row[2], - 'allocated_percent': row[3] - } - for row in utilization_rows - ] - - # Top performing resources - cur.execute(""" - SELECT - rp.id, - rp.resource_type, - rp.resource_value, - COALESCE(AVG(rm.performance_score), 0) as avg_score, - COALESCE(SUM(rm.revenue), 0) as total_revenue, - COALESCE(SUM(rm.cost), 1) as total_cost, - CASE - WHEN COALESCE(SUM(rm.cost), 0) = 0 THEN 0 - ELSE COALESCE(SUM(rm.revenue), 0) / COALESCE(SUM(rm.cost), 1) - END as roi - FROM resource_pools rp - LEFT JOIN resource_metrics rm ON rp.id = rm.resource_id - AND rm.metric_date >= CURRENT_DATE - INTERVAL '30 days' - WHERE rp.status != 'quarantine' - GROUP BY rp.id, rp.resource_type, rp.resource_value - HAVING AVG(rm.performance_score) IS NOT NULL - ORDER BY avg_score DESC - LIMIT 10 - """) - top_rows = cur.fetchall() - top_performers = [ - { - 'id': row[0], - 'resource_type': row[1], - 'resource_value': row[2], - 'avg_score': row[3], - 'roi': row[6] - } - for row in top_rows - ] - - # Resources with issues - cur.execute(""" - SELECT - rp.id, - rp.resource_type, - rp.resource_value, - rp.status, - COALESCE(SUM(rm.issues_count), 0) as total_issues - FROM resource_pools rp - LEFT JOIN resource_metrics rm ON rp.id = rm.resource_id - AND rm.metric_date >= CURRENT_DATE - INTERVAL '30 days' - WHERE rm.issues_count > 0 OR rp.status = 'quarantine' - GROUP BY rp.id, rp.resource_type, rp.resource_value, rp.status - HAVING SUM(rm.issues_count) > 0 - ORDER BY total_issues DESC - LIMIT 10 - """) - problem_rows = cur.fetchall() - problem_resources = [ - { - 'id': row[0], - 'resource_type': row[1], - 'resource_value': row[2], - 'status': row[3], - 'total_issues': row[4] - } - for row in problem_rows - ] - - # Daily metrics for trend chart (last 30 days) - cur.execute(""" - SELECT - metric_date, - COALESCE(AVG(performance_score), 0) as avg_performance, - COALESCE(SUM(issues_count), 0) as total_issues - FROM resource_metrics - WHERE metric_date >= CURRENT_DATE - INTERVAL '30 days' - GROUP BY metric_date - ORDER BY metric_date - """) - daily_rows = cur.fetchall() - daily_metrics = [ - { - 'date': row[0].strftime('%d.%m'), - 'performance': float(row[1]), - 'issues': int(row[2]) - } - for row in daily_rows - ] - - cur.close() - conn.close() - - return render_template('resource_metrics.html', - stats=stats, - performance_by_type=performance_by_type, - utilization_data=utilization_data, - top_performers=top_performers, - problem_resources=problem_resources, - daily_metrics=daily_metrics) - -@app.route('/resources/report', methods=['GET']) -@login_required -def resources_report(): - """Generiert Ressourcen-Reports oder zeigt Report-Formular""" - # Prüfe ob Download angefordert wurde - if request.args.get('download') == 'true': - report_type = request.args.get('type', 'usage') - format_type = request.args.get('format', 'excel') - date_from = request.args.get('from', (datetime.now(ZoneInfo("Europe/Berlin")) - timedelta(days=30)).strftime('%Y-%m-%d')) - date_to = request.args.get('to', datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y-%m-%d')) - - conn = get_connection() - cur = conn.cursor() - - if report_type == 'usage': - # Auslastungsreport - query = """ - SELECT - rp.resource_type, - rp.resource_value, - rp.status, - COUNT(DISTINCT rh.license_id) as unique_licenses, - COUNT(rh.id) as total_allocations, - MIN(rh.action_at) as first_used, - MAX(rh.action_at) as last_used - FROM resource_pools rp - LEFT JOIN resource_history rh ON rp.id = rh.resource_id - AND rh.action = 'allocated' - AND rh.action_at BETWEEN %s AND %s - GROUP BY rp.id, rp.resource_type, rp.resource_value, rp.status - ORDER BY rp.resource_type, total_allocations DESC - """ - cur.execute(query, (date_from, date_to)) - columns = ['Typ', 'Ressource', 'Status', 'Unique Lizenzen', 'Gesamt Zuweisungen', 'Erste Nutzung', 'Letzte Nutzung'] - - elif report_type == 'performance': - # Performance-Report - query = """ - SELECT - rp.resource_type, - rp.resource_value, - AVG(rm.performance_score) as avg_performance, - SUM(rm.usage_count) as total_usage, - SUM(rm.revenue) as total_revenue, - SUM(rm.cost) as total_cost, - SUM(rm.revenue - rm.cost) as profit, - SUM(rm.issues_count) as total_issues - FROM resource_pools rp - JOIN resource_metrics rm ON rp.id = rm.resource_id - WHERE rm.metric_date BETWEEN %s AND %s - GROUP BY rp.id, rp.resource_type, rp.resource_value - ORDER BY profit DESC - """ - cur.execute(query, (date_from, date_to)) - columns = ['Typ', 'Ressource', 'Durchschn. Performance', 'Gesamt Nutzung', 'Umsatz', 'Kosten', 'Gewinn', 'Issues'] - - elif report_type == 'compliance': - # Compliance-Report - query = """ - SELECT - rh.action_at, - rh.action, - rh.action_by, - rp.resource_type, - rp.resource_value, - l.license_key, - c.name as customer_name, - rh.ip_address - FROM resource_history rh - JOIN resource_pools rp ON rh.resource_id = rp.id - LEFT JOIN licenses l ON rh.license_id = l.id - LEFT JOIN customers c ON l.customer_id = c.id - WHERE rh.action_at BETWEEN %s AND %s - ORDER BY rh.action_at DESC - """ - cur.execute(query, (date_from, date_to)) - columns = ['Zeit', 'Aktion', 'Von', 'Typ', 'Ressource', 'Lizenz', 'Kunde', 'IP-Adresse'] - - else: # inventory report - # Inventar-Report - query = """ - SELECT - resource_type, - COUNT(*) FILTER (WHERE status = 'available') as available, - COUNT(*) FILTER (WHERE status = 'allocated') as allocated, - COUNT(*) FILTER (WHERE status = 'quarantine') as quarantine, - COUNT(*) as total - FROM resource_pools - GROUP BY resource_type - ORDER BY resource_type - """ - cur.execute(query) - columns = ['Typ', 'Verfügbar', 'Zugeteilt', 'Quarantäne', 'Gesamt'] - - # Convert to DataFrame - data = cur.fetchall() - df = pd.DataFrame(data, columns=columns) - - cur.close() - conn.close() - - # Generate file - timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') - filename = f"resource_report_{report_type}_{timestamp}" - - if format_type == 'excel': - output = io.BytesIO() - with pd.ExcelWriter(output, engine='openpyxl') as writer: - df.to_excel(writer, sheet_name='Report', index=False) - - # Auto-adjust columns width - worksheet = writer.sheets['Report'] - for column in worksheet.columns: - max_length = 0 - column = [cell for cell in column] - for cell in column: - try: - if len(str(cell.value)) > max_length: - max_length = len(str(cell.value)) - except: - pass - adjusted_width = (max_length + 2) - worksheet.column_dimensions[column[0].column_letter].width = adjusted_width - - output.seek(0) - - log_audit('EXPORT', 'resource_report', None, - new_values={'type': report_type, 'format': 'excel', 'rows': len(df)}, - additional_info=f"Resource Report {report_type} exportiert") - - return send_file(output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=f'{filename}.xlsx') - - else: # CSV - output = io.StringIO() - df.to_csv(output, index=False, sep=';', encoding='utf-8-sig') - output.seek(0) - - log_audit('EXPORT', 'resource_report', None, - new_values={'type': report_type, 'format': 'csv', 'rows': len(df)}, - additional_info=f"Resource Report {report_type} exportiert") - - return send_file(io.BytesIO(output.getvalue().encode('utf-8-sig')), - mimetype='text/csv', - as_attachment=True, - download_name=f'{filename}.csv') - - # Wenn kein Download, zeige Report-Formular - return render_template('resource_report.html', - datetime=datetime, - timedelta=timedelta, - username=session.get('username')) - -if __name__ == "__main__": - app.run(host="0.0.0.0", port=5000) diff --git a/v2_adminpanel/app.py.backup_before_blueprint_migration b/v2_adminpanel/app.py.backup_before_blueprint_migration deleted file mode 100644 index 0622714..0000000 --- a/v2_adminpanel/app.py.backup_before_blueprint_migration +++ /dev/null @@ -1,4461 +0,0 @@ -import os -import sys -import time -import json -import logging -import requests -import re -import random -import base64 -from io import BytesIO -from datetime import datetime, timedelta -from zoneinfo import ZoneInfo -from pathlib import Path - -# Add current directory to Python path to ensure modules can be imported -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) - -from flask import Flask, render_template, request, redirect, session, url_for, send_file, jsonify, flash -from flask_session import Session -from werkzeug.middleware.proxy_fix import ProxyFix -from apscheduler.schedulers.background import BackgroundScheduler -import pandas as pd -from psycopg2.extras import Json - -# Import our new modules -import config -from db import get_connection, get_db_connection, get_db_cursor, execute_query -from auth.decorators import login_required -from auth.password import hash_password, verify_password -from auth.two_factor import ( - generate_totp_secret, generate_qr_code, verify_totp, - generate_backup_codes, hash_backup_code, verify_backup_code -) -from auth.rate_limiting import ( - check_ip_blocked, record_failed_attempt, - reset_login_attempts, get_login_attempts -) -from utils.network import get_client_ip -from utils.audit import log_audit -from utils.license import generate_license_key, validate_license_key -from utils.backup import create_backup, restore_backup, get_or_create_encryption_key -from utils.export import ( - create_excel_export, format_datetime_for_export, - prepare_license_export_data, prepare_customer_export_data, - prepare_session_export_data, prepare_audit_export_data -) -from models import get_user_by_username - -app = Flask(__name__) -# Load configuration from config module -app.config['SECRET_KEY'] = config.SECRET_KEY -app.config['SESSION_TYPE'] = config.SESSION_TYPE -app.config['JSON_AS_ASCII'] = config.JSON_AS_ASCII -app.config['JSONIFY_MIMETYPE'] = config.JSONIFY_MIMETYPE -app.config['PERMANENT_SESSION_LIFETIME'] = config.PERMANENT_SESSION_LIFETIME -app.config['SESSION_COOKIE_HTTPONLY'] = config.SESSION_COOKIE_HTTPONLY -app.config['SESSION_COOKIE_SECURE'] = config.SESSION_COOKIE_SECURE -app.config['SESSION_COOKIE_SAMESITE'] = config.SESSION_COOKIE_SAMESITE -app.config['SESSION_COOKIE_NAME'] = config.SESSION_COOKIE_NAME -app.config['SESSION_REFRESH_EACH_REQUEST'] = config.SESSION_REFRESH_EACH_REQUEST -Session(app) - -# ProxyFix für korrekte IP-Adressen hinter Nginx -app.wsgi_app = ProxyFix( - app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1 -) - -# Configuration is now loaded from config module - -# Scheduler für automatische Backups -scheduler = BackgroundScheduler() -scheduler.start() - -# Logging konfigurieren -logging.basicConfig(level=logging.INFO) - -# Import and register blueprints -from routes.auth_routes import auth_bp -from routes.admin_routes import admin_bp - -# Temporarily comment out blueprints to avoid conflicts -# app.register_blueprint(auth_bp) -# app.register_blueprint(admin_bp) - - -# Scheduled Backup Job -def scheduled_backup(): - """Führt ein geplantes Backup aus""" - logging.info("Starte geplantes Backup...") - create_backup(backup_type="scheduled", created_by="scheduler") - -# Scheduler konfigurieren - täglich um 3:00 Uhr -scheduler.add_job( - scheduled_backup, - 'cron', - hour=config.SCHEDULER_CONFIG['backup_hour'], - minute=config.SCHEDULER_CONFIG['backup_minute'], - id='daily_backup', - replace_existing=True -) - - -def verify_recaptcha(response): - """Verifiziert die reCAPTCHA v2 Response mit Google""" - secret_key = config.RECAPTCHA_SECRET_KEY - - # Wenn kein Secret Key konfiguriert ist, CAPTCHA als bestanden werten (für PoC) - if not secret_key: - logging.warning("RECAPTCHA_SECRET_KEY nicht konfiguriert - CAPTCHA wird übersprungen") - return True - - # Verifizierung bei Google - try: - verify_url = 'https://www.google.com/recaptcha/api/siteverify' - data = { - 'secret': secret_key, - 'response': response - } - - # Timeout für Request setzen - r = requests.post(verify_url, data=data, timeout=5) - result = r.json() - - # Log für Debugging - if not result.get('success'): - logging.warning(f"reCAPTCHA Validierung fehlgeschlagen: {result.get('error-codes', [])}") - - return result.get('success', False) - - except requests.exceptions.RequestException as e: - logging.error(f"reCAPTCHA Verifizierung fehlgeschlagen: {str(e)}") - # Bei Netzwerkfehlern CAPTCHA als bestanden werten - return True - except Exception as e: - logging.error(f"Unerwarteter Fehler bei reCAPTCHA: {str(e)}") - return False - - -@app.route("/login", methods=["GET", "POST"]) -def login(): - # Timing-Attack Schutz - Start Zeit merken - start_time = time.time() - - # IP-Adresse ermitteln - ip_address = get_client_ip() - - # Prüfen ob IP gesperrt ist - is_blocked, blocked_until = check_ip_blocked(ip_address) - if is_blocked: - time_remaining = (blocked_until - datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None)).total_seconds() / 3600 - error_msg = f"IP GESPERRT! Noch {time_remaining:.1f} Stunden warten." - return render_template("login.html", error=error_msg, error_type="blocked") - - # Anzahl bisheriger Versuche - attempt_count = get_login_attempts(ip_address) - - if request.method == "POST": - username = request.form.get("username") - password = request.form.get("password") - captcha_response = request.form.get("g-recaptcha-response") - - # CAPTCHA-Prüfung nur wenn Keys konfiguriert sind - recaptcha_site_key = config.RECAPTCHA_SITE_KEY - if attempt_count >= config.CAPTCHA_AFTER_ATTEMPTS and recaptcha_site_key: - if not captcha_response: - # Timing-Attack Schutz - elapsed = time.time() - start_time - if elapsed < 1.0: - time.sleep(1.0 - elapsed) - return render_template("login.html", - error="CAPTCHA ERFORDERLICH!", - show_captcha=True, - error_type="captcha", - attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - attempt_count), - recaptcha_site_key=recaptcha_site_key) - - # CAPTCHA validieren - if not verify_recaptcha(captcha_response): - # Timing-Attack Schutz - elapsed = time.time() - start_time - if elapsed < 1.0: - time.sleep(1.0 - elapsed) - return render_template("login.html", - error="CAPTCHA UNGÜLTIG! Bitte erneut versuchen.", - show_captcha=True, - error_type="captcha", - attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - attempt_count), - recaptcha_site_key=recaptcha_site_key) - - # Check user in database first, fallback to env vars - user = get_user_by_username(username) - login_success = False - needs_2fa = False - - if user: - # Database user authentication - if verify_password(password, user['password_hash']): - login_success = True - needs_2fa = user['totp_enabled'] - else: - # Fallback to environment variables for backward compatibility - if username in config.ADMIN_USERS and password == config.ADMIN_USERS[username]: - login_success = True - - # Timing-Attack Schutz - Mindestens 1 Sekunde warten - elapsed = time.time() - start_time - if elapsed < 1.0: - time.sleep(1.0 - elapsed) - - if login_success: - # Erfolgreicher Login - if needs_2fa: - # Store temporary session for 2FA verification - session['temp_username'] = username - session['temp_user_id'] = user['id'] - session['awaiting_2fa'] = True - return redirect(url_for('verify_2fa')) - else: - # Complete login without 2FA - session.permanent = True # Aktiviert das Timeout - session['logged_in'] = True - session['username'] = username - session['user_id'] = user['id'] if user else None - session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() - reset_login_attempts(ip_address) - log_audit('LOGIN_SUCCESS', 'user', - additional_info=f"Erfolgreiche Anmeldung von IP: {ip_address}") - return redirect(url_for('dashboard')) - else: - # Fehlgeschlagener Login - error_message = record_failed_attempt(ip_address, username) - new_attempt_count = get_login_attempts(ip_address) - - # Prüfen ob jetzt gesperrt - is_now_blocked, _ = check_ip_blocked(ip_address) - if is_now_blocked: - log_audit('LOGIN_BLOCKED', 'security', - additional_info=f"IP {ip_address} wurde nach {config.MAX_LOGIN_ATTEMPTS} Versuchen gesperrt") - - return render_template("login.html", - error=error_message, - show_captcha=(new_attempt_count >= config.CAPTCHA_AFTER_ATTEMPTS and config.RECAPTCHA_SITE_KEY), - error_type="failed", - attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - new_attempt_count), - recaptcha_site_key=config.RECAPTCHA_SITE_KEY) - - # GET Request - return render_template("login.html", - show_captcha=(attempt_count >= config.CAPTCHA_AFTER_ATTEMPTS and config.RECAPTCHA_SITE_KEY), - attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - attempt_count), - recaptcha_site_key=config.RECAPTCHA_SITE_KEY) - -@app.route("/logout") -def logout(): - username = session.get('username', 'unknown') - log_audit('LOGOUT', 'user', additional_info=f"Abmeldung") - session.pop('logged_in', None) - session.pop('username', None) - session.pop('user_id', None) - session.pop('temp_username', None) - session.pop('temp_user_id', None) - session.pop('awaiting_2fa', None) - return redirect(url_for('login')) - -@app.route("/verify-2fa", methods=["GET", "POST"]) -def verify_2fa(): - if not session.get('awaiting_2fa'): - return redirect(url_for('login')) - - if request.method == "POST": - token = request.form.get('token', '').replace(' ', '') - username = session.get('temp_username') - user_id = session.get('temp_user_id') - - if not username or not user_id: - flash('Session expired. Please login again.', 'error') - return redirect(url_for('login')) - - user = get_user_by_username(username) - if not user: - flash('User not found.', 'error') - return redirect(url_for('login')) - - # Check if it's a backup code - if len(token) == 8 and token.isupper(): - # Try backup code - backup_codes = json.loads(user['backup_codes']) if user['backup_codes'] else [] - if verify_backup_code(token, backup_codes): - # Remove used backup code - code_hash = hash_backup_code(token) - backup_codes.remove(code_hash) - - conn = get_connection() - cur = conn.cursor() - cur.execute("UPDATE users SET backup_codes = %s WHERE id = %s", - (json.dumps(backup_codes), user_id)) - conn.commit() - cur.close() - conn.close() - - # Complete login - session.permanent = True - session['logged_in'] = True - session['username'] = username - session['user_id'] = user_id - session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() - session.pop('temp_username', None) - session.pop('temp_user_id', None) - session.pop('awaiting_2fa', None) - - flash('Login successful using backup code. Please generate new backup codes.', 'warning') - log_audit('LOGIN_2FA_BACKUP', 'user', additional_info=f"2FA login with backup code") - return redirect(url_for('dashboard')) - else: - # Try TOTP token - if verify_totp(user['totp_secret'], token): - # Complete login - session.permanent = True - session['logged_in'] = True - session['username'] = username - session['user_id'] = user_id - session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() - session.pop('temp_username', None) - session.pop('temp_user_id', None) - session.pop('awaiting_2fa', None) - - log_audit('LOGIN_2FA_SUCCESS', 'user', additional_info=f"2FA login successful") - return redirect(url_for('dashboard')) - - # Failed verification - conn = get_connection() - cur = conn.cursor() - cur.execute("UPDATE users SET failed_2fa_attempts = failed_2fa_attempts + 1, last_failed_2fa = %s WHERE id = %s", - (datetime.now(), user_id)) - conn.commit() - cur.close() - conn.close() - - flash('Invalid authentication code. Please try again.', 'error') - log_audit('LOGIN_2FA_FAILED', 'user', additional_info=f"Failed 2FA attempt") - - return render_template('verify_2fa.html') - -@app.route("/profile") -@login_required -def profile(): - user = get_user_by_username(session['username']) - if not user: - # For environment-based users, redirect with message - flash('Bitte führen Sie das Migrations-Script aus, um Passwort-Änderung und 2FA zu aktivieren.', 'info') - return redirect(url_for('dashboard')) - return render_template('profile.html', user=user) - -@app.route("/profile/change-password", methods=["POST"]) -@login_required -def change_password(): - current_password = request.form.get('current_password') - new_password = request.form.get('new_password') - confirm_password = request.form.get('confirm_password') - - user = get_user_by_username(session['username']) - - # Verify current password - if not verify_password(current_password, user['password_hash']): - flash('Current password is incorrect.', 'error') - return redirect(url_for('profile')) - - # Check new password - if new_password != confirm_password: - flash('New passwords do not match.', 'error') - return redirect(url_for('profile')) - - if len(new_password) < 8: - flash('Password must be at least 8 characters long.', 'error') - return redirect(url_for('profile')) - - # Update password - new_hash = hash_password(new_password) - conn = get_connection() - cur = conn.cursor() - cur.execute("UPDATE users SET password_hash = %s, last_password_change = %s WHERE id = %s", - (new_hash, datetime.now(), user['id'])) - conn.commit() - cur.close() - conn.close() - - log_audit('PASSWORD_CHANGE', 'user', entity_id=user['id'], - additional_info="Password changed successfully") - flash('Password changed successfully.', 'success') - return redirect(url_for('profile')) - -@app.route("/profile/setup-2fa") -@login_required -def setup_2fa(): - user = get_user_by_username(session['username']) - - if user['totp_enabled']: - flash('2FA is already enabled for your account.', 'info') - return redirect(url_for('profile')) - - # Generate new TOTP secret - totp_secret = generate_totp_secret() - session['temp_totp_secret'] = totp_secret - - # Generate QR code - qr_code = generate_qr_code(user['username'], totp_secret) - - return render_template('setup_2fa.html', - totp_secret=totp_secret, - qr_code=qr_code) - -@app.route("/profile/enable-2fa", methods=["POST"]) -@login_required -def enable_2fa(): - token = request.form.get('token', '').replace(' ', '') - totp_secret = session.get('temp_totp_secret') - - if not totp_secret: - flash('2FA setup session expired. Please try again.', 'error') - return redirect(url_for('setup_2fa')) - - # Verify the token - if not verify_totp(totp_secret, token): - flash('Invalid authentication code. Please try again.', 'error') - return redirect(url_for('setup_2fa')) - - # Generate backup codes - backup_codes = generate_backup_codes() - hashed_codes = [hash_backup_code(code) for code in backup_codes] - - # Enable 2FA - conn = get_connection() - cur = conn.cursor() - cur.execute(""" - UPDATE users - SET totp_secret = %s, totp_enabled = TRUE, backup_codes = %s - WHERE username = %s - """, (totp_secret, json.dumps(hashed_codes), session['username'])) - conn.commit() - cur.close() - conn.close() - - session.pop('temp_totp_secret', None) - - log_audit('2FA_ENABLED', 'user', additional_info="2FA enabled successfully") - - # Show backup codes - return render_template('backup_codes.html', backup_codes=backup_codes) - -@app.route("/profile/disable-2fa", methods=["POST"]) -@login_required -def disable_2fa(): - password = request.form.get('password') - user = get_user_by_username(session['username']) - - # Verify password - if not verify_password(password, user['password_hash']): - flash('Incorrect password.', 'error') - return redirect(url_for('profile')) - - # Disable 2FA - conn = get_connection() - cur = conn.cursor() - cur.execute(""" - UPDATE users - SET totp_enabled = FALSE, totp_secret = NULL, backup_codes = NULL - WHERE username = %s - """, (session['username'],)) - conn.commit() - cur.close() - conn.close() - - log_audit('2FA_DISABLED', 'user', additional_info="2FA disabled") - flash('2FA has been disabled for your account.', 'success') - return redirect(url_for('profile')) - -@app.route("/heartbeat", methods=['POST']) -@login_required -def heartbeat(): - """Endpoint für Session Keep-Alive - aktualisiert last_activity""" - # Aktualisiere last_activity nur wenn explizit angefordert - session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() - # Force session save - session.modified = True - - return jsonify({ - 'status': 'ok', - 'last_activity': session['last_activity'], - 'username': session.get('username') - }) - -@app.route("/api/generate-license-key", methods=['POST']) -@login_required -def api_generate_key(): - """API Endpoint zur Generierung eines neuen Lizenzschlüssels""" - try: - # Lizenztyp aus Request holen (default: full) - data = request.get_json() or {} - license_type = data.get('type', 'full') - - # Key generieren - key = generate_license_key(license_type) - - # Prüfen ob Key bereits existiert (sehr unwahrscheinlich aber sicher ist sicher) - conn = get_connection() - cur = conn.cursor() - - # Wiederhole bis eindeutiger Key gefunden - attempts = 0 - while attempts < 10: # Max 10 Versuche - cur.execute("SELECT 1 FROM licenses WHERE license_key = %s", (key,)) - if not cur.fetchone(): - break # Key ist eindeutig - key = generate_license_key(license_type) - attempts += 1 - - cur.close() - conn.close() - - # Log für Audit - log_audit('GENERATE_KEY', 'license', - additional_info={'type': license_type, 'key': key}) - - return jsonify({ - 'success': True, - 'key': key, - 'type': license_type - }) - - except Exception as e: - logging.error(f"Fehler bei Key-Generierung: {str(e)}") - return jsonify({ - 'success': False, - 'error': 'Fehler bei der Key-Generierung' - }), 500 - -@app.route("/api/customers", methods=['GET']) -@login_required -def api_customers(): - """API Endpoint für die Kundensuche mit Select2""" - try: - # Suchparameter - search = request.args.get('q', '').strip() - page = request.args.get('page', 1, type=int) - per_page = 20 - customer_id = request.args.get('id', type=int) - - conn = get_connection() - cur = conn.cursor() - - # Einzelnen Kunden per ID abrufen - if customer_id: - cur.execute(""" - SELECT c.id, c.name, c.email, - COUNT(l.id) as license_count - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - WHERE c.id = %s - GROUP BY c.id, c.name, c.email - """, (customer_id,)) - - customer = cur.fetchone() - results = [] - if customer: - results.append({ - 'id': customer[0], - 'text': f"{customer[1]} ({customer[2]})", - 'name': customer[1], - 'email': customer[2], - 'license_count': customer[3] - }) - - cur.close() - conn.close() - - return jsonify({ - 'results': results, - 'pagination': {'more': False} - }) - - # SQL Query mit optionaler Suche - elif search: - cur.execute(""" - SELECT c.id, c.name, c.email, - COUNT(l.id) as license_count - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - WHERE LOWER(c.name) LIKE LOWER(%s) - OR LOWER(c.email) LIKE LOWER(%s) - GROUP BY c.id, c.name, c.email - ORDER BY c.name - LIMIT %s OFFSET %s - """, (f'%{search}%', f'%{search}%', per_page, (page - 1) * per_page)) - else: - cur.execute(""" - SELECT c.id, c.name, c.email, - COUNT(l.id) as license_count - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - GROUP BY c.id, c.name, c.email - ORDER BY c.name - LIMIT %s OFFSET %s - """, (per_page, (page - 1) * per_page)) - - customers = cur.fetchall() - - # Format für Select2 - results = [] - for customer in customers: - results.append({ - 'id': customer[0], - 'text': f"{customer[1]} - {customer[2]} ({customer[3]} Lizenzen)", - 'name': customer[1], - 'email': customer[2], - 'license_count': customer[3] - }) - - # Gesamtanzahl für Pagination - if search: - cur.execute(""" - SELECT COUNT(*) FROM customers - WHERE LOWER(name) LIKE LOWER(%s) - OR LOWER(email) LIKE LOWER(%s) - """, (f'%{search}%', f'%{search}%')) - else: - cur.execute("SELECT COUNT(*) FROM customers") - - total_count = cur.fetchone()[0] - - cur.close() - conn.close() - - # Select2 Response Format - return jsonify({ - 'results': results, - 'pagination': { - 'more': (page * per_page) < total_count - } - }) - - except Exception as e: - logging.error(f"Fehler bei Kundensuche: {str(e)}") - return jsonify({ - 'results': [], - 'error': 'Fehler bei der Kundensuche' - }), 500 - -@app.route("/") -@login_required -def dashboard(): - conn = get_connection() - cur = conn.cursor() - - # Statistiken abrufen - # Gesamtanzahl Kunden (ohne Testdaten) - cur.execute("SELECT COUNT(*) FROM customers WHERE is_test = FALSE") - total_customers = cur.fetchone()[0] - - # Gesamtanzahl Lizenzen (ohne Testdaten) - cur.execute("SELECT COUNT(*) FROM licenses WHERE is_test = FALSE") - total_licenses = cur.fetchone()[0] - - # Aktive Lizenzen (nicht abgelaufen und is_active = true, ohne Testdaten) - cur.execute(""" - SELECT COUNT(*) FROM licenses - WHERE valid_until >= CURRENT_DATE AND is_active = TRUE AND is_test = FALSE - """) - active_licenses = cur.fetchone()[0] - - # Aktive Sessions - cur.execute("SELECT COUNT(*) FROM sessions WHERE is_active = TRUE") - active_sessions_count = cur.fetchone()[0] - - # Abgelaufene Lizenzen (ohne Testdaten) - cur.execute(""" - SELECT COUNT(*) FROM licenses - WHERE valid_until < CURRENT_DATE AND is_test = FALSE - """) - expired_licenses = cur.fetchone()[0] - - # Deaktivierte Lizenzen (ohne Testdaten) - cur.execute(""" - SELECT COUNT(*) FROM licenses - WHERE is_active = FALSE AND is_test = FALSE - """) - inactive_licenses = cur.fetchone()[0] - - # Lizenzen die in den nächsten 30 Tagen ablaufen (ohne Testdaten) - cur.execute(""" - SELECT COUNT(*) FROM licenses - WHERE valid_until >= CURRENT_DATE - AND valid_until < CURRENT_DATE + INTERVAL '30 days' - AND is_active = TRUE - AND is_test = FALSE - """) - expiring_soon = cur.fetchone()[0] - - # Testlizenzen vs Vollversionen (ohne Testdaten) - cur.execute(""" - SELECT license_type, COUNT(*) - FROM licenses - WHERE is_test = FALSE - GROUP BY license_type - """) - license_types = dict(cur.fetchall()) - - # Anzahl Testdaten - cur.execute("SELECT COUNT(*) FROM licenses WHERE is_test = TRUE") - test_data_count = cur.fetchone()[0] - - # Anzahl Test-Kunden - cur.execute("SELECT COUNT(*) FROM customers WHERE is_test = TRUE") - test_customers_count = cur.fetchone()[0] - - # Anzahl Test-Ressourcen - cur.execute("SELECT COUNT(*) FROM resource_pools WHERE is_test = TRUE") - test_resources_count = cur.fetchone()[0] - - # Letzte 5 erstellten Lizenzen (ohne Testdaten) - cur.execute(""" - SELECT l.id, l.license_key, c.name, l.valid_until, - CASE - WHEN l.is_active = FALSE THEN 'deaktiviert' - WHEN l.valid_until < CURRENT_DATE THEN 'abgelaufen' - WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'läuft bald ab' - ELSE 'aktiv' - END as status - FROM licenses l - JOIN customers c ON l.customer_id = c.id - WHERE l.is_test = FALSE - ORDER BY l.id DESC - LIMIT 5 - """) - recent_licenses = cur.fetchall() - - # Bald ablaufende Lizenzen (nächste 30 Tage, ohne Testdaten) - cur.execute(""" - SELECT l.id, l.license_key, c.name, l.valid_until, - l.valid_until - CURRENT_DATE as days_left - FROM licenses l - JOIN customers c ON l.customer_id = c.id - WHERE l.valid_until >= CURRENT_DATE - AND l.valid_until < CURRENT_DATE + INTERVAL '30 days' - AND l.is_active = TRUE - AND l.is_test = FALSE - ORDER BY l.valid_until - LIMIT 10 - """) - expiring_licenses = cur.fetchall() - - # Letztes Backup - cur.execute(""" - SELECT created_at, filesize, duration_seconds, backup_type, status - FROM backup_history - ORDER BY created_at DESC - LIMIT 1 - """) - last_backup_info = cur.fetchone() - - # Sicherheitsstatistiken - # Gesperrte IPs - cur.execute(""" - SELECT COUNT(*) FROM login_attempts - WHERE blocked_until IS NOT NULL AND blocked_until > CURRENT_TIMESTAMP - """) - blocked_ips_count = cur.fetchone()[0] - - # Fehlversuche heute - cur.execute(""" - SELECT COALESCE(SUM(attempt_count), 0) FROM login_attempts - WHERE last_attempt::date = CURRENT_DATE - """) - failed_attempts_today = cur.fetchone()[0] - - # Letzte 5 Sicherheitsereignisse - cur.execute(""" - SELECT - la.ip_address, - la.attempt_count, - la.last_attempt, - la.blocked_until, - la.last_username_tried, - la.last_error_message - FROM login_attempts la - ORDER BY la.last_attempt DESC - LIMIT 5 - """) - recent_security_events = [] - for event in cur.fetchall(): - recent_security_events.append({ - 'ip_address': event[0], - 'attempt_count': event[1], - 'last_attempt': event[2].strftime('%d.%m %H:%M'), - 'blocked_until': event[3].strftime('%d.%m %H:%M') if event[3] and event[3] > datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None) else None, - 'username_tried': event[4], - 'error_message': event[5] - }) - - # Sicherheitslevel berechnen - if blocked_ips_count > 5 or failed_attempts_today > 50: - security_level = 'danger' - security_level_text = 'KRITISCH' - elif blocked_ips_count > 2 or failed_attempts_today > 20: - security_level = 'warning' - security_level_text = 'ERHÖHT' - else: - security_level = 'success' - security_level_text = 'NORMAL' - - # Resource Pool Statistiken (nur Live-Daten, keine Testdaten) - cur.execute(""" - SELECT - resource_type, - COUNT(*) FILTER (WHERE status = 'available') as available, - COUNT(*) FILTER (WHERE status = 'allocated') as allocated, - COUNT(*) FILTER (WHERE status = 'quarantine') as quarantine, - COUNT(*) as total - FROM resource_pools - WHERE is_test = FALSE - GROUP BY resource_type - """) - - resource_stats = {} - resource_warning = None - - for row in cur.fetchall(): - available_percent = round((row[1] / row[4] * 100) if row[4] > 0 else 0, 1) - resource_stats[row[0]] = { - 'available': row[1], - 'allocated': row[2], - 'quarantine': row[3], - 'total': row[4], - 'available_percent': available_percent, - 'warning_level': 'danger' if row[1] < 50 else 'warning' if row[1] < 100 else 'success' - } - - # Warnung bei niedrigem Bestand - if row[1] < 50: - if not resource_warning: - resource_warning = f"Niedriger Bestand bei {row[0].upper()}: nur noch {row[1]} verfügbar!" - else: - resource_warning += f" | {row[0].upper()}: {row[1]}" - - cur.close() - conn.close() - - stats = { - 'total_customers': total_customers, - 'total_licenses': total_licenses, - 'active_licenses': active_licenses, - 'expired_licenses': expired_licenses, - 'inactive_licenses': inactive_licenses, - 'expiring_soon': expiring_soon, - 'full_licenses': license_types.get('full', 0), - 'test_licenses': license_types.get('test', 0), - 'test_data_count': test_data_count, - 'test_customers_count': test_customers_count, - 'test_resources_count': test_resources_count, - 'recent_licenses': recent_licenses, - 'expiring_licenses': expiring_licenses, - 'active_sessions': active_sessions_count, - 'last_backup': last_backup_info, - # Sicherheitsstatistiken - 'blocked_ips_count': blocked_ips_count, - 'failed_attempts_today': failed_attempts_today, - 'recent_security_events': recent_security_events, - 'security_level': security_level, - 'security_level_text': security_level_text, - 'resource_stats': resource_stats - } - - return render_template("dashboard.html", - stats=stats, - resource_stats=resource_stats, - resource_warning=resource_warning, - username=session.get('username')) - -@app.route("/create", methods=["GET", "POST"]) -@login_required -def create_license(): - if request.method == "POST": - customer_id = request.form.get("customer_id") - license_key = request.form["license_key"].upper() # Immer Großbuchstaben - license_type = request.form["license_type"] - valid_from = request.form["valid_from"] - is_test = request.form.get("is_test") == "on" # Checkbox value - - # Berechne valid_until basierend auf Laufzeit - duration = int(request.form.get("duration", 1)) - duration_type = request.form.get("duration_type", "years") - - from datetime import datetime, timedelta - from dateutil.relativedelta import relativedelta - - start_date = datetime.strptime(valid_from, "%Y-%m-%d") - - if duration_type == "days": - end_date = start_date + timedelta(days=duration) - elif duration_type == "months": - end_date = start_date + relativedelta(months=duration) - else: # years - end_date = start_date + relativedelta(years=duration) - - # Ein Tag abziehen, da der Starttag mitgezählt wird - end_date = end_date - timedelta(days=1) - valid_until = end_date.strftime("%Y-%m-%d") - - # Validiere License Key Format - if not validate_license_key(license_key): - flash('Ungültiges License Key Format! Erwartet: AF-YYYYMMFT-XXXX-YYYY-ZZZZ', 'error') - return redirect(url_for('create_license')) - - # Resource counts - domain_count = int(request.form.get("domain_count", 1)) - ipv4_count = int(request.form.get("ipv4_count", 1)) - phone_count = int(request.form.get("phone_count", 1)) - device_limit = int(request.form.get("device_limit", 3)) - - conn = get_connection() - cur = conn.cursor() - - try: - # Prüfe ob neuer Kunde oder bestehender - if customer_id == "new": - # Neuer Kunde - name = request.form.get("customer_name") - email = request.form.get("email") - - if not name: - flash('Kundenname ist erforderlich!', 'error') - return redirect(url_for('create_license')) - - # Prüfe ob E-Mail bereits existiert - if email: - cur.execute("SELECT id, name FROM customers WHERE LOWER(email) = LOWER(%s)", (email,)) - existing = cur.fetchone() - if existing: - flash(f'E-Mail bereits vergeben für Kunde: {existing[1]}', 'error') - return redirect(url_for('create_license')) - - # Kunde einfügen (erbt Test-Status von Lizenz) - cur.execute(""" - INSERT INTO customers (name, email, is_test, created_at) - VALUES (%s, %s, %s, NOW()) - RETURNING id - """, (name, email, is_test)) - customer_id = cur.fetchone()[0] - customer_info = {'name': name, 'email': email, 'is_test': is_test} - - # Audit-Log für neuen Kunden - log_audit('CREATE', 'customer', customer_id, - new_values={'name': name, 'email': email, 'is_test': is_test}) - else: - # Bestehender Kunde - hole Infos für Audit-Log - cur.execute("SELECT name, email, is_test FROM customers WHERE id = %s", (customer_id,)) - customer_data = cur.fetchone() - if not customer_data: - flash('Kunde nicht gefunden!', 'error') - return redirect(url_for('create_license')) - customer_info = {'name': customer_data[0], 'email': customer_data[1]} - - # Wenn Kunde Test-Kunde ist, Lizenz auch als Test markieren - if customer_data[2]: # is_test des Kunden - is_test = True - - # Lizenz hinzufügen - cur.execute(""" - INSERT INTO licenses (license_key, customer_id, license_type, valid_from, valid_until, is_active, - domain_count, ipv4_count, phone_count, device_limit, is_test) - VALUES (%s, %s, %s, %s, %s, TRUE, %s, %s, %s, %s, %s) - RETURNING id - """, (license_key, customer_id, license_type, valid_from, valid_until, - domain_count, ipv4_count, phone_count, device_limit, is_test)) - license_id = cur.fetchone()[0] - - # Ressourcen zuweisen - try: - # Prüfe Verfügbarkeit - cur.execute(""" - SELECT - (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s) as domains, - (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s) as ipv4s, - (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s) as phones - """, (is_test, is_test, is_test)) - available = cur.fetchone() - - if available[0] < domain_count: - raise ValueError(f"Nicht genügend Domains verfügbar (benötigt: {domain_count}, verfügbar: {available[0]})") - if available[1] < ipv4_count: - raise ValueError(f"Nicht genügend IPv4-Adressen verfügbar (benötigt: {ipv4_count}, verfügbar: {available[1]})") - if available[2] < phone_count: - raise ValueError(f"Nicht genügend Telefonnummern verfügbar (benötigt: {phone_count}, verfügbar: {available[2]})") - - # Domains zuweisen - if domain_count > 0: - cur.execute(""" - SELECT id FROM resource_pools - WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s - LIMIT %s FOR UPDATE - """, (is_test, domain_count)) - for (resource_id,) in cur.fetchall(): - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], resource_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, resource_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (resource_id, license_id, session['username'], get_client_ip())) - - # IPv4s zuweisen - if ipv4_count > 0: - cur.execute(""" - SELECT id FROM resource_pools - WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s - LIMIT %s FOR UPDATE - """, (is_test, ipv4_count)) - for (resource_id,) in cur.fetchall(): - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], resource_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, resource_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (resource_id, license_id, session['username'], get_client_ip())) - - # Telefonnummern zuweisen - if phone_count > 0: - cur.execute(""" - SELECT id FROM resource_pools - WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s - LIMIT %s FOR UPDATE - """, (is_test, phone_count)) - for (resource_id,) in cur.fetchall(): - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], resource_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, resource_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (resource_id, license_id, session['username'], get_client_ip())) - - except ValueError as e: - conn.rollback() - flash(str(e), 'error') - return redirect(url_for('create_license')) - - conn.commit() - - # Audit-Log - log_audit('CREATE', 'license', license_id, - new_values={ - 'license_key': license_key, - 'customer_name': customer_info['name'], - 'customer_email': customer_info['email'], - 'license_type': license_type, - 'valid_from': valid_from, - 'valid_until': valid_until, - 'device_limit': device_limit, - 'is_test': is_test - }) - - flash(f'Lizenz {license_key} erfolgreich erstellt!', 'success') - - except Exception as e: - conn.rollback() - logging.error(f"Fehler beim Erstellen der Lizenz: {str(e)}") - flash('Fehler beim Erstellen der Lizenz!', 'error') - finally: - cur.close() - conn.close() - - # Preserve show_test parameter if present - redirect_url = "/create" - if request.args.get('show_test') == 'true': - redirect_url += "?show_test=true" - return redirect(redirect_url) - - # Unterstützung für vorausgewählten Kunden - preselected_customer_id = request.args.get('customer_id', type=int) - return render_template("index.html", username=session.get('username'), preselected_customer_id=preselected_customer_id) - -@app.route("/batch", methods=["GET", "POST"]) -@login_required -def batch_licenses(): - """Batch-Generierung mehrerer Lizenzen für einen Kunden""" - if request.method == "POST": - # Formulardaten - customer_id = request.form.get("customer_id") - license_type = request.form["license_type"] - quantity = int(request.form["quantity"]) - valid_from = request.form["valid_from"] - is_test = request.form.get("is_test") == "on" # Checkbox value - - # Berechne valid_until basierend auf Laufzeit - duration = int(request.form.get("duration", 1)) - duration_type = request.form.get("duration_type", "years") - - from datetime import datetime, timedelta - from dateutil.relativedelta import relativedelta - - start_date = datetime.strptime(valid_from, "%Y-%m-%d") - - if duration_type == "days": - end_date = start_date + timedelta(days=duration) - elif duration_type == "months": - end_date = start_date + relativedelta(months=duration) - else: # years - end_date = start_date + relativedelta(years=duration) - - # Ein Tag abziehen, da der Starttag mitgezählt wird - end_date = end_date - timedelta(days=1) - valid_until = end_date.strftime("%Y-%m-%d") - - # Resource counts - domain_count = int(request.form.get("domain_count", 1)) - ipv4_count = int(request.form.get("ipv4_count", 1)) - phone_count = int(request.form.get("phone_count", 1)) - device_limit = int(request.form.get("device_limit", 3)) - - # Sicherheitslimit - if quantity < 1 or quantity > 100: - flash('Anzahl muss zwischen 1 und 100 liegen!', 'error') - return redirect(url_for('batch_licenses')) - - conn = get_connection() - cur = conn.cursor() - - try: - # Prüfe ob neuer Kunde oder bestehender - if customer_id == "new": - # Neuer Kunde - name = request.form.get("customer_name") - email = request.form.get("email") - - if not name: - flash('Kundenname ist erforderlich!', 'error') - return redirect(url_for('batch_licenses')) - - # Prüfe ob E-Mail bereits existiert - if email: - cur.execute("SELECT id, name FROM customers WHERE LOWER(email) = LOWER(%s)", (email,)) - existing = cur.fetchone() - if existing: - flash(f'E-Mail bereits vergeben für Kunde: {existing[1]}', 'error') - return redirect(url_for('batch_licenses')) - - # Kunde einfügen (erbt Test-Status von Lizenz) - cur.execute(""" - INSERT INTO customers (name, email, is_test, created_at) - VALUES (%s, %s, %s, NOW()) - RETURNING id - """, (name, email, is_test)) - customer_id = cur.fetchone()[0] - - # Audit-Log für neuen Kunden - log_audit('CREATE', 'customer', customer_id, - new_values={'name': name, 'email': email, 'is_test': is_test}) - else: - # Bestehender Kunde - hole Infos - cur.execute("SELECT name, email, is_test FROM customers WHERE id = %s", (customer_id,)) - customer_data = cur.fetchone() - if not customer_data: - flash('Kunde nicht gefunden!', 'error') - return redirect(url_for('batch_licenses')) - name = customer_data[0] - email = customer_data[1] - - # Wenn Kunde Test-Kunde ist, Lizenzen auch als Test markieren - if customer_data[2]: # is_test des Kunden - is_test = True - - # Prüfe Ressourcen-Verfügbarkeit für gesamten Batch - total_domains_needed = domain_count * quantity - total_ipv4s_needed = ipv4_count * quantity - total_phones_needed = phone_count * quantity - - cur.execute(""" - SELECT - (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s) as domains, - (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s) as ipv4s, - (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s) as phones - """, (is_test, is_test, is_test)) - available = cur.fetchone() - - if available[0] < total_domains_needed: - flash(f"Nicht genügend Domains verfügbar (benötigt: {total_domains_needed}, verfügbar: {available[0]})", 'error') - return redirect(url_for('batch_licenses')) - if available[1] < total_ipv4s_needed: - flash(f"Nicht genügend IPv4-Adressen verfügbar (benötigt: {total_ipv4s_needed}, verfügbar: {available[1]})", 'error') - return redirect(url_for('batch_licenses')) - if available[2] < total_phones_needed: - flash(f"Nicht genügend Telefonnummern verfügbar (benötigt: {total_phones_needed}, verfügbar: {available[2]})", 'error') - return redirect(url_for('batch_licenses')) - - # Lizenzen generieren und speichern - generated_licenses = [] - for i in range(quantity): - # Eindeutigen Key generieren - attempts = 0 - while attempts < 10: - license_key = generate_license_key(license_type) - cur.execute("SELECT 1 FROM licenses WHERE license_key = %s", (license_key,)) - if not cur.fetchone(): - break - attempts += 1 - - # Lizenz einfügen - cur.execute(""" - INSERT INTO licenses (license_key, customer_id, license_type, is_test, - valid_from, valid_until, is_active, - domain_count, ipv4_count, phone_count, device_limit) - VALUES (%s, %s, %s, %s, %s, %s, true, %s, %s, %s, %s) - RETURNING id - """, (license_key, customer_id, license_type, is_test, valid_from, valid_until, - domain_count, ipv4_count, phone_count, device_limit)) - license_id = cur.fetchone()[0] - - # Ressourcen für diese Lizenz zuweisen - # Domains - if domain_count > 0: - cur.execute(""" - SELECT id FROM resource_pools - WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s - LIMIT %s FOR UPDATE - """, (is_test, domain_count)) - for (resource_id,) in cur.fetchall(): - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], resource_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, resource_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (resource_id, license_id, session['username'], get_client_ip())) - - # IPv4s - if ipv4_count > 0: - cur.execute(""" - SELECT id FROM resource_pools - WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s - LIMIT %s FOR UPDATE - """, (is_test, ipv4_count)) - for (resource_id,) in cur.fetchall(): - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], resource_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, resource_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (resource_id, license_id, session['username'], get_client_ip())) - - # Telefonnummern - if phone_count > 0: - cur.execute(""" - SELECT id FROM resource_pools - WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s - LIMIT %s FOR UPDATE - """, (is_test, phone_count)) - for (resource_id,) in cur.fetchall(): - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], resource_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, resource_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (resource_id, license_id, session['username'], get_client_ip())) - - generated_licenses.append({ - 'id': license_id, - 'key': license_key, - 'type': license_type - }) - - conn.commit() - - # Audit-Log - log_audit('CREATE_BATCH', 'license', - new_values={'customer': name, 'quantity': quantity, 'type': license_type}, - additional_info=f"Batch-Generierung von {quantity} Lizenzen") - - # Session für Export speichern - session['batch_export'] = { - 'customer': name, - 'email': email, - 'licenses': generated_licenses, - 'valid_from': valid_from, - 'valid_until': valid_until, - 'timestamp': datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() - } - - flash(f'{quantity} Lizenzen erfolgreich generiert!', 'success') - return render_template("batch_result.html", - customer=name, - email=email, - licenses=generated_licenses, - valid_from=valid_from, - valid_until=valid_until) - - except Exception as e: - conn.rollback() - logging.error(f"Fehler bei Batch-Generierung: {str(e)}") - flash('Fehler bei der Batch-Generierung!', 'error') - return redirect(url_for('batch_licenses')) - finally: - cur.close() - conn.close() - - # GET Request - return render_template("batch_form.html") - -@app.route("/batch/export") -@login_required -def export_batch(): - """Exportiert die zuletzt generierten Batch-Lizenzen""" - batch_data = session.get('batch_export') - if not batch_data: - flash('Keine Batch-Daten zum Exportieren vorhanden!', 'error') - return redirect(url_for('batch_licenses')) - - # CSV generieren - output = io.StringIO() - output.write('\ufeff') # UTF-8 BOM für Excel - - # Header - output.write(f"Kunde: {batch_data['customer']}\n") - output.write(f"E-Mail: {batch_data['email']}\n") - output.write(f"Generiert am: {datetime.fromisoformat(batch_data['timestamp']).strftime('%d.%m.%Y %H:%M')}\n") - output.write(f"Gültig von: {batch_data['valid_from']} bis {batch_data['valid_until']}\n") - output.write("\n") - output.write("Nr;Lizenzschlüssel;Typ\n") - - # Lizenzen - for i, license in enumerate(batch_data['licenses'], 1): - typ_text = "Vollversion" if license['type'] == 'full' else "Testversion" - output.write(f"{i};{license['key']};{typ_text}\n") - - output.seek(0) - - # Audit-Log - log_audit('EXPORT', 'batch_licenses', - additional_info=f"Export von {len(batch_data['licenses'])} Batch-Lizenzen") - - return send_file( - io.BytesIO(output.getvalue().encode('utf-8-sig')), - mimetype='text/csv', - as_attachment=True, - download_name=f"batch_licenses_{batch_data['customer'].replace(' ', '_')}_{datetime.now(ZoneInfo('Europe/Berlin')).strftime('%Y%m%d_%H%M%S')}.csv" - ) - -@app.route("/licenses") -@login_required -def licenses(): - # Redirect zur kombinierten Ansicht - return redirect("/customers-licenses") - -@app.route("/license/edit/", methods=["GET", "POST"]) -@login_required -def edit_license(license_id): - conn = get_connection() - cur = conn.cursor() - - if request.method == "POST": - # Alte Werte für Audit-Log abrufen - cur.execute(""" - SELECT license_key, license_type, valid_from, valid_until, is_active, is_test, device_limit - FROM licenses WHERE id = %s - """, (license_id,)) - old_license = cur.fetchone() - - # Update license - license_key = request.form["license_key"] - license_type = request.form["license_type"] - valid_from = request.form["valid_from"] - valid_until = request.form["valid_until"] - is_active = request.form.get("is_active") == "on" - is_test = request.form.get("is_test") == "on" - device_limit = int(request.form.get("device_limit", 3)) - - cur.execute(""" - UPDATE licenses - SET license_key = %s, license_type = %s, valid_from = %s, - valid_until = %s, is_active = %s, is_test = %s, device_limit = %s - WHERE id = %s - """, (license_key, license_type, valid_from, valid_until, is_active, is_test, device_limit, license_id)) - - conn.commit() - - # Audit-Log - log_audit('UPDATE', 'license', license_id, - old_values={ - 'license_key': old_license[0], - 'license_type': old_license[1], - 'valid_from': str(old_license[2]), - 'valid_until': str(old_license[3]), - 'is_active': old_license[4], - 'is_test': old_license[5], - 'device_limit': old_license[6] - }, - new_values={ - 'license_key': license_key, - 'license_type': license_type, - 'valid_from': valid_from, - 'valid_until': valid_until, - 'is_active': is_active, - 'is_test': is_test, - 'device_limit': device_limit - }) - - cur.close() - conn.close() - - # Redirect zurück zu customers-licenses mit beibehaltenen Parametern - redirect_url = "/customers-licenses" - - # Behalte show_test Parameter bei (aus Form oder GET-Parameter) - show_test = request.form.get('show_test') or request.args.get('show_test') - if show_test == 'true': - redirect_url += "?show_test=true" - - # Behalte customer_id bei wenn vorhanden - if request.referrer and 'customer_id=' in request.referrer: - import re - match = re.search(r'customer_id=(\d+)', request.referrer) - if match: - connector = "&" if "?" in redirect_url else "?" - redirect_url += f"{connector}customer_id={match.group(1)}" - - return redirect(redirect_url) - - # Get license data - cur.execute(""" - SELECT l.id, l.license_key, c.name, c.email, l.license_type, - l.valid_from, l.valid_until, l.is_active, c.id, l.is_test, l.device_limit - FROM licenses l - JOIN customers c ON l.customer_id = c.id - WHERE l.id = %s - """, (license_id,)) - - license = cur.fetchone() - cur.close() - conn.close() - - if not license: - return redirect("/licenses") - - return render_template("edit_license.html", license=license, username=session.get('username')) - -@app.route("/license/delete/", methods=["POST"]) -@login_required -def delete_license(license_id): - conn = get_connection() - cur = conn.cursor() - - # Lizenzdetails für Audit-Log abrufen - cur.execute(""" - SELECT l.license_key, c.name, l.license_type - FROM licenses l - JOIN customers c ON l.customer_id = c.id - WHERE l.id = %s - """, (license_id,)) - license_info = cur.fetchone() - - cur.execute("DELETE FROM licenses WHERE id = %s", (license_id,)) - - conn.commit() - - # Audit-Log - if license_info: - log_audit('DELETE', 'license', license_id, - old_values={ - 'license_key': license_info[0], - 'customer_name': license_info[1], - 'license_type': license_info[2] - }) - - cur.close() - conn.close() - - return redirect("/licenses") - -@app.route("/customers") -@login_required -def customers(): - # Redirect zur kombinierten Ansicht - return redirect("/customers-licenses") - -@app.route("/customer/edit/", methods=["GET", "POST"]) -@login_required -def edit_customer(customer_id): - conn = get_connection() - cur = conn.cursor() - - if request.method == "POST": - # Alte Werte für Audit-Log abrufen - cur.execute("SELECT name, email, is_test FROM customers WHERE id = %s", (customer_id,)) - old_customer = cur.fetchone() - - # Update customer - name = request.form["name"] - email = request.form["email"] - is_test = request.form.get("is_test") == "on" - - cur.execute(""" - UPDATE customers - SET name = %s, email = %s, is_test = %s - WHERE id = %s - """, (name, email, is_test, customer_id)) - - conn.commit() - - # Audit-Log - log_audit('UPDATE', 'customer', customer_id, - old_values={ - 'name': old_customer[0], - 'email': old_customer[1], - 'is_test': old_customer[2] - }, - new_values={ - 'name': name, - 'email': email, - 'is_test': is_test - }) - - cur.close() - conn.close() - - # Redirect zurück zu customers-licenses mit beibehaltenen Parametern - redirect_url = "/customers-licenses" - - # Behalte show_test Parameter bei (aus Form oder GET-Parameter) - show_test = request.form.get('show_test') or request.args.get('show_test') - if show_test == 'true': - redirect_url += "?show_test=true" - - # Behalte customer_id bei (immer der aktuelle Kunde) - connector = "&" if "?" in redirect_url else "?" - redirect_url += f"{connector}customer_id={customer_id}" - - return redirect(redirect_url) - - # Get customer data with licenses - cur.execute(""" - SELECT id, name, email, created_at, is_test FROM customers WHERE id = %s - """, (customer_id,)) - - customer = cur.fetchone() - if not customer: - cur.close() - conn.close() - return "Kunde nicht gefunden", 404 - - - # Get customer's licenses - cur.execute(""" - SELECT id, license_key, license_type, valid_from, valid_until, is_active - FROM licenses - WHERE customer_id = %s - ORDER BY valid_until DESC - """, (customer_id,)) - - licenses = cur.fetchall() - - cur.close() - conn.close() - - if not customer: - return redirect("/customers-licenses") - - return render_template("edit_customer.html", customer=customer, licenses=licenses, username=session.get('username')) - -@app.route("/customer/create", methods=["GET", "POST"]) -@login_required -def create_customer(): - """Erstellt einen neuen Kunden ohne Lizenz""" - if request.method == "POST": - name = request.form.get('name') - email = request.form.get('email') - is_test = request.form.get('is_test') == 'on' - - if not name or not email: - flash("Name und E-Mail sind Pflichtfelder!", "error") - return render_template("create_customer.html", username=session.get('username')) - - conn = get_connection() - cur = conn.cursor() - - try: - # Prüfen ob E-Mail bereits existiert - cur.execute("SELECT id, name FROM customers WHERE email = %s", (email,)) - existing = cur.fetchone() - if existing: - flash(f"Ein Kunde mit der E-Mail '{email}' existiert bereits: {existing[1]}", "error") - return render_template("create_customer.html", username=session.get('username')) - - # Kunde erstellen - cur.execute(""" - INSERT INTO customers (name, email, created_at, is_test) - VALUES (%s, %s, %s, %s) RETURNING id - """, (name, email, datetime.now(), is_test)) - - customer_id = cur.fetchone()[0] - conn.commit() - - # Audit-Log - log_audit('CREATE', 'customer', customer_id, - new_values={ - 'name': name, - 'email': email, - 'is_test': is_test - }) - - flash(f"Kunde '{name}' wurde erfolgreich angelegt!", "success") - return redirect(f"/customer/edit/{customer_id}") - - except Exception as e: - conn.rollback() - flash(f"Fehler beim Anlegen des Kunden: {str(e)}", "error") - return render_template("create_customer.html", username=session.get('username')) - finally: - cur.close() - conn.close() - - # GET Request - Formular anzeigen - return render_template("create_customer.html", username=session.get('username')) - -@app.route("/customer/delete/", methods=["POST"]) -@login_required -def delete_customer(customer_id): - conn = get_connection() - cur = conn.cursor() - - # Prüfen ob Kunde Lizenzen hat - cur.execute("SELECT COUNT(*) FROM licenses WHERE customer_id = %s", (customer_id,)) - license_count = cur.fetchone()[0] - - if license_count > 0: - # Kunde hat Lizenzen - nicht löschen - cur.close() - conn.close() - return redirect("/customers") - - # Kundendetails für Audit-Log abrufen - cur.execute("SELECT name, email FROM customers WHERE id = %s", (customer_id,)) - customer_info = cur.fetchone() - - # Kunde löschen wenn keine Lizenzen vorhanden - cur.execute("DELETE FROM customers WHERE id = %s", (customer_id,)) - - conn.commit() - - # Audit-Log - if customer_info: - log_audit('DELETE', 'customer', customer_id, - old_values={ - 'name': customer_info[0], - 'email': customer_info[1] - }) - - cur.close() - conn.close() - - return redirect("/customers") - -@app.route("/customers-licenses") -@login_required -def customers_licenses(): - """Kombinierte Ansicht für Kunden und deren Lizenzen""" - conn = get_connection() - cur = conn.cursor() - - # Hole alle Kunden mit Lizenzstatistiken (inkl. Testkunden wenn gewünscht) - show_test = request.args.get('show_test', 'false').lower() == 'true' - - query = """ - SELECT - c.id, - c.name, - c.email, - c.created_at, - COUNT(l.id) as total_licenses, - COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE THEN 1 END) as active_licenses, - COUNT(CASE WHEN l.valid_until < CURRENT_DATE THEN 1 END) as expired_licenses - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - """ - - if not show_test: - query += " WHERE c.is_test = FALSE" - - query += """ - GROUP BY c.id, c.name, c.email, c.created_at - ORDER BY c.name - """ - - cur.execute(query) - customers = cur.fetchall() - - # Hole ausgewählten Kunden nur wenn explizit in URL angegeben - selected_customer_id = request.args.get('customer_id', type=int) - licenses = [] - selected_customer = None - - if customers and selected_customer_id: - # Hole Daten des ausgewählten Kunden - for customer in customers: - if customer[0] == selected_customer_id: - selected_customer = customer - break - - # Hole Lizenzen des ausgewählten Kunden - if selected_customer: - cur.execute(""" - SELECT - l.id, - l.license_key, - l.license_type, - l.valid_from, - l.valid_until, - l.is_active, - CASE - WHEN l.is_active = FALSE THEN 'deaktiviert' - WHEN l.valid_until < CURRENT_DATE THEN 'abgelaufen' - WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'läuft bald ab' - ELSE 'aktiv' - END as status, - l.domain_count, - l.ipv4_count, - l.phone_count, - l.device_limit, - (SELECT COUNT(*) FROM device_registrations WHERE license_id = l.id AND is_active = TRUE) as active_devices, - -- Actual resource counts - (SELECT COUNT(*) FROM license_resources lr - JOIN resource_pools rp ON lr.resource_id = rp.id - WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'domain') as actual_domain_count, - (SELECT COUNT(*) FROM license_resources lr - JOIN resource_pools rp ON lr.resource_id = rp.id - WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'ipv4') as actual_ipv4_count, - (SELECT COUNT(*) FROM license_resources lr - JOIN resource_pools rp ON lr.resource_id = rp.id - WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'phone') as actual_phone_count - FROM licenses l - WHERE l.customer_id = %s - ORDER BY l.created_at DESC, l.id DESC - """, (selected_customer_id,)) - licenses = cur.fetchall() - - cur.close() - conn.close() - - return render_template("customers_licenses.html", - customers=customers, - selected_customer=selected_customer, - selected_customer_id=selected_customer_id, - licenses=licenses, - show_test=show_test) - -@app.route("/api/customer//licenses") -@login_required -def api_customer_licenses(customer_id): - """API-Endpoint für AJAX-Abruf der Lizenzen eines Kunden""" - conn = get_connection() - cur = conn.cursor() - - # Hole Lizenzen des Kunden - cur.execute(""" - SELECT - l.id, - l.license_key, - l.license_type, - l.valid_from, - l.valid_until, - l.is_active, - CASE - WHEN l.is_active = FALSE THEN 'deaktiviert' - WHEN l.valid_until < CURRENT_DATE THEN 'abgelaufen' - WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'läuft bald ab' - ELSE 'aktiv' - END as status, - l.domain_count, - l.ipv4_count, - l.phone_count, - l.device_limit, - (SELECT COUNT(*) FROM device_registrations WHERE license_id = l.id AND is_active = TRUE) as active_devices, - -- Actual resource counts - (SELECT COUNT(*) FROM license_resources lr - JOIN resource_pools rp ON lr.resource_id = rp.id - WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'domain') as actual_domain_count, - (SELECT COUNT(*) FROM license_resources lr - JOIN resource_pools rp ON lr.resource_id = rp.id - WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'ipv4') as actual_ipv4_count, - (SELECT COUNT(*) FROM license_resources lr - JOIN resource_pools rp ON lr.resource_id = rp.id - WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'phone') as actual_phone_count - FROM licenses l - WHERE l.customer_id = %s - ORDER BY l.created_at DESC, l.id DESC - """, (customer_id,)) - - licenses = [] - for row in cur.fetchall(): - license_id = row[0] - - # Hole die konkreten zugewiesenen Ressourcen für diese Lizenz - cur.execute(""" - SELECT rp.id, rp.resource_type, rp.resource_value, lr.assigned_at - FROM resource_pools rp - JOIN license_resources lr ON rp.id = lr.resource_id - WHERE lr.license_id = %s AND lr.is_active = true - ORDER BY rp.resource_type, rp.resource_value - """, (license_id,)) - - resources = { - 'domains': [], - 'ipv4s': [], - 'phones': [] - } - - for res_row in cur.fetchall(): - resource_info = { - 'id': res_row[0], - 'value': res_row[2], - 'assigned_at': res_row[3].strftime('%d.%m.%Y') if res_row[3] else '' - } - - if res_row[1] == 'domain': - resources['domains'].append(resource_info) - elif res_row[1] == 'ipv4': - resources['ipv4s'].append(resource_info) - elif res_row[1] == 'phone': - resources['phones'].append(resource_info) - - licenses.append({ - 'id': row[0], - 'license_key': row[1], - 'license_type': row[2], - 'valid_from': row[3].strftime('%d.%m.%Y') if row[3] else '', - 'valid_until': row[4].strftime('%d.%m.%Y') if row[4] else '', - 'is_active': row[5], - 'status': row[6], - 'domain_count': row[7], # limit - 'ipv4_count': row[8], # limit - 'phone_count': row[9], # limit - 'device_limit': row[10], - 'active_devices': row[11], - 'actual_domain_count': row[12], # actual count - 'actual_ipv4_count': row[13], # actual count - 'actual_phone_count': row[14], # actual count - 'resources': resources - }) - - cur.close() - conn.close() - - return jsonify({ - 'success': True, - 'licenses': licenses, - 'count': len(licenses) - }) - -@app.route("/api/customer//quick-stats") -@login_required -def api_customer_quick_stats(customer_id): - """API-Endpoint für Schnellstatistiken eines Kunden""" - conn = get_connection() - cur = conn.cursor() - - # Hole Kundenstatistiken - cur.execute(""" - SELECT - COUNT(l.id) as total_licenses, - COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE THEN 1 END) as active_licenses, - COUNT(CASE WHEN l.valid_until < CURRENT_DATE THEN 1 END) as expired_licenses, - COUNT(CASE WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' AND l.valid_until >= CURRENT_DATE THEN 1 END) as expiring_soon - FROM licenses l - WHERE l.customer_id = %s - """, (customer_id,)) - - stats = cur.fetchone() - - cur.close() - conn.close() - - return jsonify({ - 'success': True, - 'stats': { - 'total': stats[0], - 'active': stats[1], - 'expired': stats[2], - 'expiring_soon': stats[3] - } - }) - -@app.route("/api/license//quick-edit", methods=['POST']) -@login_required -def api_license_quick_edit(license_id): - """API-Endpoint für schnelle Lizenz-Bearbeitung""" - conn = get_connection() - cur = conn.cursor() - - try: - data = request.get_json() - - # Hole alte Werte für Audit-Log - cur.execute(""" - SELECT is_active, valid_until, license_type - FROM licenses WHERE id = %s - """, (license_id,)) - old_values = cur.fetchone() - - if not old_values: - return jsonify({'success': False, 'error': 'Lizenz nicht gefunden'}), 404 - - # Update-Felder vorbereiten - updates = [] - params = [] - new_values = {} - - if 'is_active' in data: - updates.append("is_active = %s") - params.append(data['is_active']) - new_values['is_active'] = data['is_active'] - - if 'valid_until' in data: - updates.append("valid_until = %s") - params.append(data['valid_until']) - new_values['valid_until'] = data['valid_until'] - - if 'license_type' in data: - updates.append("license_type = %s") - params.append(data['license_type']) - new_values['license_type'] = data['license_type'] - - if updates: - params.append(license_id) - cur.execute(f""" - UPDATE licenses - SET {', '.join(updates)} - WHERE id = %s - """, params) - - conn.commit() - - # Audit-Log - log_audit('UPDATE', 'license', license_id, - old_values={ - 'is_active': old_values[0], - 'valid_until': old_values[1].isoformat() if old_values[1] else None, - 'license_type': old_values[2] - }, - new_values=new_values) - - cur.close() - conn.close() - - return jsonify({'success': True}) - - except Exception as e: - conn.rollback() - cur.close() - conn.close() - return jsonify({'success': False, 'error': str(e)}), 500 - -@app.route("/api/license//resources") -@login_required -def api_license_resources(license_id): - """API-Endpoint für detaillierte Ressourcen-Informationen einer Lizenz""" - conn = get_connection() - cur = conn.cursor() - - try: - # Hole die konkreten zugewiesenen Ressourcen für diese Lizenz - cur.execute(""" - SELECT rp.id, rp.resource_type, rp.resource_value, lr.assigned_at - FROM resource_pools rp - JOIN license_resources lr ON rp.id = lr.resource_id - WHERE lr.license_id = %s AND lr.is_active = true - ORDER BY rp.resource_type, rp.resource_value - """, (license_id,)) - - resources = { - 'domains': [], - 'ipv4s': [], - 'phones': [] - } - - for row in cur.fetchall(): - resource_info = { - 'id': row[0], - 'value': row[2], - 'assigned_at': row[3].strftime('%d.%m.%Y') if row[3] else '' - } - - if row[1] == 'domain': - resources['domains'].append(resource_info) - elif row[1] == 'ipv4': - resources['ipv4s'].append(resource_info) - elif row[1] == 'phone': - resources['phones'].append(resource_info) - - cur.close() - conn.close() - - return jsonify({ - 'success': True, - 'resources': resources - }) - - except Exception as e: - cur.close() - conn.close() - return jsonify({'success': False, 'error': str(e)}), 500 - -@app.route("/sessions") -@login_required -def sessions(): - conn = get_connection() - cur = conn.cursor() - - # Sortierparameter - active_sort = request.args.get('active_sort', 'last_heartbeat') - active_order = request.args.get('active_order', 'desc') - ended_sort = request.args.get('ended_sort', 'ended_at') - ended_order = request.args.get('ended_order', 'desc') - - # Whitelist für erlaubte Sortierfelder - Aktive Sessions - active_sort_fields = { - 'customer': 'c.name', - 'license': 'l.license_key', - 'ip': 's.ip_address', - 'started': 's.started_at', - 'last_heartbeat': 's.last_heartbeat', - 'inactive': 'minutes_inactive' - } - - # Whitelist für erlaubte Sortierfelder - Beendete Sessions - ended_sort_fields = { - 'customer': 'c.name', - 'license': 'l.license_key', - 'ip': 's.ip_address', - 'started': 's.started_at', - 'ended_at': 's.ended_at', - 'duration': 'duration_minutes' - } - - # Validierung - if active_sort not in active_sort_fields: - active_sort = 'last_heartbeat' - if ended_sort not in ended_sort_fields: - ended_sort = 'ended_at' - if active_order not in ['asc', 'desc']: - active_order = 'desc' - if ended_order not in ['asc', 'desc']: - ended_order = 'desc' - - # Aktive Sessions abrufen - cur.execute(f""" - SELECT s.id, s.session_id, l.license_key, c.name, s.ip_address, - s.user_agent, s.started_at, s.last_heartbeat, - EXTRACT(EPOCH FROM (NOW() - s.last_heartbeat))/60 as minutes_inactive - FROM sessions s - JOIN licenses l ON s.license_id = l.id - JOIN customers c ON l.customer_id = c.id - WHERE s.is_active = TRUE - ORDER BY {active_sort_fields[active_sort]} {active_order.upper()} - """) - active_sessions = cur.fetchall() - - # Inaktive Sessions der letzten 24 Stunden - cur.execute(f""" - SELECT s.id, s.session_id, l.license_key, c.name, s.ip_address, - s.started_at, s.ended_at, - EXTRACT(EPOCH FROM (s.ended_at - s.started_at))/60 as duration_minutes - FROM sessions s - JOIN licenses l ON s.license_id = l.id - JOIN customers c ON l.customer_id = c.id - WHERE s.is_active = FALSE - AND s.ended_at > NOW() - INTERVAL '24 hours' - ORDER BY {ended_sort_fields[ended_sort]} {ended_order.upper()} - LIMIT 50 - """) - recent_sessions = cur.fetchall() - - cur.close() - conn.close() - - return render_template("sessions.html", - active_sessions=active_sessions, - recent_sessions=recent_sessions, - active_sort=active_sort, - active_order=active_order, - ended_sort=ended_sort, - ended_order=ended_order, - username=session.get('username')) - -@app.route("/session/end/", methods=["POST"]) -@login_required -def end_session(session_id): - conn = get_connection() - cur = conn.cursor() - - # Session beenden - cur.execute(""" - UPDATE sessions - SET is_active = FALSE, ended_at = NOW() - WHERE id = %s AND is_active = TRUE - """, (session_id,)) - - conn.commit() - cur.close() - conn.close() - - return redirect("/sessions") - -@app.route("/export/licenses") -@login_required -def export_licenses(): - conn = get_connection() - cur = conn.cursor() - - # Alle Lizenzen mit Kundeninformationen abrufen (ohne Testdaten, außer explizit gewünscht) - include_test = request.args.get('include_test', 'false').lower() == 'true' - customer_id = request.args.get('customer_id', type=int) - - query = """ - SELECT l.id, l.license_key, c.name as customer_name, c.email as customer_email, - l.license_type, l.valid_from, l.valid_until, l.is_active, l.is_test, - CASE - WHEN l.is_active = FALSE THEN 'Deaktiviert' - WHEN l.valid_until < CURRENT_DATE THEN 'Abgelaufen' - WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'Läuft bald ab' - ELSE 'Aktiv' - END as status - FROM licenses l - JOIN customers c ON l.customer_id = c.id - """ - - # Build WHERE clause - where_conditions = [] - params = [] - - if not include_test: - where_conditions.append("l.is_test = FALSE") - - if customer_id: - where_conditions.append("l.customer_id = %s") - params.append(customer_id) - - if where_conditions: - query += " WHERE " + " AND ".join(where_conditions) - - query += " ORDER BY l.id" - - cur.execute(query, params) - - # Spaltennamen - columns = ['ID', 'Lizenzschlüssel', 'Kunde', 'E-Mail', 'Typ', - 'Gültig von', 'Gültig bis', 'Aktiv', 'Testdaten', 'Status'] - - # Daten in DataFrame - data = cur.fetchall() - df = pd.DataFrame(data, columns=columns) - - # Datumsformatierung - df['Gültig von'] = pd.to_datetime(df['Gültig von']).dt.strftime('%d.%m.%Y') - df['Gültig bis'] = pd.to_datetime(df['Gültig bis']).dt.strftime('%d.%m.%Y') - - # Typ und Aktiv Status anpassen - df['Typ'] = df['Typ'].replace({'full': 'Vollversion', 'test': 'Testversion'}) - df['Aktiv'] = df['Aktiv'].replace({True: 'Ja', False: 'Nein'}) - df['Testdaten'] = df['Testdaten'].replace({True: 'Ja', False: 'Nein'}) - - cur.close() - conn.close() - - # Export Format - export_format = request.args.get('format', 'excel') - - # Audit-Log - log_audit('EXPORT', 'license', - additional_info=f"Export aller Lizenzen als {export_format.upper()}") - filename = f'lizenzen_export_{datetime.now(ZoneInfo("Europe/Berlin")).strftime("%Y%m%d_%H%M%S")}' - - if export_format == 'csv': - # CSV Export - output = io.StringIO() - df.to_csv(output, index=False, encoding='utf-8-sig', sep=';') - output.seek(0) - - return send_file( - io.BytesIO(output.getvalue().encode('utf-8-sig')), - mimetype='text/csv', - as_attachment=True, - download_name=f'{filename}.csv' - ) - else: - # Excel Export - output = io.BytesIO() - with pd.ExcelWriter(output, engine='openpyxl') as writer: - df.to_excel(writer, sheet_name='Lizenzen', index=False) - - # Formatierung - worksheet = writer.sheets['Lizenzen'] - for column in worksheet.columns: - max_length = 0 - column_letter = column[0].column_letter - for cell in column: - try: - if len(str(cell.value)) > max_length: - max_length = len(str(cell.value)) - except: - pass - adjusted_width = min(max_length + 2, 50) - worksheet.column_dimensions[column_letter].width = adjusted_width - - output.seek(0) - - return send_file( - output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=f'{filename}.xlsx' - ) - -@app.route("/export/audit") -@login_required -def export_audit(): - conn = get_connection() - cur = conn.cursor() - - # Holen der Filter-Parameter - filter_user = request.args.get('user', '') - filter_action = request.args.get('action', '') - filter_entity = request.args.get('entity', '') - export_format = request.args.get('format', 'excel') - - # SQL Query mit Filtern - query = """ - SELECT id, timestamp, username, action, entity_type, entity_id, - old_values, new_values, ip_address, user_agent, additional_info - FROM audit_log - WHERE 1=1 - """ - params = [] - - if filter_user: - query += " AND username ILIKE %s" - params.append(f'%{filter_user}%') - - if filter_action: - query += " AND action = %s" - params.append(filter_action) - - if filter_entity: - query += " AND entity_type = %s" - params.append(filter_entity) - - query += " ORDER BY timestamp DESC" - - cur.execute(query, params) - audit_logs = cur.fetchall() - cur.close() - conn.close() - - # Daten für Export vorbereiten - data = [] - for log in audit_logs: - action_text = { - 'CREATE': 'Erstellt', - 'UPDATE': 'Bearbeitet', - 'DELETE': 'Gelöscht', - 'LOGIN': 'Anmeldung', - 'LOGOUT': 'Abmeldung', - 'AUTO_LOGOUT': 'Auto-Logout', - 'EXPORT': 'Export', - 'GENERATE_KEY': 'Key generiert', - 'CREATE_BATCH': 'Batch erstellt', - 'BACKUP': 'Backup erstellt', - 'LOGIN_2FA_SUCCESS': '2FA-Anmeldung', - 'LOGIN_2FA_BACKUP': '2FA-Backup-Code', - 'LOGIN_2FA_FAILED': '2FA-Fehlgeschlagen', - 'LOGIN_BLOCKED': 'Login-Blockiert', - 'RESTORE': 'Wiederhergestellt', - 'PASSWORD_CHANGE': 'Passwort geändert', - '2FA_ENABLED': '2FA aktiviert', - '2FA_DISABLED': '2FA deaktiviert' - }.get(log[3], log[3]) - - data.append({ - 'ID': log[0], - 'Zeitstempel': log[1].strftime('%d.%m.%Y %H:%M:%S'), - 'Benutzer': log[2], - 'Aktion': action_text, - 'Entität': log[4], - 'Entität-ID': log[5] or '', - 'IP-Adresse': log[8] or '', - 'Zusatzinfo': log[10] or '' - }) - - # DataFrame erstellen - df = pd.DataFrame(data) - - # Timestamp für Dateiname - timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') - filename = f'audit_log_export_{timestamp}' - - # Audit Log für Export - log_audit('EXPORT', 'audit_log', - additional_info=f"{export_format.upper()} Export mit {len(data)} Einträgen") - - if export_format == 'csv': - # CSV Export - output = io.StringIO() - # UTF-8 BOM für Excel - output.write('\ufeff') - df.to_csv(output, index=False, sep=';', encoding='utf-8') - output.seek(0) - - return send_file( - io.BytesIO(output.getvalue().encode('utf-8')), - mimetype='text/csv;charset=utf-8', - as_attachment=True, - download_name=f'{filename}.csv' - ) - else: - # Excel Export - output = BytesIO() - with pd.ExcelWriter(output, engine='openpyxl') as writer: - df.to_excel(writer, index=False, sheet_name='Audit Log') - - # Spaltenbreiten anpassen - worksheet = writer.sheets['Audit Log'] - for idx, col in enumerate(df.columns): - max_length = max( - df[col].astype(str).map(len).max(), - len(col) - ) + 2 - worksheet.column_dimensions[get_column_letter(idx + 1)].width = min(max_length, 50) - - output.seek(0) - - return send_file( - output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=f'{filename}.xlsx' - ) - -@app.route("/export/customers") -@login_required -def export_customers(): - conn = get_connection() - cur = conn.cursor() - - # Check if test data should be included - include_test = request.args.get('include_test', 'false').lower() == 'true' - - # Build query based on test data filter - if include_test: - # Include all customers - query = """ - SELECT c.id, c.name, c.email, c.created_at, c.is_test, - COUNT(l.id) as total_licenses, - COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE THEN 1 END) as active_licenses, - COUNT(CASE WHEN l.valid_until < CURRENT_DATE THEN 1 END) as expired_licenses - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - GROUP BY c.id, c.name, c.email, c.created_at, c.is_test - ORDER BY c.id - """ - else: - # Exclude test customers and test licenses - query = """ - SELECT c.id, c.name, c.email, c.created_at, c.is_test, - COUNT(CASE WHEN l.is_test = FALSE THEN 1 END) as total_licenses, - COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE AND l.is_test = FALSE THEN 1 END) as active_licenses, - COUNT(CASE WHEN l.valid_until < CURRENT_DATE AND l.is_test = FALSE THEN 1 END) as expired_licenses - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - WHERE c.is_test = FALSE - GROUP BY c.id, c.name, c.email, c.created_at, c.is_test - ORDER BY c.id - """ - - cur.execute(query) - - # Spaltennamen - columns = ['ID', 'Name', 'E-Mail', 'Erstellt am', 'Testdaten', - 'Lizenzen gesamt', 'Aktive Lizenzen', 'Abgelaufene Lizenzen'] - - # Daten in DataFrame - data = cur.fetchall() - df = pd.DataFrame(data, columns=columns) - - # Datumsformatierung - df['Erstellt am'] = pd.to_datetime(df['Erstellt am']).dt.strftime('%d.%m.%Y %H:%M') - - # Testdaten formatting - df['Testdaten'] = df['Testdaten'].replace({True: 'Ja', False: 'Nein'}) - - cur.close() - conn.close() - - # Export Format - export_format = request.args.get('format', 'excel') - - # Audit-Log - log_audit('EXPORT', 'customer', - additional_info=f"Export aller Kunden als {export_format.upper()}") - filename = f'kunden_export_{datetime.now(ZoneInfo("Europe/Berlin")).strftime("%Y%m%d_%H%M%S")}' - - if export_format == 'csv': - # CSV Export - output = io.StringIO() - df.to_csv(output, index=False, encoding='utf-8-sig', sep=';') - output.seek(0) - - return send_file( - io.BytesIO(output.getvalue().encode('utf-8-sig')), - mimetype='text/csv', - as_attachment=True, - download_name=f'{filename}.csv' - ) - else: - # Excel Export - output = io.BytesIO() - with pd.ExcelWriter(output, engine='openpyxl') as writer: - df.to_excel(writer, sheet_name='Kunden', index=False) - - # Formatierung - worksheet = writer.sheets['Kunden'] - for column in worksheet.columns: - max_length = 0 - column_letter = column[0].column_letter - for cell in column: - try: - if len(str(cell.value)) > max_length: - max_length = len(str(cell.value)) - except: - pass - adjusted_width = min(max_length + 2, 50) - worksheet.column_dimensions[column_letter].width = adjusted_width - - output.seek(0) - - return send_file( - output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=f'{filename}.xlsx' - ) - -@app.route("/export/sessions") -@login_required -def export_sessions(): - conn = get_connection() - cur = conn.cursor() - - # Holen des Session-Typs (active oder ended) - session_type = request.args.get('type', 'active') - export_format = request.args.get('format', 'excel') - - # Daten je nach Typ abrufen - if session_type == 'active': - # Aktive Lizenz-Sessions - cur.execute(""" - SELECT s.id, l.license_key, c.name as customer_name, s.session_id, - s.started_at, s.last_heartbeat, - EXTRACT(EPOCH FROM (NOW() - s.started_at))::INT as duration_seconds, - s.ip_address, s.user_agent - FROM sessions s - JOIN licenses l ON s.license_id = l.id - JOIN customers c ON l.customer_id = c.id - WHERE s.is_active = true - ORDER BY s.last_heartbeat DESC - """) - sessions = cur.fetchall() - - # Daten für Export vorbereiten - data = [] - for sess in sessions: - duration = sess[6] - hours = duration // 3600 - minutes = (duration % 3600) // 60 - seconds = duration % 60 - - data.append({ - 'Session-ID': sess[0], - 'Lizenzschlüssel': sess[1], - 'Kunde': sess[2], - 'Session-ID (Tech)': sess[3], - 'Startzeit': sess[4].strftime('%d.%m.%Y %H:%M:%S'), - 'Letzte Aktivität': sess[5].strftime('%d.%m.%Y %H:%M:%S'), - 'Dauer': f"{hours}h {minutes}m {seconds}s", - 'IP-Adresse': sess[7], - 'Browser': sess[8] - }) - - sheet_name = 'Aktive Sessions' - filename_prefix = 'aktive_sessions' - else: - # Beendete Lizenz-Sessions - cur.execute(""" - SELECT s.id, l.license_key, c.name as customer_name, s.session_id, - s.started_at, s.ended_at, - EXTRACT(EPOCH FROM (s.ended_at - s.started_at))::INT as duration_seconds, - s.ip_address, s.user_agent - FROM sessions s - JOIN licenses l ON s.license_id = l.id - JOIN customers c ON l.customer_id = c.id - WHERE s.is_active = false AND s.ended_at IS NOT NULL - ORDER BY s.ended_at DESC - LIMIT 1000 - """) - sessions = cur.fetchall() - - # Daten für Export vorbereiten - data = [] - for sess in sessions: - duration = sess[6] if sess[6] else 0 - hours = duration // 3600 - minutes = (duration % 3600) // 60 - seconds = duration % 60 - - data.append({ - 'Session-ID': sess[0], - 'Lizenzschlüssel': sess[1], - 'Kunde': sess[2], - 'Session-ID (Tech)': sess[3], - 'Startzeit': sess[4].strftime('%d.%m.%Y %H:%M:%S'), - 'Endzeit': sess[5].strftime('%d.%m.%Y %H:%M:%S') if sess[5] else '', - 'Dauer': f"{hours}h {minutes}m {seconds}s", - 'IP-Adresse': sess[7], - 'Browser': sess[8] - }) - - sheet_name = 'Beendete Sessions' - filename_prefix = 'beendete_sessions' - - cur.close() - conn.close() - - # DataFrame erstellen - df = pd.DataFrame(data) - - # Timestamp für Dateiname - timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') - filename = f'{filename_prefix}_export_{timestamp}' - - # Audit Log für Export - log_audit('EXPORT', 'sessions', - additional_info=f"{export_format.upper()} Export von {session_type} Sessions mit {len(data)} Einträgen") - - if export_format == 'csv': - # CSV Export - output = io.StringIO() - # UTF-8 BOM für Excel - output.write('\ufeff') - df.to_csv(output, index=False, sep=';', encoding='utf-8') - output.seek(0) - - return send_file( - io.BytesIO(output.getvalue().encode('utf-8')), - mimetype='text/csv;charset=utf-8', - as_attachment=True, - download_name=f'{filename}.csv' - ) - else: - # Excel Export - output = BytesIO() - with pd.ExcelWriter(output, engine='openpyxl') as writer: - df.to_excel(writer, index=False, sheet_name=sheet_name) - - # Spaltenbreiten anpassen - worksheet = writer.sheets[sheet_name] - for idx, col in enumerate(df.columns): - max_length = max( - df[col].astype(str).map(len).max(), - len(col) - ) + 2 - worksheet.column_dimensions[get_column_letter(idx + 1)].width = min(max_length, 50) - - output.seek(0) - - return send_file( - output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=f'{filename}.xlsx' - ) - -@app.route("/export/resources") -@login_required -def export_resources(): - conn = get_connection() - cur = conn.cursor() - - # Holen der Filter-Parameter - filter_type = request.args.get('type', '') - filter_status = request.args.get('status', '') - search_query = request.args.get('search', '') - show_test = request.args.get('show_test', 'false').lower() == 'true' - export_format = request.args.get('format', 'excel') - - # SQL Query mit Filtern - query = """ - SELECT r.id, r.resource_type, r.resource_value, r.status, r.allocated_to_license, - r.created_at, r.status_changed_at, - l.license_key, c.name as customer_name, c.email as customer_email, - l.license_type - FROM resource_pools r - LEFT JOIN licenses l ON r.allocated_to_license = l.id - LEFT JOIN customers c ON l.customer_id = c.id - WHERE 1=1 - """ - params = [] - - # Filter für Testdaten - if not show_test: - query += " AND (r.is_test = false OR r.is_test IS NULL)" - - # Filter für Ressourcentyp - if filter_type: - query += " AND r.resource_type = %s" - params.append(filter_type) - - # Filter für Status - if filter_status: - query += " AND r.status = %s" - params.append(filter_status) - - # Suchfilter - if search_query: - query += " AND (r.resource_value ILIKE %s OR l.license_key ILIKE %s OR c.name ILIKE %s)" - params.extend([f'%{search_query}%', f'%{search_query}%', f'%{search_query}%']) - - query += " ORDER BY r.id DESC" - - cur.execute(query, params) - resources = cur.fetchall() - cur.close() - conn.close() - - # Daten für Export vorbereiten - data = [] - for res in resources: - status_text = { - 'available': 'Verfügbar', - 'allocated': 'Zugewiesen', - 'quarantine': 'Quarantäne' - }.get(res[3], res[3]) - - type_text = { - 'domain': 'Domain', - 'ipv4': 'IPv4', - 'phone': 'Telefon' - }.get(res[1], res[1]) - - data.append({ - 'ID': res[0], - 'Typ': type_text, - 'Ressource': res[2], - 'Status': status_text, - 'Lizenzschlüssel': res[7] or '', - 'Kunde': res[8] or '', - 'Kunden-Email': res[9] or '', - 'Lizenztyp': res[10] or '', - 'Erstellt am': res[5].strftime('%d.%m.%Y %H:%M:%S') if res[5] else '', - 'Zugewiesen am': res[6].strftime('%d.%m.%Y %H:%M:%S') if res[6] else '' - }) - - # DataFrame erstellen - df = pd.DataFrame(data) - - # Timestamp für Dateiname - timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') - filename = f'resources_export_{timestamp}' - - # Audit Log für Export - log_audit('EXPORT', 'resources', - additional_info=f"{export_format.upper()} Export mit {len(data)} Ressourcen") - - if export_format == 'csv': - # CSV Export - output = io.StringIO() - # UTF-8 BOM für Excel - output.write('\ufeff') - df.to_csv(output, index=False, sep=';', encoding='utf-8') - output.seek(0) - - return send_file( - io.BytesIO(output.getvalue().encode('utf-8')), - mimetype='text/csv;charset=utf-8', - as_attachment=True, - download_name=f'{filename}.csv' - ) - else: - # Excel Export - output = BytesIO() - with pd.ExcelWriter(output, engine='openpyxl') as writer: - df.to_excel(writer, index=False, sheet_name='Resources') - - # Spaltenbreiten anpassen - worksheet = writer.sheets['Resources'] - for idx, col in enumerate(df.columns): - max_length = max( - df[col].astype(str).map(len).max(), - len(col) - ) + 2 - worksheet.column_dimensions[get_column_letter(idx + 1)].width = min(max_length, 50) - - output.seek(0) - - return send_file( - output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=f'{filename}.xlsx' - ) - -@app.route("/audit") -@login_required -def audit_log(): - conn = get_connection() - cur = conn.cursor() - - # Parameter - filter_user = request.args.get('user', '').strip() - filter_action = request.args.get('action', '').strip() - filter_entity = request.args.get('entity', '').strip() - page = request.args.get('page', 1, type=int) - sort = request.args.get('sort', 'timestamp') - order = request.args.get('order', 'desc') - per_page = 50 - - # Whitelist für erlaubte Sortierfelder - allowed_sort_fields = { - 'timestamp': 'timestamp', - 'username': 'username', - 'action': 'action', - 'entity': 'entity_type', - 'ip': 'ip_address' - } - - # Validierung - if sort not in allowed_sort_fields: - sort = 'timestamp' - if order not in ['asc', 'desc']: - order = 'desc' - - sort_field = allowed_sort_fields[sort] - - # SQL Query mit optionalen Filtern - query = """ - SELECT id, timestamp, username, action, entity_type, entity_id, - old_values, new_values, ip_address, user_agent, additional_info - FROM audit_log - WHERE 1=1 - """ - - params = [] - - # Filter - if filter_user: - query += " AND LOWER(username) LIKE LOWER(%s)" - params.append(f'%{filter_user}%') - - if filter_action: - query += " AND action = %s" - params.append(filter_action) - - if filter_entity: - query += " AND entity_type = %s" - params.append(filter_entity) - - # Gesamtanzahl für Pagination - count_query = "SELECT COUNT(*) FROM (" + query + ") as count_table" - cur.execute(count_query, params) - total = cur.fetchone()[0] - - # Pagination - offset = (page - 1) * per_page - query += f" ORDER BY {sort_field} {order.upper()} LIMIT %s OFFSET %s" - params.extend([per_page, offset]) - - cur.execute(query, params) - logs = cur.fetchall() - - # JSON-Werte parsen - parsed_logs = [] - for log in logs: - parsed_log = list(log) - # old_values und new_values sind bereits Dictionaries (JSONB) - # Keine Konvertierung nötig - parsed_logs.append(parsed_log) - - # Pagination Info - total_pages = (total + per_page - 1) // per_page - - cur.close() - conn.close() - - return render_template("audit_log.html", - logs=parsed_logs, - filter_user=filter_user, - filter_action=filter_action, - filter_entity=filter_entity, - page=page, - total_pages=total_pages, - total=total, - sort=sort, - order=order, - username=session.get('username')) - -@app.route("/backups") -@login_required -def backups(): - """Zeigt die Backup-Historie an""" - conn = get_connection() - cur = conn.cursor() - - # Letztes erfolgreiches Backup für Dashboard - cur.execute(""" - SELECT created_at, filesize, duration_seconds - FROM backup_history - WHERE status = 'success' - ORDER BY created_at DESC - LIMIT 1 - """) - last_backup = cur.fetchone() - - # Alle Backups abrufen - cur.execute(""" - SELECT id, filename, filesize, backup_type, status, error_message, - created_at, created_by, tables_count, records_count, - duration_seconds, is_encrypted - FROM backup_history - ORDER BY created_at DESC - """) - backups = cur.fetchall() - - cur.close() - conn.close() - - return render_template("backups.html", - backups=backups, - last_backup=last_backup, - username=session.get('username')) - -@app.route("/backup/create", methods=["POST"]) -@login_required -def create_backup_route(): - """Erstellt ein manuelles Backup""" - username = session.get('username') - success, result = create_backup(backup_type="manual", created_by=username) - - if success: - return jsonify({ - 'success': True, - 'message': f'Backup erfolgreich erstellt: {result}' - }) - else: - return jsonify({ - 'success': False, - 'message': f'Backup fehlgeschlagen: {result}' - }), 500 - -@app.route("/backup/restore/", methods=["POST"]) -@login_required -def restore_backup_route(backup_id): - """Stellt ein Backup wieder her""" - encryption_key = request.form.get('encryption_key') - - success, message = restore_backup(backup_id, encryption_key) - - if success: - return jsonify({ - 'success': True, - 'message': message - }) - else: - return jsonify({ - 'success': False, - 'message': message - }), 500 - -@app.route("/backup/download/") -@login_required -def download_backup(backup_id): - """Lädt eine Backup-Datei herunter""" - conn = get_connection() - cur = conn.cursor() - - cur.execute(""" - SELECT filename, filepath - FROM backup_history - WHERE id = %s - """, (backup_id,)) - backup_info = cur.fetchone() - - cur.close() - conn.close() - - if not backup_info: - return "Backup nicht gefunden", 404 - - filename, filepath = backup_info - filepath = Path(filepath) - - if not filepath.exists(): - return "Backup-Datei nicht gefunden", 404 - - # Audit-Log - log_audit('DOWNLOAD', 'backup', backup_id, - additional_info=f"Backup heruntergeladen: {filename}") - - return send_file(filepath, as_attachment=True, download_name=filename) - -@app.route("/backup/delete/", methods=["DELETE"]) -@login_required -def delete_backup(backup_id): - """Löscht ein Backup""" - conn = get_connection() - cur = conn.cursor() - - try: - # Backup-Informationen abrufen - cur.execute(""" - SELECT filename, filepath - FROM backup_history - WHERE id = %s - """, (backup_id,)) - backup_info = cur.fetchone() - - if not backup_info: - return jsonify({ - 'success': False, - 'message': 'Backup nicht gefunden' - }), 404 - - filename, filepath = backup_info - filepath = Path(filepath) - - # Datei löschen, wenn sie existiert - if filepath.exists(): - filepath.unlink() - - # Aus Datenbank löschen - cur.execute(""" - DELETE FROM backup_history - WHERE id = %s - """, (backup_id,)) - - conn.commit() - - # Audit-Log - log_audit('DELETE', 'backup', backup_id, - additional_info=f"Backup gelöscht: {filename}") - - return jsonify({ - 'success': True, - 'message': f'Backup "{filename}" wurde erfolgreich gelöscht' - }) - - except Exception as e: - conn.rollback() - return jsonify({ - 'success': False, - 'message': f'Fehler beim Löschen des Backups: {str(e)}' - }), 500 - finally: - cur.close() - conn.close() - -@app.route("/security/blocked-ips") -@login_required -def blocked_ips(): - """Zeigt alle gesperrten IPs an""" - conn = get_connection() - cur = conn.cursor() - - cur.execute(""" - SELECT - ip_address, - attempt_count, - first_attempt, - last_attempt, - blocked_until, - last_username_tried, - last_error_message - FROM login_attempts - WHERE blocked_until IS NOT NULL - ORDER BY blocked_until DESC - """) - - blocked_ips_list = [] - for ip in cur.fetchall(): - blocked_ips_list.append({ - 'ip_address': ip[0], - 'attempt_count': ip[1], - 'first_attempt': ip[2].strftime('%d.%m.%Y %H:%M'), - 'last_attempt': ip[3].strftime('%d.%m.%Y %H:%M'), - 'blocked_until': ip[4].strftime('%d.%m.%Y %H:%M'), - 'is_active': ip[4] > datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None), - 'last_username': ip[5], - 'last_error': ip[6] - }) - - cur.close() - conn.close() - - return render_template("blocked_ips.html", - blocked_ips=blocked_ips_list, - username=session.get('username')) - -@app.route("/security/unblock-ip", methods=["POST"]) -@login_required -def unblock_ip(): - """Entsperrt eine IP-Adresse""" - ip_address = request.form.get('ip_address') - - if ip_address: - conn = get_connection() - cur = conn.cursor() - - cur.execute(""" - UPDATE login_attempts - SET blocked_until = NULL - WHERE ip_address = %s - """, (ip_address,)) - - conn.commit() - cur.close() - conn.close() - - # Audit-Log - log_audit('UNBLOCK_IP', 'security', - additional_info=f"IP {ip_address} manuell entsperrt") - - return redirect(url_for('blocked_ips')) - -@app.route("/security/clear-attempts", methods=["POST"]) -@login_required -def clear_attempts(): - """Löscht alle Login-Versuche für eine IP""" - ip_address = request.form.get('ip_address') - - if ip_address: - reset_login_attempts(ip_address) - - # Audit-Log - log_audit('CLEAR_ATTEMPTS', 'security', - additional_info=f"Login-Versuche für IP {ip_address} zurückgesetzt") - - return redirect(url_for('blocked_ips')) - -# API Endpoints for License Management -@app.route("/api/license//toggle", methods=["POST"]) -@login_required -def toggle_license_api(license_id): - """Toggle license active status via API""" - try: - data = request.get_json() - is_active = data.get('is_active', False) - - conn = get_connection() - cur = conn.cursor() - - # Update license status - cur.execute(""" - UPDATE licenses - SET is_active = %s - WHERE id = %s - """, (is_active, license_id)) - - conn.commit() - - # Log the action - log_audit('UPDATE', 'license', license_id, - new_values={'is_active': is_active}, - additional_info=f"Lizenz {'aktiviert' if is_active else 'deaktiviert'} via Toggle") - - cur.close() - conn.close() - - return jsonify({'success': True, 'message': 'Status erfolgreich geändert'}) - except Exception as e: - return jsonify({'success': False, 'message': str(e)}), 500 - -@app.route("/api/licenses/bulk-activate", methods=["POST"]) -@login_required -def bulk_activate_licenses(): - """Activate multiple licenses at once""" - try: - data = request.get_json() - license_ids = data.get('ids', []) - - if not license_ids: - return jsonify({'success': False, 'message': 'Keine Lizenzen ausgewählt'}), 400 - - conn = get_connection() - cur = conn.cursor() - - # Update all selected licenses (nur Live-Daten) - cur.execute(""" - UPDATE licenses - SET is_active = TRUE - WHERE id = ANY(%s) AND is_test = FALSE - """, (license_ids,)) - - affected_rows = cur.rowcount - conn.commit() - - # Log the bulk action - log_audit('BULK_UPDATE', 'licenses', None, - new_values={'is_active': True, 'count': affected_rows}, - additional_info=f"{affected_rows} Lizenzen aktiviert") - - cur.close() - conn.close() - - return jsonify({'success': True, 'message': f'{affected_rows} Lizenzen aktiviert'}) - except Exception as e: - return jsonify({'success': False, 'message': str(e)}), 500 - -@app.route("/api/licenses/bulk-deactivate", methods=["POST"]) -@login_required -def bulk_deactivate_licenses(): - """Deactivate multiple licenses at once""" - try: - data = request.get_json() - license_ids = data.get('ids', []) - - if not license_ids: - return jsonify({'success': False, 'message': 'Keine Lizenzen ausgewählt'}), 400 - - conn = get_connection() - cur = conn.cursor() - - # Update all selected licenses (nur Live-Daten) - cur.execute(""" - UPDATE licenses - SET is_active = FALSE - WHERE id = ANY(%s) AND is_test = FALSE - """, (license_ids,)) - - affected_rows = cur.rowcount - conn.commit() - - # Log the bulk action - log_audit('BULK_UPDATE', 'licenses', None, - new_values={'is_active': False, 'count': affected_rows}, - additional_info=f"{affected_rows} Lizenzen deaktiviert") - - cur.close() - conn.close() - - return jsonify({'success': True, 'message': f'{affected_rows} Lizenzen deaktiviert'}) - except Exception as e: - return jsonify({'success': False, 'message': str(e)}), 500 - -@app.route("/api/license//devices") -@login_required -def get_license_devices(license_id): - """Hole alle registrierten Geräte einer Lizenz""" - try: - conn = get_connection() - cur = conn.cursor() - - # Prüfe ob Lizenz existiert und hole device_limit - cur.execute(""" - SELECT device_limit FROM licenses WHERE id = %s - """, (license_id,)) - license_data = cur.fetchone() - - if not license_data: - return jsonify({'success': False, 'message': 'Lizenz nicht gefunden'}), 404 - - device_limit = license_data[0] - - # Hole alle Geräte für diese Lizenz - cur.execute(""" - SELECT id, hardware_id, device_name, operating_system, - first_seen, last_seen, is_active, ip_address - FROM device_registrations - WHERE license_id = %s - ORDER BY is_active DESC, last_seen DESC - """, (license_id,)) - - devices = [] - for row in cur.fetchall(): - devices.append({ - 'id': row[0], - 'hardware_id': row[1], - 'device_name': row[2] or 'Unbekanntes Gerät', - 'operating_system': row[3] or 'Unbekannt', - 'first_seen': row[4].strftime('%d.%m.%Y %H:%M') if row[4] else '', - 'last_seen': row[5].strftime('%d.%m.%Y %H:%M') if row[5] else '', - 'is_active': row[6], - 'ip_address': row[7] or '-' - }) - - cur.close() - conn.close() - - return jsonify({ - 'success': True, - 'devices': devices, - 'device_limit': device_limit, - 'active_count': sum(1 for d in devices if d['is_active']) - }) - - except Exception as e: - logging.error(f"Fehler beim Abrufen der Geräte: {str(e)}") - return jsonify({'success': False, 'message': 'Fehler beim Abrufen der Geräte'}), 500 - -@app.route("/api/license//register-device", methods=["POST"]) -def register_device(license_id): - """Registriere ein neues Gerät für eine Lizenz""" - try: - data = request.get_json() - hardware_id = data.get('hardware_id') - device_name = data.get('device_name', '') - operating_system = data.get('operating_system', '') - - if not hardware_id: - return jsonify({'success': False, 'message': 'Hardware-ID fehlt'}), 400 - - conn = get_connection() - cur = conn.cursor() - - # Prüfe ob Lizenz existiert und aktiv ist - cur.execute(""" - SELECT device_limit, is_active, valid_until - FROM licenses - WHERE id = %s - """, (license_id,)) - license_data = cur.fetchone() - - if not license_data: - return jsonify({'success': False, 'message': 'Lizenz nicht gefunden'}), 404 - - device_limit, is_active, valid_until = license_data - - # Prüfe ob Lizenz aktiv und gültig ist - if not is_active: - return jsonify({'success': False, 'message': 'Lizenz ist deaktiviert'}), 403 - - if valid_until < datetime.now(ZoneInfo("Europe/Berlin")).date(): - return jsonify({'success': False, 'message': 'Lizenz ist abgelaufen'}), 403 - - # Prüfe ob Gerät bereits registriert ist - cur.execute(""" - SELECT id, is_active FROM device_registrations - WHERE license_id = %s AND hardware_id = %s - """, (license_id, hardware_id)) - existing_device = cur.fetchone() - - if existing_device: - device_id, is_device_active = existing_device - if is_device_active: - # Gerät ist bereits aktiv, update last_seen - cur.execute(""" - UPDATE device_registrations - SET last_seen = CURRENT_TIMESTAMP, - ip_address = %s, - user_agent = %s - WHERE id = %s - """, (get_client_ip(), request.headers.get('User-Agent', ''), device_id)) - conn.commit() - return jsonify({'success': True, 'message': 'Gerät bereits registriert', 'device_id': device_id}) - else: - # Gerät war deaktiviert, prüfe ob wir es reaktivieren können - cur.execute(""" - SELECT COUNT(*) FROM device_registrations - WHERE license_id = %s AND is_active = TRUE - """, (license_id,)) - active_count = cur.fetchone()[0] - - if active_count >= device_limit: - return jsonify({'success': False, 'message': f'Gerätelimit erreicht ({device_limit} Geräte)'}), 403 - - # Reaktiviere das Gerät - cur.execute(""" - UPDATE device_registrations - SET is_active = TRUE, - last_seen = CURRENT_TIMESTAMP, - deactivated_at = NULL, - deactivated_by = NULL, - ip_address = %s, - user_agent = %s - WHERE id = %s - """, (get_client_ip(), request.headers.get('User-Agent', ''), device_id)) - conn.commit() - return jsonify({'success': True, 'message': 'Gerät reaktiviert', 'device_id': device_id}) - - # Neues Gerät - prüfe Gerätelimit - cur.execute(""" - SELECT COUNT(*) FROM device_registrations - WHERE license_id = %s AND is_active = TRUE - """, (license_id,)) - active_count = cur.fetchone()[0] - - if active_count >= device_limit: - return jsonify({'success': False, 'message': f'Gerätelimit erreicht ({device_limit} Geräte)'}), 403 - - # Registriere neues Gerät - cur.execute(""" - INSERT INTO device_registrations - (license_id, hardware_id, device_name, operating_system, ip_address, user_agent) - VALUES (%s, %s, %s, %s, %s, %s) - RETURNING id - """, (license_id, hardware_id, device_name, operating_system, - get_client_ip(), request.headers.get('User-Agent', ''))) - device_id = cur.fetchone()[0] - - conn.commit() - - # Audit Log - log_audit('DEVICE_REGISTER', 'device', device_id, - new_values={'license_id': license_id, 'hardware_id': hardware_id}) - - cur.close() - conn.close() - - return jsonify({'success': True, 'message': 'Gerät erfolgreich registriert', 'device_id': device_id}) - - except Exception as e: - logging.error(f"Fehler bei Geräte-Registrierung: {str(e)}") - return jsonify({'success': False, 'message': 'Fehler bei der Registrierung'}), 500 - -@app.route("/api/license//deactivate-device/", methods=["POST"]) -@login_required -def deactivate_device(license_id, device_id): - """Deaktiviere ein registriertes Gerät""" - try: - conn = get_connection() - cur = conn.cursor() - - # Prüfe ob das Gerät zu dieser Lizenz gehört - cur.execute(""" - SELECT id FROM device_registrations - WHERE id = %s AND license_id = %s AND is_active = TRUE - """, (device_id, license_id)) - - if not cur.fetchone(): - return jsonify({'success': False, 'message': 'Gerät nicht gefunden oder bereits deaktiviert'}), 404 - - # Deaktiviere das Gerät - cur.execute(""" - UPDATE device_registrations - SET is_active = FALSE, - deactivated_at = CURRENT_TIMESTAMP, - deactivated_by = %s - WHERE id = %s - """, (session['username'], device_id)) - - conn.commit() - - # Audit Log - log_audit('DEVICE_DEACTIVATE', 'device', device_id, - old_values={'is_active': True}, - new_values={'is_active': False}) - - cur.close() - conn.close() - - return jsonify({'success': True, 'message': 'Gerät erfolgreich deaktiviert'}) - - except Exception as e: - logging.error(f"Fehler beim Deaktivieren des Geräts: {str(e)}") - return jsonify({'success': False, 'message': 'Fehler beim Deaktivieren'}), 500 - -@app.route("/api/licenses/bulk-delete", methods=["POST"]) -@login_required -def bulk_delete_licenses(): - """Delete multiple licenses at once""" - try: - data = request.get_json() - license_ids = data.get('ids', []) - - if not license_ids: - return jsonify({'success': False, 'message': 'Keine Lizenzen ausgewählt'}), 400 - - conn = get_connection() - cur = conn.cursor() - - # Get license info for audit log (nur Live-Daten) - cur.execute(""" - SELECT license_key - FROM licenses - WHERE id = ANY(%s) AND is_test = FALSE - """, (license_ids,)) - license_keys = [row[0] for row in cur.fetchall()] - - # Delete all selected licenses (nur Live-Daten) - cur.execute(""" - DELETE FROM licenses - WHERE id = ANY(%s) AND is_test = FALSE - """, (license_ids,)) - - affected_rows = cur.rowcount - conn.commit() - - # Log the bulk action - log_audit('BULK_DELETE', 'licenses', None, - old_values={'license_keys': license_keys, 'count': affected_rows}, - additional_info=f"{affected_rows} Lizenzen gelöscht") - - cur.close() - conn.close() - - return jsonify({'success': True, 'message': f'{affected_rows} Lizenzen gelöscht'}) - except Exception as e: - return jsonify({'success': False, 'message': str(e)}), 500 - -# ===================== RESOURCE POOL MANAGEMENT ===================== - -@app.route('/resources') -@login_required -def resources(): - """Resource Pool Hauptübersicht""" - conn = get_connection() - cur = conn.cursor() - - # Prüfe ob Testdaten angezeigt werden sollen (gleiche Logik wie bei Kunden) - show_test = request.args.get('show_test', 'false').lower() == 'true' - - # Statistiken abrufen - cur.execute(""" - SELECT - resource_type, - COUNT(*) FILTER (WHERE status = 'available') as available, - COUNT(*) FILTER (WHERE status = 'allocated') as allocated, - COUNT(*) FILTER (WHERE status = 'quarantine') as quarantine, - COUNT(*) as total - FROM resource_pools - WHERE is_test = %s - GROUP BY resource_type - """, (show_test,)) - - stats = {} - for row in cur.fetchall(): - stats[row[0]] = { - 'available': row[1], - 'allocated': row[2], - 'quarantine': row[3], - 'total': row[4], - 'available_percent': round((row[1] / row[4] * 100) if row[4] > 0 else 0, 1) - } - - # Letzte Aktivitäten (gefiltert nach Test/Live) - cur.execute(""" - SELECT - rh.action, - rh.action_by, - rh.action_at, - rp.resource_type, - rp.resource_value, - rh.details - FROM resource_history rh - JOIN resource_pools rp ON rh.resource_id = rp.id - WHERE rp.is_test = %s - ORDER BY rh.action_at DESC - LIMIT 10 - """, (show_test,)) - recent_activities = cur.fetchall() - - # Ressourcen-Liste mit Pagination - page = request.args.get('page', 1, type=int) - per_page = 50 - offset = (page - 1) * per_page - - resource_type = request.args.get('type', '') - status_filter = request.args.get('status', '') - search = request.args.get('search', '') - - # Sortierung - sort_by = request.args.get('sort', 'id') - sort_order = request.args.get('order', 'desc') - - # Base Query - query = """ - SELECT - rp.id, - rp.resource_type, - rp.resource_value, - rp.status, - rp.allocated_to_license, - l.license_key, - c.name as customer_name, - rp.status_changed_at, - rp.quarantine_reason, - rp.quarantine_until, - c.id as customer_id - FROM resource_pools rp - LEFT JOIN licenses l ON rp.allocated_to_license = l.id - LEFT JOIN customers c ON l.customer_id = c.id - WHERE rp.is_test = %s - """ - params = [show_test] - - if resource_type: - query += " AND rp.resource_type = %s" - params.append(resource_type) - - if status_filter: - query += " AND rp.status = %s" - params.append(status_filter) - - if search: - query += " AND rp.resource_value ILIKE %s" - params.append(f'%{search}%') - - # Count total - count_query = f"SELECT COUNT(*) FROM ({query}) as cnt" - cur.execute(count_query, params) - total = cur.fetchone()[0] - total_pages = (total + per_page - 1) // per_page - - # Get paginated results with dynamic sorting - sort_column_map = { - 'id': 'rp.id', - 'type': 'rp.resource_type', - 'resource': 'rp.resource_value', - 'status': 'rp.status', - 'assigned': 'c.name', - 'changed': 'rp.status_changed_at' - } - - sort_column = sort_column_map.get(sort_by, 'rp.id') - sort_direction = 'ASC' if sort_order == 'asc' else 'DESC' - - query += f" ORDER BY {sort_column} {sort_direction} LIMIT %s OFFSET %s" - params.extend([per_page, offset]) - - cur.execute(query, params) - resources = cur.fetchall() - - cur.close() - conn.close() - - return render_template('resources.html', - stats=stats, - resources=resources, - recent_activities=recent_activities, - page=page, - total_pages=total_pages, - total=total, - resource_type=resource_type, - status_filter=status_filter, - search=search, - show_test=show_test, - sort_by=sort_by, - sort_order=sort_order, - datetime=datetime, - timedelta=timedelta) - -@app.route('/resources/add', methods=['GET', 'POST']) -@login_required -def add_resources(): - """Ressourcen zum Pool hinzufügen""" - # Hole show_test Parameter für die Anzeige - show_test = request.args.get('show_test', 'false').lower() == 'true' - - if request.method == 'POST': - resource_type = request.form.get('resource_type') - resources_text = request.form.get('resources_text', '') - is_test = request.form.get('is_test') == 'on' # Checkbox für Testdaten - - # Parse resources (one per line) - resources = [r.strip() for r in resources_text.split('\n') if r.strip()] - - if not resources: - flash('Keine Ressourcen angegeben', 'error') - return redirect(url_for('add_resources', show_test=show_test)) - - conn = get_connection() - cur = conn.cursor() - - added = 0 - duplicates = 0 - - for resource_value in resources: - try: - cur.execute(""" - INSERT INTO resource_pools (resource_type, resource_value, status_changed_by, is_test) - VALUES (%s, %s, %s, %s) - ON CONFLICT (resource_type, resource_value) DO NOTHING - """, (resource_type, resource_value, session['username'], is_test)) - - if cur.rowcount > 0: - added += 1 - # Get the inserted ID - cur.execute("SELECT id FROM resource_pools WHERE resource_type = %s AND resource_value = %s", - (resource_type, resource_value)) - resource_id = cur.fetchone()[0] - - # Log in history - cur.execute(""" - INSERT INTO resource_history (resource_id, action, action_by, ip_address) - VALUES (%s, 'created', %s, %s) - """, (resource_id, session['username'], get_client_ip())) - else: - duplicates += 1 - - except Exception as e: - app.logger.error(f"Error adding resource {resource_value}: {e}") - - conn.commit() - cur.close() - conn.close() - - log_audit('CREATE', 'resource_pool', None, - new_values={'type': resource_type, 'added': added, 'duplicates': duplicates, 'is_test': is_test}, - additional_info=f"{added} {'Test-' if is_test else ''}Ressourcen hinzugefügt, {duplicates} Duplikate übersprungen") - - flash(f'{added} {"Test-" if is_test else ""}Ressourcen hinzugefügt, {duplicates} Duplikate übersprungen', 'success') - return redirect(url_for('resources', show_test=show_test)) - - return render_template('add_resources.html', show_test=show_test) - -@app.route('/resources/quarantine/', methods=['POST']) -@login_required -def quarantine_resource(resource_id): - """Ressource in Quarantäne setzen""" - reason = request.form.get('reason', 'review') - until_date = request.form.get('until_date') - notes = request.form.get('notes', '') - - conn = get_connection() - cur = conn.cursor() - - # Get current resource info - cur.execute("SELECT resource_type, resource_value, status FROM resource_pools WHERE id = %s", (resource_id,)) - resource = cur.fetchone() - - if not resource: - flash('Ressource nicht gefunden', 'error') - return redirect(url_for('resources')) - - old_status = resource[2] - - # Update resource - cur.execute(""" - UPDATE resource_pools - SET status = 'quarantine', - quarantine_reason = %s, - quarantine_until = %s, - notes = %s, - status_changed_at = CURRENT_TIMESTAMP, - status_changed_by = %s - WHERE id = %s - """, (reason, until_date if until_date else None, notes, session['username'], resource_id)) - - # Log in history - cur.execute(""" - INSERT INTO resource_history (resource_id, action, action_by, ip_address, details) - VALUES (%s, 'quarantined', %s, %s, %s) - """, (resource_id, session['username'], get_client_ip(), - Json({'reason': reason, 'until': until_date, 'notes': notes, 'old_status': old_status}))) - - conn.commit() - cur.close() - conn.close() - - log_audit('UPDATE', 'resource', resource_id, - old_values={'status': old_status}, - new_values={'status': 'quarantine', 'reason': reason}, - additional_info=f"Ressource {resource[0]}: {resource[1]} in Quarantäne") - - flash('Ressource in Quarantäne gesetzt', 'success') - - # Redirect mit allen aktuellen Filtern - return redirect(url_for('resources', - show_test=request.args.get('show_test', request.form.get('show_test', 'false')), - type=request.args.get('type', request.form.get('type', '')), - status=request.args.get('status', request.form.get('status', '')), - search=request.args.get('search', request.form.get('search', '')))) - -@app.route('/resources/release', methods=['POST']) -@login_required -def release_resources(): - """Ressourcen aus Quarantäne freigeben""" - resource_ids = request.form.getlist('resource_ids') - - if not resource_ids: - flash('Keine Ressourcen ausgewählt', 'error') - return redirect(url_for('resources')) - - conn = get_connection() - cur = conn.cursor() - - released = 0 - for resource_id in resource_ids: - cur.execute(""" - UPDATE resource_pools - SET status = 'available', - quarantine_reason = NULL, - quarantine_until = NULL, - allocated_to_license = NULL, - status_changed_at = CURRENT_TIMESTAMP, - status_changed_by = %s - WHERE id = %s AND status = 'quarantine' - """, (session['username'], resource_id)) - - if cur.rowcount > 0: - released += 1 - # Log in history - cur.execute(""" - INSERT INTO resource_history (resource_id, action, action_by, ip_address) - VALUES (%s, 'released', %s, %s) - """, (resource_id, session['username'], get_client_ip())) - - conn.commit() - cur.close() - conn.close() - - log_audit('UPDATE', 'resource_pool', None, - new_values={'released': released}, - additional_info=f"{released} Ressourcen aus Quarantäne freigegeben") - - flash(f'{released} Ressourcen freigegeben', 'success') - - # Redirect mit allen aktuellen Filtern - return redirect(url_for('resources', - show_test=request.args.get('show_test', request.form.get('show_test', 'false')), - type=request.args.get('type', request.form.get('type', '')), - status=request.args.get('status', request.form.get('status', '')), - search=request.args.get('search', request.form.get('search', '')))) - -@app.route('/api/resources/allocate', methods=['POST']) -@login_required -def allocate_resources_api(): - """API für Ressourcen-Zuweisung bei Lizenzerstellung""" - data = request.json - license_id = data.get('license_id') - domain_count = data.get('domain_count', 1) - ipv4_count = data.get('ipv4_count', 1) - phone_count = data.get('phone_count', 1) - - conn = get_connection() - cur = conn.cursor() - - try: - allocated = {'domains': [], 'ipv4s': [], 'phones': []} - - # Allocate domains - if domain_count > 0: - cur.execute(""" - SELECT id, resource_value FROM resource_pools - WHERE resource_type = 'domain' AND status = 'available' - LIMIT %s FOR UPDATE - """, (domain_count,)) - domains = cur.fetchall() - - if len(domains) < domain_count: - raise ValueError(f"Nicht genügend Domains verfügbar (benötigt: {domain_count}, verfügbar: {len(domains)})") - - for domain_id, domain_value in domains: - # Update resource status - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', - allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, - status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], domain_id)) - - # Create assignment - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, domain_id, session['username'])) - - # Log history - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (domain_id, license_id, session['username'], get_client_ip())) - - allocated['domains'].append(domain_value) - - # Allocate IPv4s (similar logic) - if ipv4_count > 0: - cur.execute(""" - SELECT id, resource_value FROM resource_pools - WHERE resource_type = 'ipv4' AND status = 'available' - LIMIT %s FOR UPDATE - """, (ipv4_count,)) - ipv4s = cur.fetchall() - - if len(ipv4s) < ipv4_count: - raise ValueError(f"Nicht genügend IPv4-Adressen verfügbar") - - for ipv4_id, ipv4_value in ipv4s: - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', - allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, - status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], ipv4_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, ipv4_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (ipv4_id, license_id, session['username'], get_client_ip())) - - allocated['ipv4s'].append(ipv4_value) - - # Allocate phones (similar logic) - if phone_count > 0: - cur.execute(""" - SELECT id, resource_value FROM resource_pools - WHERE resource_type = 'phone' AND status = 'available' - LIMIT %s FOR UPDATE - """, (phone_count,)) - phones = cur.fetchall() - - if len(phones) < phone_count: - raise ValueError(f"Nicht genügend Telefonnummern verfügbar") - - for phone_id, phone_value in phones: - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', - allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, - status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], phone_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, phone_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (phone_id, license_id, session['username'], get_client_ip())) - - allocated['phones'].append(phone_value) - - # Update license resource counts - cur.execute(""" - UPDATE licenses - SET domain_count = %s, - ipv4_count = %s, - phone_count = %s - WHERE id = %s - """, (domain_count, ipv4_count, phone_count, license_id)) - - conn.commit() - cur.close() - conn.close() - - return jsonify({ - 'success': True, - 'allocated': allocated - }) - - except Exception as e: - conn.rollback() - cur.close() - conn.close() - return jsonify({ - 'success': False, - 'error': str(e) - }), 400 - -@app.route('/api/resources/check-availability', methods=['GET']) -@login_required -def check_resource_availability(): - """Prüft verfügbare Ressourcen""" - resource_type = request.args.get('type', '') - count = request.args.get('count', 10, type=int) - show_test = request.args.get('show_test', 'false').lower() == 'true' - - conn = get_connection() - cur = conn.cursor() - - if resource_type: - # Spezifische Ressourcen für einen Typ - cur.execute(""" - SELECT id, resource_value - FROM resource_pools - WHERE status = 'available' - AND resource_type = %s - AND is_test = %s - ORDER BY resource_value - LIMIT %s - """, (resource_type, show_test, count)) - - resources = [] - for row in cur.fetchall(): - resources.append({ - 'id': row[0], - 'value': row[1] - }) - - cur.close() - conn.close() - - return jsonify({ - 'available': resources, - 'type': resource_type, - 'count': len(resources) - }) - else: - # Zusammenfassung aller Typen - cur.execute(""" - SELECT - resource_type, - COUNT(*) as available - FROM resource_pools - WHERE status = 'available' - AND is_test = %s - GROUP BY resource_type - """, (show_test,)) - - availability = {} - for row in cur.fetchall(): - availability[row[0]] = row[1] - - cur.close() - conn.close() - - return jsonify(availability) - -@app.route('/api/global-search', methods=['GET']) -@login_required -def global_search(): - """Global search API endpoint for searching customers and licenses""" - query = request.args.get('q', '').strip() - - if not query or len(query) < 2: - return jsonify({'customers': [], 'licenses': []}) - - conn = get_connection() - cur = conn.cursor() - - # Search pattern with wildcards - search_pattern = f'%{query}%' - - # Search customers - cur.execute(""" - SELECT id, name, email, company_name - FROM customers - WHERE (LOWER(name) LIKE LOWER(%s) - OR LOWER(email) LIKE LOWER(%s) - OR LOWER(company_name) LIKE LOWER(%s)) - AND is_test = FALSE - ORDER BY name - LIMIT 5 - """, (search_pattern, search_pattern, search_pattern)) - - customers = [] - for row in cur.fetchall(): - customers.append({ - 'id': row[0], - 'name': row[1], - 'email': row[2], - 'company_name': row[3] - }) - - # Search licenses - cur.execute(""" - SELECT l.id, l.license_key, c.name as customer_name - FROM licenses l - JOIN customers c ON l.customer_id = c.id - WHERE LOWER(l.license_key) LIKE LOWER(%s) - AND l.is_test = FALSE - ORDER BY l.created_at DESC - LIMIT 5 - """, (search_pattern,)) - - licenses = [] - for row in cur.fetchall(): - licenses.append({ - 'id': row[0], - 'license_key': row[1], - 'customer_name': row[2] - }) - - cur.close() - conn.close() - - return jsonify({ - 'customers': customers, - 'licenses': licenses - }) - -@app.route('/resources/history/') -@login_required -def resource_history(resource_id): - """Zeigt die komplette Historie einer Ressource""" - conn = get_connection() - cur = conn.cursor() - - # Get complete resource info using named columns - cur.execute(""" - SELECT id, resource_type, resource_value, status, allocated_to_license, - status_changed_at, status_changed_by, quarantine_reason, - quarantine_until, created_at, notes - FROM resource_pools - WHERE id = %s - """, (resource_id,)) - row = cur.fetchone() - - if not row: - flash('Ressource nicht gefunden', 'error') - return redirect(url_for('resources')) - - # Create resource object with named attributes - resource = { - 'id': row[0], - 'resource_type': row[1], - 'resource_value': row[2], - 'status': row[3], - 'allocated_to_license': row[4], - 'status_changed_at': row[5], - 'status_changed_by': row[6], - 'quarantine_reason': row[7], - 'quarantine_until': row[8], - 'created_at': row[9], - 'notes': row[10] - } - - # Get license info if allocated - license_info = None - if resource['allocated_to_license']: - cur.execute("SELECT license_key FROM licenses WHERE id = %s", - (resource['allocated_to_license'],)) - lic = cur.fetchone() - if lic: - license_info = {'license_key': lic[0]} - - # Get history with named columns - cur.execute(""" - SELECT - rh.action, - rh.action_by, - rh.action_at, - rh.details, - rh.license_id, - rh.ip_address - FROM resource_history rh - WHERE rh.resource_id = %s - ORDER BY rh.action_at DESC - """, (resource_id,)) - - history = [] - for row in cur.fetchall(): - history.append({ - 'action': row[0], - 'action_by': row[1], - 'action_at': row[2], - 'details': row[3], - 'license_id': row[4], - 'ip_address': row[5] - }) - - cur.close() - conn.close() - - # Convert to object-like for template - class ResourceObj: - def __init__(self, data): - for key, value in data.items(): - setattr(self, key, value) - - resource_obj = ResourceObj(resource) - history_objs = [ResourceObj(h) for h in history] - - return render_template('resource_history.html', - resource=resource_obj, - license_info=license_info, - history=history_objs) - -@app.route('/resources/metrics') -@login_required -def resources_metrics(): - """Dashboard für Resource Metrics und Reports""" - conn = get_connection() - cur = conn.cursor() - - # Overall stats with fallback values - cur.execute(""" - SELECT - COUNT(DISTINCT resource_id) as total_resources, - COALESCE(AVG(performance_score), 0) as avg_performance, - COALESCE(SUM(cost), 0) as total_cost, - COALESCE(SUM(revenue), 0) as total_revenue, - COALESCE(SUM(issues_count), 0) as total_issues - FROM resource_metrics - WHERE metric_date >= CURRENT_DATE - INTERVAL '30 days' - """) - row = cur.fetchone() - - # Calculate ROI - roi = 0 - if row[2] > 0: # if total_cost > 0 - roi = row[3] / row[2] # revenue / cost - - stats = { - 'total_resources': row[0] or 0, - 'avg_performance': row[1] or 0, - 'total_cost': row[2] or 0, - 'total_revenue': row[3] or 0, - 'total_issues': row[4] or 0, - 'roi': roi - } - - # Performance by type - cur.execute(""" - SELECT - rp.resource_type, - COALESCE(AVG(rm.performance_score), 0) as avg_score, - COUNT(DISTINCT rp.id) as resource_count - FROM resource_pools rp - LEFT JOIN resource_metrics rm ON rp.id = rm.resource_id - AND rm.metric_date >= CURRENT_DATE - INTERVAL '30 days' - GROUP BY rp.resource_type - ORDER BY rp.resource_type - """) - performance_by_type = cur.fetchall() - - # Utilization data - cur.execute(""" - SELECT - resource_type, - COUNT(*) FILTER (WHERE status = 'allocated') as allocated, - COUNT(*) as total, - ROUND(COUNT(*) FILTER (WHERE status = 'allocated') * 100.0 / COUNT(*), 1) as allocated_percent - FROM resource_pools - GROUP BY resource_type - """) - utilization_rows = cur.fetchall() - utilization_data = [ - { - 'type': row[0].upper(), - 'allocated': row[1], - 'total': row[2], - 'allocated_percent': row[3] - } - for row in utilization_rows - ] - - # Top performing resources - cur.execute(""" - SELECT - rp.id, - rp.resource_type, - rp.resource_value, - COALESCE(AVG(rm.performance_score), 0) as avg_score, - COALESCE(SUM(rm.revenue), 0) as total_revenue, - COALESCE(SUM(rm.cost), 1) as total_cost, - CASE - WHEN COALESCE(SUM(rm.cost), 0) = 0 THEN 0 - ELSE COALESCE(SUM(rm.revenue), 0) / COALESCE(SUM(rm.cost), 1) - END as roi - FROM resource_pools rp - LEFT JOIN resource_metrics rm ON rp.id = rm.resource_id - AND rm.metric_date >= CURRENT_DATE - INTERVAL '30 days' - WHERE rp.status != 'quarantine' - GROUP BY rp.id, rp.resource_type, rp.resource_value - HAVING AVG(rm.performance_score) IS NOT NULL - ORDER BY avg_score DESC - LIMIT 10 - """) - top_rows = cur.fetchall() - top_performers = [ - { - 'id': row[0], - 'resource_type': row[1], - 'resource_value': row[2], - 'avg_score': row[3], - 'roi': row[6] - } - for row in top_rows - ] - - # Resources with issues - cur.execute(""" - SELECT - rp.id, - rp.resource_type, - rp.resource_value, - rp.status, - COALESCE(SUM(rm.issues_count), 0) as total_issues - FROM resource_pools rp - LEFT JOIN resource_metrics rm ON rp.id = rm.resource_id - AND rm.metric_date >= CURRENT_DATE - INTERVAL '30 days' - WHERE rm.issues_count > 0 OR rp.status = 'quarantine' - GROUP BY rp.id, rp.resource_type, rp.resource_value, rp.status - HAVING SUM(rm.issues_count) > 0 - ORDER BY total_issues DESC - LIMIT 10 - """) - problem_rows = cur.fetchall() - problem_resources = [ - { - 'id': row[0], - 'resource_type': row[1], - 'resource_value': row[2], - 'status': row[3], - 'total_issues': row[4] - } - for row in problem_rows - ] - - # Daily metrics for trend chart (last 30 days) - cur.execute(""" - SELECT - metric_date, - COALESCE(AVG(performance_score), 0) as avg_performance, - COALESCE(SUM(issues_count), 0) as total_issues - FROM resource_metrics - WHERE metric_date >= CURRENT_DATE - INTERVAL '30 days' - GROUP BY metric_date - ORDER BY metric_date - """) - daily_rows = cur.fetchall() - daily_metrics = [ - { - 'date': row[0].strftime('%d.%m'), - 'performance': float(row[1]), - 'issues': int(row[2]) - } - for row in daily_rows - ] - - cur.close() - conn.close() - - return render_template('resource_metrics.html', - stats=stats, - performance_by_type=performance_by_type, - utilization_data=utilization_data, - top_performers=top_performers, - problem_resources=problem_resources, - daily_metrics=daily_metrics) - -@app.route('/resources/report', methods=['GET']) -@login_required -def resources_report(): - """Generiert Ressourcen-Reports oder zeigt Report-Formular""" - # Prüfe ob Download angefordert wurde - if request.args.get('download') == 'true': - report_type = request.args.get('type', 'usage') - format_type = request.args.get('format', 'excel') - date_from = request.args.get('from', (datetime.now(ZoneInfo("Europe/Berlin")) - timedelta(days=30)).strftime('%Y-%m-%d')) - date_to = request.args.get('to', datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y-%m-%d')) - - conn = get_connection() - cur = conn.cursor() - - if report_type == 'usage': - # Auslastungsreport - query = """ - SELECT - rp.resource_type, - rp.resource_value, - rp.status, - COUNT(DISTINCT rh.license_id) as unique_licenses, - COUNT(rh.id) as total_allocations, - MIN(rh.action_at) as first_used, - MAX(rh.action_at) as last_used - FROM resource_pools rp - LEFT JOIN resource_history rh ON rp.id = rh.resource_id - AND rh.action = 'allocated' - AND rh.action_at BETWEEN %s AND %s - GROUP BY rp.id, rp.resource_type, rp.resource_value, rp.status - ORDER BY rp.resource_type, total_allocations DESC - """ - cur.execute(query, (date_from, date_to)) - columns = ['Typ', 'Ressource', 'Status', 'Unique Lizenzen', 'Gesamt Zuweisungen', 'Erste Nutzung', 'Letzte Nutzung'] - - elif report_type == 'performance': - # Performance-Report - query = """ - SELECT - rp.resource_type, - rp.resource_value, - AVG(rm.performance_score) as avg_performance, - SUM(rm.usage_count) as total_usage, - SUM(rm.revenue) as total_revenue, - SUM(rm.cost) as total_cost, - SUM(rm.revenue - rm.cost) as profit, - SUM(rm.issues_count) as total_issues - FROM resource_pools rp - JOIN resource_metrics rm ON rp.id = rm.resource_id - WHERE rm.metric_date BETWEEN %s AND %s - GROUP BY rp.id, rp.resource_type, rp.resource_value - ORDER BY profit DESC - """ - cur.execute(query, (date_from, date_to)) - columns = ['Typ', 'Ressource', 'Durchschn. Performance', 'Gesamt Nutzung', 'Umsatz', 'Kosten', 'Gewinn', 'Issues'] - - elif report_type == 'compliance': - # Compliance-Report - query = """ - SELECT - rh.action_at, - rh.action, - rh.action_by, - rp.resource_type, - rp.resource_value, - l.license_key, - c.name as customer_name, - rh.ip_address - FROM resource_history rh - JOIN resource_pools rp ON rh.resource_id = rp.id - LEFT JOIN licenses l ON rh.license_id = l.id - LEFT JOIN customers c ON l.customer_id = c.id - WHERE rh.action_at BETWEEN %s AND %s - ORDER BY rh.action_at DESC - """ - cur.execute(query, (date_from, date_to)) - columns = ['Zeit', 'Aktion', 'Von', 'Typ', 'Ressource', 'Lizenz', 'Kunde', 'IP-Adresse'] - - else: # inventory report - # Inventar-Report - query = """ - SELECT - resource_type, - COUNT(*) FILTER (WHERE status = 'available') as available, - COUNT(*) FILTER (WHERE status = 'allocated') as allocated, - COUNT(*) FILTER (WHERE status = 'quarantine') as quarantine, - COUNT(*) as total - FROM resource_pools - GROUP BY resource_type - ORDER BY resource_type - """ - cur.execute(query) - columns = ['Typ', 'Verfügbar', 'Zugeteilt', 'Quarantäne', 'Gesamt'] - - # Convert to DataFrame - data = cur.fetchall() - df = pd.DataFrame(data, columns=columns) - - cur.close() - conn.close() - - # Generate file - timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') - filename = f"resource_report_{report_type}_{timestamp}" - - if format_type == 'excel': - output = io.BytesIO() - with pd.ExcelWriter(output, engine='openpyxl') as writer: - df.to_excel(writer, sheet_name='Report', index=False) - - # Auto-adjust columns width - worksheet = writer.sheets['Report'] - for column in worksheet.columns: - max_length = 0 - column = [cell for cell in column] - for cell in column: - try: - if len(str(cell.value)) > max_length: - max_length = len(str(cell.value)) - except: - pass - adjusted_width = (max_length + 2) - worksheet.column_dimensions[column[0].column_letter].width = adjusted_width - - output.seek(0) - - log_audit('EXPORT', 'resource_report', None, - new_values={'type': report_type, 'format': 'excel', 'rows': len(df)}, - additional_info=f"Resource Report {report_type} exportiert") - - return send_file(output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=f'{filename}.xlsx') - - else: # CSV - output = io.StringIO() - df.to_csv(output, index=False, sep=';', encoding='utf-8-sig') - output.seek(0) - - log_audit('EXPORT', 'resource_report', None, - new_values={'type': report_type, 'format': 'csv', 'rows': len(df)}, - additional_info=f"Resource Report {report_type} exportiert") - - return send_file(io.BytesIO(output.getvalue().encode('utf-8-sig')), - mimetype='text/csv', - as_attachment=True, - download_name=f'{filename}.csv') - - # Wenn kein Download, zeige Report-Formular - return render_template('resource_report.html', - datetime=datetime, - timedelta=timedelta, - username=session.get('username')) - -if __name__ == "__main__": - app.run(host="0.0.0.0", port=5000) diff --git a/v2_adminpanel/app.py.backup_before_cleanup_20250616_223830 b/v2_adminpanel/app.py.backup_before_cleanup_20250616_223830 deleted file mode 100644 index 4e6204a..0000000 --- a/v2_adminpanel/app.py.backup_before_cleanup_20250616_223830 +++ /dev/null @@ -1,4475 +0,0 @@ -import os -import sys -import time -import json -import logging -import requests -import re -import random -import base64 -from io import BytesIO -from datetime import datetime, timedelta -from zoneinfo import ZoneInfo -from pathlib import Path - -# Add current directory to Python path to ensure modules can be imported -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) - -from flask import Flask, render_template, request, redirect, session, url_for, send_file, jsonify, flash -from flask_session import Session -from werkzeug.middleware.proxy_fix import ProxyFix -from apscheduler.schedulers.background import BackgroundScheduler -import pandas as pd -from psycopg2.extras import Json - -# Import our new modules -import config -from db import get_connection, get_db_connection, get_db_cursor, execute_query -from auth.decorators import login_required -from auth.password import hash_password, verify_password -from auth.two_factor import ( - generate_totp_secret, generate_qr_code, verify_totp, - generate_backup_codes, hash_backup_code, verify_backup_code -) -from auth.rate_limiting import ( - check_ip_blocked, record_failed_attempt, - reset_login_attempts, get_login_attempts -) -from utils.network import get_client_ip -from utils.audit import log_audit -from utils.license import generate_license_key, validate_license_key -from utils.backup import create_backup, restore_backup, get_or_create_encryption_key -from utils.export import ( - create_excel_export, format_datetime_for_export, - prepare_license_export_data, prepare_customer_export_data, - prepare_session_export_data, prepare_audit_export_data -) -from models import get_user_by_username - -app = Flask(__name__) -# Load configuration from config module -app.config['SECRET_KEY'] = config.SECRET_KEY -app.config['SESSION_TYPE'] = config.SESSION_TYPE -app.config['JSON_AS_ASCII'] = config.JSON_AS_ASCII -app.config['JSONIFY_MIMETYPE'] = config.JSONIFY_MIMETYPE -app.config['PERMANENT_SESSION_LIFETIME'] = config.PERMANENT_SESSION_LIFETIME -app.config['SESSION_COOKIE_HTTPONLY'] = config.SESSION_COOKIE_HTTPONLY -app.config['SESSION_COOKIE_SECURE'] = config.SESSION_COOKIE_SECURE -app.config['SESSION_COOKIE_SAMESITE'] = config.SESSION_COOKIE_SAMESITE -app.config['SESSION_COOKIE_NAME'] = config.SESSION_COOKIE_NAME -app.config['SESSION_REFRESH_EACH_REQUEST'] = config.SESSION_REFRESH_EACH_REQUEST -Session(app) - -# ProxyFix für korrekte IP-Adressen hinter Nginx -app.wsgi_app = ProxyFix( - app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1 -) - -# Configuration is now loaded from config module - -# Scheduler für automatische Backups -scheduler = BackgroundScheduler() -scheduler.start() - -# Logging konfigurieren -logging.basicConfig(level=logging.INFO) - -# Import and register blueprints -from routes.auth_routes import auth_bp -from routes.admin_routes import admin_bp -from routes.license_routes import license_bp -from routes.customer_routes import customer_bp -from routes.resource_routes import resource_bp -from routes.session_routes import session_bp -from routes.batch_routes import batch_bp -from routes.api_routes import api_bp -from routes.export_routes import export_bp - -# Register blueprints -app.register_blueprint(auth_bp) -app.register_blueprint(admin_bp) -app.register_blueprint(license_bp) -app.register_blueprint(customer_bp) -app.register_blueprint(resource_bp) -app.register_blueprint(session_bp) -app.register_blueprint(batch_bp) -app.register_blueprint(api_bp) -app.register_blueprint(export_bp) - - -# Scheduled Backup Job -def scheduled_backup(): - """Führt ein geplantes Backup aus""" - logging.info("Starte geplantes Backup...") - create_backup(backup_type="scheduled", created_by="scheduler") - -# Scheduler konfigurieren - täglich um 3:00 Uhr -scheduler.add_job( - scheduled_backup, - 'cron', - hour=config.SCHEDULER_CONFIG['backup_hour'], - minute=config.SCHEDULER_CONFIG['backup_minute'], - id='daily_backup', - replace_existing=True -) - - -def verify_recaptcha(response): - """Verifiziert die reCAPTCHA v2 Response mit Google""" - secret_key = config.RECAPTCHA_SECRET_KEY - - # Wenn kein Secret Key konfiguriert ist, CAPTCHA als bestanden werten (für PoC) - if not secret_key: - logging.warning("RECAPTCHA_SECRET_KEY nicht konfiguriert - CAPTCHA wird übersprungen") - return True - - # Verifizierung bei Google - try: - verify_url = 'https://www.google.com/recaptcha/api/siteverify' - data = { - 'secret': secret_key, - 'response': response - } - - # Timeout für Request setzen - r = requests.post(verify_url, data=data, timeout=5) - result = r.json() - - # Log für Debugging - if not result.get('success'): - logging.warning(f"reCAPTCHA Validierung fehlgeschlagen: {result.get('error-codes', [])}") - - return result.get('success', False) - - except requests.exceptions.RequestException as e: - logging.error(f"reCAPTCHA Verifizierung fehlgeschlagen: {str(e)}") - # Bei Netzwerkfehlern CAPTCHA als bestanden werten - return True - except Exception as e: - logging.error(f"Unerwarteter Fehler bei reCAPTCHA: {str(e)}") - return False - - -# @app.route("/login", methods=["GET", "POST"]) -# def login(): - # Timing-Attack Schutz - Start Zeit merken - # start_time = time.time() - - # IP-Adresse ermitteln - # ip_address = get_client_ip() - - # Prüfen ob IP gesperrt ist - # is_blocked, blocked_until = check_ip_blocked(ip_address) - # if is_blocked: - # time_remaining = (blocked_until - datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None)).total_seconds() / 3600 - # error_msg = f"IP GESPERRT! Noch {time_remaining:.1f} Stunden warten." - # return render_template("login.html", error=error_msg, error_type="blocked") - - # Anzahl bisheriger Versuche - # attempt_count = get_login_attempts(ip_address) - - # if request.method == "POST": - # username = request.form.get("username") - # password = request.form.get("password") - # captcha_response = request.form.get("g-recaptcha-response") - - # CAPTCHA-Prüfung nur wenn Keys konfiguriert sind - # recaptcha_site_key = config.RECAPTCHA_SITE_KEY - # if attempt_count >= config.CAPTCHA_AFTER_ATTEMPTS and recaptcha_site_key: - # if not captcha_response: - # Timing-Attack Schutz - # elapsed = time.time() - start_time - # if elapsed < 1.0: - # time.sleep(1.0 - elapsed) - # return render_template("login.html", - # error="CAPTCHA ERFORDERLICH!", - # show_captcha=True, - # error_type="captcha", - # attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - attempt_count), - # recaptcha_site_key=recaptcha_site_key) - - # CAPTCHA validieren - # if not verify_recaptcha(captcha_response): - # Timing-Attack Schutz - # elapsed = time.time() - start_time - # if elapsed < 1.0: - # time.sleep(1.0 - elapsed) - # return render_template("login.html", - # error="CAPTCHA UNGÜLTIG! Bitte erneut versuchen.", - # show_captcha=True, - # error_type="captcha", - # attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - attempt_count), - # recaptcha_site_key=recaptcha_site_key) - - # Check user in database first, fallback to env vars - # user = get_user_by_username(username) - # login_success = False - # needs_2fa = False - - # if user: - # Database user authentication - # if verify_password(password, user['password_hash']): - # login_success = True - # needs_2fa = user['totp_enabled'] - # else: - # Fallback to environment variables for backward compatibility - # if username in config.ADMIN_USERS and password == config.ADMIN_USERS[username]: - # login_success = True - - # Timing-Attack Schutz - Mindestens 1 Sekunde warten - # elapsed = time.time() - start_time - # if elapsed < 1.0: - # time.sleep(1.0 - elapsed) - - # if login_success: - # Erfolgreicher Login - # if needs_2fa: - # Store temporary session for 2FA verification - # session['temp_username'] = username - # session['temp_user_id'] = user['id'] - # session['awaiting_2fa'] = True - # return redirect(url_for('verify_2fa')) - # else: - # Complete login without 2FA - # session.permanent = True # Aktiviert das Timeout - # session['logged_in'] = True - # session['username'] = username - # session['user_id'] = user['id'] if user else None - # session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() - # reset_login_attempts(ip_address) - # log_audit('LOGIN_SUCCESS', 'user', - # additional_info=f"Erfolgreiche Anmeldung von IP: {ip_address}") - # return redirect(url_for('dashboard')) - # else: - # Fehlgeschlagener Login - # error_message = record_failed_attempt(ip_address, username) - # new_attempt_count = get_login_attempts(ip_address) - - # Prüfen ob jetzt gesperrt - # is_now_blocked, _ = check_ip_blocked(ip_address) - # if is_now_blocked: - # log_audit('LOGIN_BLOCKED', 'security', - # additional_info=f"IP {ip_address} wurde nach {config.MAX_LOGIN_ATTEMPTS} Versuchen gesperrt") - - # return render_template("login.html", - # error=error_message, - # show_captcha=(new_attempt_count >= config.CAPTCHA_AFTER_ATTEMPTS and config.RECAPTCHA_SITE_KEY), - # error_type="failed", - # attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - new_attempt_count), - # recaptcha_site_key=config.RECAPTCHA_SITE_KEY) - - # GET Request - # return render_template("login.html", - # show_captcha=(attempt_count >= config.CAPTCHA_AFTER_ATTEMPTS and config.RECAPTCHA_SITE_KEY), - # attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - attempt_count), - # recaptcha_site_key=config.RECAPTCHA_SITE_KEY) - -# @app.route("/logout") -# def logout(): - # username = session.get('username', 'unknown') - # log_audit('LOGOUT', 'user', additional_info=f"Abmeldung") - # session.pop('logged_in', None) - # session.pop('username', None) - # session.pop('user_id', None) - # session.pop('temp_username', None) - # session.pop('temp_user_id', None) - # session.pop('awaiting_2fa', None) - # return redirect(url_for('login')) - -# @app.route("/verify-2fa", methods=["GET", "POST"]) -# def verify_2fa(): - # if not session.get('awaiting_2fa'): - # return redirect(url_for('login')) - - # if request.method == "POST": - # token = request.form.get('token', '').replace(' ', '') - # username = session.get('temp_username') - # user_id = session.get('temp_user_id') - - # if not username or not user_id: - # flash('Session expired. Please login again.', 'error') - # return redirect(url_for('login')) - - # user = get_user_by_username(username) - # if not user: - # flash('User not found.', 'error') - # return redirect(url_for('login')) - - # Check if it's a backup code - # if len(token) == 8 and token.isupper(): - # Try backup code - # backup_codes = json.loads(user['backup_codes']) if user['backup_codes'] else [] - # if verify_backup_code(token, backup_codes): - # Remove used backup code - # code_hash = hash_backup_code(token) - # backup_codes.remove(code_hash) - - # conn = get_connection() - # cur = conn.cursor() - # cur.execute("UPDATE users SET backup_codes = %s WHERE id = %s", - # (json.dumps(backup_codes), user_id)) - # conn.commit() - # cur.close() - # conn.close() - - # Complete login - # session.permanent = True - # session['logged_in'] = True - # session['username'] = username - # session['user_id'] = user_id - # session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() - # session.pop('temp_username', None) - # session.pop('temp_user_id', None) - # session.pop('awaiting_2fa', None) - - # flash('Login successful using backup code. Please generate new backup codes.', 'warning') - # log_audit('LOGIN_2FA_BACKUP', 'user', additional_info=f"2FA login with backup code") - # return redirect(url_for('dashboard')) - # else: - # Try TOTP token - # if verify_totp(user['totp_secret'], token): - # Complete login - # session.permanent = True - # session['logged_in'] = True - # session['username'] = username - # session['user_id'] = user_id - # session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() - # session.pop('temp_username', None) - # session.pop('temp_user_id', None) - # session.pop('awaiting_2fa', None) - - # log_audit('LOGIN_2FA_SUCCESS', 'user', additional_info=f"2FA login successful") - # return redirect(url_for('dashboard')) - - # Failed verification - # conn = get_connection() - # cur = conn.cursor() - # cur.execute("UPDATE users SET failed_2fa_attempts = failed_2fa_attempts + 1, last_failed_2fa = %s WHERE id = %s", - # (datetime.now(), user_id)) - # conn.commit() - # cur.close() - # conn.close() - - # flash('Invalid authentication code. Please try again.', 'error') - # log_audit('LOGIN_2FA_FAILED', 'user', additional_info=f"Failed 2FA attempt") - - # return render_template('verify_2fa.html') - -# @app.route("/profile") -# @login_required -def profile(): - user = get_user_by_username(session['username']) - if not user: - # For environment-based users, redirect with message - flash('Bitte führen Sie das Migrations-Script aus, um Passwort-Änderung und 2FA zu aktivieren.', 'info') - return redirect(url_for('dashboard')) - return render_template('profile.html', user=user) - -# @app.route("/profile/change-password", methods=["POST"]) -# @login_required -def change_password(): - current_password = request.form.get('current_password') - new_password = request.form.get('new_password') - confirm_password = request.form.get('confirm_password') - - user = get_user_by_username(session['username']) - - # Verify current password - if not verify_password(current_password, user['password_hash']): - flash('Current password is incorrect.', 'error') - return redirect(url_for('profile')) - - # Check new password - if new_password != confirm_password: - flash('New passwords do not match.', 'error') - return redirect(url_for('profile')) - - if len(new_password) < 8: - flash('Password must be at least 8 characters long.', 'error') - return redirect(url_for('profile')) - - # Update password - new_hash = hash_password(new_password) - conn = get_connection() - cur = conn.cursor() - cur.execute("UPDATE users SET password_hash = %s, last_password_change = %s WHERE id = %s", - (new_hash, datetime.now(), user['id'])) - conn.commit() - cur.close() - conn.close() - - log_audit('PASSWORD_CHANGE', 'user', entity_id=user['id'], - additional_info="Password changed successfully") - flash('Password changed successfully.', 'success') - return redirect(url_for('profile')) - -# @app.route("/profile/setup-2fa") -# @login_required -def setup_2fa(): - user = get_user_by_username(session['username']) - - if user['totp_enabled']: - flash('2FA is already enabled for your account.', 'info') - return redirect(url_for('profile')) - - # Generate new TOTP secret - totp_secret = generate_totp_secret() - session['temp_totp_secret'] = totp_secret - - # Generate QR code - qr_code = generate_qr_code(user['username'], totp_secret) - - return render_template('setup_2fa.html', - totp_secret=totp_secret, - qr_code=qr_code) - -# @app.route("/profile/enable-2fa", methods=["POST"]) -# @login_required -def enable_2fa(): - token = request.form.get('token', '').replace(' ', '') - totp_secret = session.get('temp_totp_secret') - - if not totp_secret: - flash('2FA setup session expired. Please try again.', 'error') - return redirect(url_for('setup_2fa')) - - # Verify the token - if not verify_totp(totp_secret, token): - flash('Invalid authentication code. Please try again.', 'error') - return redirect(url_for('setup_2fa')) - - # Generate backup codes - backup_codes = generate_backup_codes() - hashed_codes = [hash_backup_code(code) for code in backup_codes] - - # Enable 2FA - conn = get_connection() - cur = conn.cursor() - cur.execute(""" - UPDATE users - SET totp_secret = %s, totp_enabled = TRUE, backup_codes = %s - WHERE username = %s - """, (totp_secret, json.dumps(hashed_codes), session['username'])) - conn.commit() - cur.close() - conn.close() - - session.pop('temp_totp_secret', None) - - log_audit('2FA_ENABLED', 'user', additional_info="2FA enabled successfully") - - # Show backup codes - return render_template('backup_codes.html', backup_codes=backup_codes) - -# @app.route("/profile/disable-2fa", methods=["POST"]) -# @login_required -def disable_2fa(): - password = request.form.get('password') - user = get_user_by_username(session['username']) - - # Verify password - if not verify_password(password, user['password_hash']): - flash('Incorrect password.', 'error') - return redirect(url_for('profile')) - - # Disable 2FA - conn = get_connection() - cur = conn.cursor() - cur.execute(""" - UPDATE users - SET totp_enabled = FALSE, totp_secret = NULL, backup_codes = NULL - WHERE username = %s - """, (session['username'],)) - conn.commit() - cur.close() - conn.close() - - log_audit('2FA_DISABLED', 'user', additional_info="2FA disabled") - flash('2FA has been disabled for your account.', 'success') - return redirect(url_for('profile')) - -# @app.route("/heartbeat", methods=['POST']) -# @login_required -def heartbeat(): - """Endpoint für Session Keep-Alive - aktualisiert last_activity""" - # Aktualisiere last_activity nur wenn explizit angefordert - session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() - # Force session save - session.modified = True - - return jsonify({ - 'status': 'ok', - 'last_activity': session['last_activity'], - 'username': session.get('username') - }) - -# @app.route("/api/generate-license-key", methods=['POST']) -# @login_required -def api_generate_key(): - """API Endpoint zur Generierung eines neuen Lizenzschlüssels""" - try: - # Lizenztyp aus Request holen (default: full) - data = request.get_json() or {} - license_type = data.get('type', 'full') - - # Key generieren - key = generate_license_key(license_type) - - # Prüfen ob Key bereits existiert (sehr unwahrscheinlich aber sicher ist sicher) - conn = get_connection() - cur = conn.cursor() - - # Wiederhole bis eindeutiger Key gefunden - attempts = 0 - while attempts < 10: # Max 10 Versuche - cur.execute("SELECT 1 FROM licenses WHERE license_key = %s", (key,)) - if not cur.fetchone(): - break # Key ist eindeutig - key = generate_license_key(license_type) - attempts += 1 - - cur.close() - conn.close() - - # Log für Audit - log_audit('GENERATE_KEY', 'license', - additional_info={'type': license_type, 'key': key}) - - return jsonify({ - 'success': True, - 'key': key, - 'type': license_type - }) - - except Exception as e: - logging.error(f"Fehler bei Key-Generierung: {str(e)}") - return jsonify({ - 'success': False, - 'error': 'Fehler bei der Key-Generierung' - }), 500 - -# @app.route("/api/customers", methods=['GET']) -# @login_required -def api_customers(): - """API Endpoint für die Kundensuche mit Select2""" - try: - # Suchparameter - search = request.args.get('q', '').strip() - page = request.args.get('page', 1, type=int) - per_page = 20 - customer_id = request.args.get('id', type=int) - - conn = get_connection() - cur = conn.cursor() - - # Einzelnen Kunden per ID abrufen - if customer_id: - cur.execute(""" - SELECT c.id, c.name, c.email, - COUNT(l.id) as license_count - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - WHERE c.id = %s - GROUP BY c.id, c.name, c.email - """, (customer_id,)) - - customer = cur.fetchone() - results = [] - if customer: - results.append({ - 'id': customer[0], - 'text': f"{customer[1]} ({customer[2]})", - 'name': customer[1], - 'email': customer[2], - 'license_count': customer[3] - }) - - cur.close() - conn.close() - - return jsonify({ - 'results': results, - 'pagination': {'more': False} - }) - - # SQL Query mit optionaler Suche - elif search: - cur.execute(""" - SELECT c.id, c.name, c.email, - COUNT(l.id) as license_count - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - WHERE LOWER(c.name) LIKE LOWER(%s) - OR LOWER(c.email) LIKE LOWER(%s) - GROUP BY c.id, c.name, c.email - ORDER BY c.name - LIMIT %s OFFSET %s - """, (f'%{search}%', f'%{search}%', per_page, (page - 1) * per_page)) - else: - cur.execute(""" - SELECT c.id, c.name, c.email, - COUNT(l.id) as license_count - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - GROUP BY c.id, c.name, c.email - ORDER BY c.name - LIMIT %s OFFSET %s - """, (per_page, (page - 1) * per_page)) - - customers = cur.fetchall() - - # Format für Select2 - results = [] - for customer in customers: - results.append({ - 'id': customer[0], - 'text': f"{customer[1]} - {customer[2]} ({customer[3]} Lizenzen)", - 'name': customer[1], - 'email': customer[2], - 'license_count': customer[3] - }) - - # Gesamtanzahl für Pagination - if search: - cur.execute(""" - SELECT COUNT(*) FROM customers - WHERE LOWER(name) LIKE LOWER(%s) - OR LOWER(email) LIKE LOWER(%s) - """, (f'%{search}%', f'%{search}%')) - else: - cur.execute("SELECT COUNT(*) FROM customers") - - total_count = cur.fetchone()[0] - - cur.close() - conn.close() - - # Select2 Response Format - return jsonify({ - 'results': results, - 'pagination': { - 'more': (page * per_page) < total_count - } - }) - - except Exception as e: - logging.error(f"Fehler bei Kundensuche: {str(e)}") - return jsonify({ - 'results': [], - 'error': 'Fehler bei der Kundensuche' - }), 500 - -# @app.route("/") -# @login_required -def dashboard(): - conn = get_connection() - cur = conn.cursor() - - # Statistiken abrufen - # Gesamtanzahl Kunden (ohne Testdaten) - cur.execute("SELECT COUNT(*) FROM customers WHERE is_test = FALSE") - total_customers = cur.fetchone()[0] - - # Gesamtanzahl Lizenzen (ohne Testdaten) - cur.execute("SELECT COUNT(*) FROM licenses WHERE is_test = FALSE") - total_licenses = cur.fetchone()[0] - - # Aktive Lizenzen (nicht abgelaufen und is_active = true, ohne Testdaten) - cur.execute(""" - SELECT COUNT(*) FROM licenses - WHERE valid_until >= CURRENT_DATE AND is_active = TRUE AND is_test = FALSE - """) - active_licenses = cur.fetchone()[0] - - # Aktive Sessions - cur.execute("SELECT COUNT(*) FROM sessions WHERE is_active = TRUE") - active_sessions_count = cur.fetchone()[0] - - # Abgelaufene Lizenzen (ohne Testdaten) - cur.execute(""" - SELECT COUNT(*) FROM licenses - WHERE valid_until < CURRENT_DATE AND is_test = FALSE - """) - expired_licenses = cur.fetchone()[0] - - # Deaktivierte Lizenzen (ohne Testdaten) - cur.execute(""" - SELECT COUNT(*) FROM licenses - WHERE is_active = FALSE AND is_test = FALSE - """) - inactive_licenses = cur.fetchone()[0] - - # Lizenzen die in den nächsten 30 Tagen ablaufen (ohne Testdaten) - cur.execute(""" - SELECT COUNT(*) FROM licenses - WHERE valid_until >= CURRENT_DATE - AND valid_until < CURRENT_DATE + INTERVAL '30 days' - AND is_active = TRUE - AND is_test = FALSE - """) - expiring_soon = cur.fetchone()[0] - - # Testlizenzen vs Vollversionen (ohne Testdaten) - cur.execute(""" - SELECT license_type, COUNT(*) - FROM licenses - WHERE is_test = FALSE - GROUP BY license_type - """) - license_types = dict(cur.fetchall()) - - # Anzahl Testdaten - cur.execute("SELECT COUNT(*) FROM licenses WHERE is_test = TRUE") - test_data_count = cur.fetchone()[0] - - # Anzahl Test-Kunden - cur.execute("SELECT COUNT(*) FROM customers WHERE is_test = TRUE") - test_customers_count = cur.fetchone()[0] - - # Anzahl Test-Ressourcen - cur.execute("SELECT COUNT(*) FROM resource_pools WHERE is_test = TRUE") - test_resources_count = cur.fetchone()[0] - - # Letzte 5 erstellten Lizenzen (ohne Testdaten) - cur.execute(""" - SELECT l.id, l.license_key, c.name, l.valid_until, - CASE - WHEN l.is_active = FALSE THEN 'deaktiviert' - WHEN l.valid_until < CURRENT_DATE THEN 'abgelaufen' - WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'läuft bald ab' - ELSE 'aktiv' - END as status - FROM licenses l - JOIN customers c ON l.customer_id = c.id - WHERE l.is_test = FALSE - ORDER BY l.id DESC - LIMIT 5 - """) - recent_licenses = cur.fetchall() - - # Bald ablaufende Lizenzen (nächste 30 Tage, ohne Testdaten) - cur.execute(""" - SELECT l.id, l.license_key, c.name, l.valid_until, - l.valid_until - CURRENT_DATE as days_left - FROM licenses l - JOIN customers c ON l.customer_id = c.id - WHERE l.valid_until >= CURRENT_DATE - AND l.valid_until < CURRENT_DATE + INTERVAL '30 days' - AND l.is_active = TRUE - AND l.is_test = FALSE - ORDER BY l.valid_until - LIMIT 10 - """) - expiring_licenses = cur.fetchall() - - # Letztes Backup - cur.execute(""" - SELECT created_at, filesize, duration_seconds, backup_type, status - FROM backup_history - ORDER BY created_at DESC - LIMIT 1 - """) - last_backup_info = cur.fetchone() - - # Sicherheitsstatistiken - # Gesperrte IPs - cur.execute(""" - SELECT COUNT(*) FROM login_attempts - WHERE blocked_until IS NOT NULL AND blocked_until > CURRENT_TIMESTAMP - """) - blocked_ips_count = cur.fetchone()[0] - - # Fehlversuche heute - cur.execute(""" - SELECT COALESCE(SUM(attempt_count), 0) FROM login_attempts - WHERE last_attempt::date = CURRENT_DATE - """) - failed_attempts_today = cur.fetchone()[0] - - # Letzte 5 Sicherheitsereignisse - cur.execute(""" - SELECT - la.ip_address, - la.attempt_count, - la.last_attempt, - la.blocked_until, - la.last_username_tried, - la.last_error_message - FROM login_attempts la - ORDER BY la.last_attempt DESC - LIMIT 5 - """) - recent_security_events = [] - for event in cur.fetchall(): - recent_security_events.append({ - 'ip_address': event[0], - 'attempt_count': event[1], - 'last_attempt': event[2].strftime('%d.%m %H:%M'), - 'blocked_until': event[3].strftime('%d.%m %H:%M') if event[3] and event[3] > datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None) else None, - 'username_tried': event[4], - 'error_message': event[5] - }) - - # Sicherheitslevel berechnen - if blocked_ips_count > 5 or failed_attempts_today > 50: - security_level = 'danger' - security_level_text = 'KRITISCH' - elif blocked_ips_count > 2 or failed_attempts_today > 20: - security_level = 'warning' - security_level_text = 'ERHÖHT' - else: - security_level = 'success' - security_level_text = 'NORMAL' - - # Resource Pool Statistiken (nur Live-Daten, keine Testdaten) - cur.execute(""" - SELECT - resource_type, - COUNT(*) FILTER (WHERE status = 'available') as available, - COUNT(*) FILTER (WHERE status = 'allocated') as allocated, - COUNT(*) FILTER (WHERE status = 'quarantine') as quarantine, - COUNT(*) as total - FROM resource_pools - WHERE is_test = FALSE - GROUP BY resource_type - """) - - resource_stats = {} - resource_warning = None - - for row in cur.fetchall(): - available_percent = round((row[1] / row[4] * 100) if row[4] > 0 else 0, 1) - resource_stats[row[0]] = { - 'available': row[1], - 'allocated': row[2], - 'quarantine': row[3], - 'total': row[4], - 'available_percent': available_percent, - 'warning_level': 'danger' if row[1] < 50 else 'warning' if row[1] < 100 else 'success' - } - - # Warnung bei niedrigem Bestand - if row[1] < 50: - if not resource_warning: - resource_warning = f"Niedriger Bestand bei {row[0].upper()}: nur noch {row[1]} verfügbar!" - else: - resource_warning += f" | {row[0].upper()}: {row[1]}" - - cur.close() - conn.close() - - stats = { - 'total_customers': total_customers, - 'total_licenses': total_licenses, - 'active_licenses': active_licenses, - 'expired_licenses': expired_licenses, - 'inactive_licenses': inactive_licenses, - 'expiring_soon': expiring_soon, - 'full_licenses': license_types.get('full', 0), - 'test_licenses': license_types.get('test', 0), - 'test_data_count': test_data_count, - 'test_customers_count': test_customers_count, - 'test_resources_count': test_resources_count, - 'recent_licenses': recent_licenses, - 'expiring_licenses': expiring_licenses, - 'active_sessions': active_sessions_count, - 'last_backup': last_backup_info, - # Sicherheitsstatistiken - 'blocked_ips_count': blocked_ips_count, - 'failed_attempts_today': failed_attempts_today, - 'recent_security_events': recent_security_events, - 'security_level': security_level, - 'security_level_text': security_level_text, - 'resource_stats': resource_stats - } - - return render_template("dashboard.html", - stats=stats, - resource_stats=resource_stats, - resource_warning=resource_warning, - username=session.get('username')) - -# @app.route("/create", methods=["GET", "POST"]) -# @login_required -def create_license(): - if request.method == "POST": - customer_id = request.form.get("customer_id") - license_key = request.form["license_key"].upper() # Immer Großbuchstaben - license_type = request.form["license_type"] - valid_from = request.form["valid_from"] - is_test = request.form.get("is_test") == "on" # Checkbox value - - # Berechne valid_until basierend auf Laufzeit - duration = int(request.form.get("duration", 1)) - duration_type = request.form.get("duration_type", "years") - - from datetime import datetime, timedelta - from dateutil.relativedelta import relativedelta - - start_date = datetime.strptime(valid_from, "%Y-%m-%d") - - if duration_type == "days": - end_date = start_date + timedelta(days=duration) - elif duration_type == "months": - end_date = start_date + relativedelta(months=duration) - else: # years - end_date = start_date + relativedelta(years=duration) - - # Ein Tag abziehen, da der Starttag mitgezählt wird - end_date = end_date - timedelta(days=1) - valid_until = end_date.strftime("%Y-%m-%d") - - # Validiere License Key Format - if not validate_license_key(license_key): - flash('Ungültiges License Key Format! Erwartet: AF-YYYYMMFT-XXXX-YYYY-ZZZZ', 'error') - return redirect(url_for('create_license')) - - # Resource counts - domain_count = int(request.form.get("domain_count", 1)) - ipv4_count = int(request.form.get("ipv4_count", 1)) - phone_count = int(request.form.get("phone_count", 1)) - device_limit = int(request.form.get("device_limit", 3)) - - conn = get_connection() - cur = conn.cursor() - - try: - # Prüfe ob neuer Kunde oder bestehender - if customer_id == "new": - # Neuer Kunde - name = request.form.get("customer_name") - email = request.form.get("email") - - if not name: - flash('Kundenname ist erforderlich!', 'error') - return redirect(url_for('create_license')) - - # Prüfe ob E-Mail bereits existiert - if email: - cur.execute("SELECT id, name FROM customers WHERE LOWER(email) = LOWER(%s)", (email,)) - existing = cur.fetchone() - if existing: - flash(f'E-Mail bereits vergeben für Kunde: {existing[1]}', 'error') - return redirect(url_for('create_license')) - - # Kunde einfügen (erbt Test-Status von Lizenz) - cur.execute(""" - INSERT INTO customers (name, email, is_test, created_at) - VALUES (%s, %s, %s, NOW()) - RETURNING id - """, (name, email, is_test)) - customer_id = cur.fetchone()[0] - customer_info = {'name': name, 'email': email, 'is_test': is_test} - - # Audit-Log für neuen Kunden - log_audit('CREATE', 'customer', customer_id, - new_values={'name': name, 'email': email, 'is_test': is_test}) - else: - # Bestehender Kunde - hole Infos für Audit-Log - cur.execute("SELECT name, email, is_test FROM customers WHERE id = %s", (customer_id,)) - customer_data = cur.fetchone() - if not customer_data: - flash('Kunde nicht gefunden!', 'error') - return redirect(url_for('create_license')) - customer_info = {'name': customer_data[0], 'email': customer_data[1]} - - # Wenn Kunde Test-Kunde ist, Lizenz auch als Test markieren - if customer_data[2]: # is_test des Kunden - is_test = True - - # Lizenz hinzufügen - cur.execute(""" - INSERT INTO licenses (license_key, customer_id, license_type, valid_from, valid_until, is_active, - domain_count, ipv4_count, phone_count, device_limit, is_test) - VALUES (%s, %s, %s, %s, %s, TRUE, %s, %s, %s, %s, %s) - RETURNING id - """, (license_key, customer_id, license_type, valid_from, valid_until, - domain_count, ipv4_count, phone_count, device_limit, is_test)) - license_id = cur.fetchone()[0] - - # Ressourcen zuweisen - try: - # Prüfe Verfügbarkeit - cur.execute(""" - SELECT - (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s) as domains, - (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s) as ipv4s, - (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s) as phones - """, (is_test, is_test, is_test)) - available = cur.fetchone() - - if available[0] < domain_count: - raise ValueError(f"Nicht genügend Domains verfügbar (benötigt: {domain_count}, verfügbar: {available[0]})") - if available[1] < ipv4_count: - raise ValueError(f"Nicht genügend IPv4-Adressen verfügbar (benötigt: {ipv4_count}, verfügbar: {available[1]})") - if available[2] < phone_count: - raise ValueError(f"Nicht genügend Telefonnummern verfügbar (benötigt: {phone_count}, verfügbar: {available[2]})") - - # Domains zuweisen - if domain_count > 0: - cur.execute(""" - SELECT id FROM resource_pools - WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s - LIMIT %s FOR UPDATE - """, (is_test, domain_count)) - for (resource_id,) in cur.fetchall(): - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], resource_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, resource_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (resource_id, license_id, session['username'], get_client_ip())) - - # IPv4s zuweisen - if ipv4_count > 0: - cur.execute(""" - SELECT id FROM resource_pools - WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s - LIMIT %s FOR UPDATE - """, (is_test, ipv4_count)) - for (resource_id,) in cur.fetchall(): - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], resource_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, resource_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (resource_id, license_id, session['username'], get_client_ip())) - - # Telefonnummern zuweisen - if phone_count > 0: - cur.execute(""" - SELECT id FROM resource_pools - WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s - LIMIT %s FOR UPDATE - """, (is_test, phone_count)) - for (resource_id,) in cur.fetchall(): - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], resource_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, resource_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (resource_id, license_id, session['username'], get_client_ip())) - - except ValueError as e: - conn.rollback() - flash(str(e), 'error') - return redirect(url_for('create_license')) - - conn.commit() - - # Audit-Log - log_audit('CREATE', 'license', license_id, - new_values={ - 'license_key': license_key, - 'customer_name': customer_info['name'], - 'customer_email': customer_info['email'], - 'license_type': license_type, - 'valid_from': valid_from, - 'valid_until': valid_until, - 'device_limit': device_limit, - 'is_test': is_test - }) - - flash(f'Lizenz {license_key} erfolgreich erstellt!', 'success') - - except Exception as e: - conn.rollback() - logging.error(f"Fehler beim Erstellen der Lizenz: {str(e)}") - flash('Fehler beim Erstellen der Lizenz!', 'error') - finally: - cur.close() - conn.close() - - # Preserve show_test parameter if present - redirect_url = "/create" - if request.args.get('show_test') == 'true': - redirect_url += "?show_test=true" - return redirect(redirect_url) - - # Unterstützung für vorausgewählten Kunden - preselected_customer_id = request.args.get('customer_id', type=int) - return render_template("index.html", username=session.get('username'), preselected_customer_id=preselected_customer_id) - -# @app.route("/batch", methods=["GET", "POST"]) -# @login_required -def batch_licenses(): - """Batch-Generierung mehrerer Lizenzen für einen Kunden""" - if request.method == "POST": - # Formulardaten - customer_id = request.form.get("customer_id") - license_type = request.form["license_type"] - quantity = int(request.form["quantity"]) - valid_from = request.form["valid_from"] - is_test = request.form.get("is_test") == "on" # Checkbox value - - # Berechne valid_until basierend auf Laufzeit - duration = int(request.form.get("duration", 1)) - duration_type = request.form.get("duration_type", "years") - - from datetime import datetime, timedelta - from dateutil.relativedelta import relativedelta - - start_date = datetime.strptime(valid_from, "%Y-%m-%d") - - if duration_type == "days": - end_date = start_date + timedelta(days=duration) - elif duration_type == "months": - end_date = start_date + relativedelta(months=duration) - else: # years - end_date = start_date + relativedelta(years=duration) - - # Ein Tag abziehen, da der Starttag mitgezählt wird - end_date = end_date - timedelta(days=1) - valid_until = end_date.strftime("%Y-%m-%d") - - # Resource counts - domain_count = int(request.form.get("domain_count", 1)) - ipv4_count = int(request.form.get("ipv4_count", 1)) - phone_count = int(request.form.get("phone_count", 1)) - device_limit = int(request.form.get("device_limit", 3)) - - # Sicherheitslimit - if quantity < 1 or quantity > 100: - flash('Anzahl muss zwischen 1 und 100 liegen!', 'error') - return redirect(url_for('batch_licenses')) - - conn = get_connection() - cur = conn.cursor() - - try: - # Prüfe ob neuer Kunde oder bestehender - if customer_id == "new": - # Neuer Kunde - name = request.form.get("customer_name") - email = request.form.get("email") - - if not name: - flash('Kundenname ist erforderlich!', 'error') - return redirect(url_for('batch_licenses')) - - # Prüfe ob E-Mail bereits existiert - if email: - cur.execute("SELECT id, name FROM customers WHERE LOWER(email) = LOWER(%s)", (email,)) - existing = cur.fetchone() - if existing: - flash(f'E-Mail bereits vergeben für Kunde: {existing[1]}', 'error') - return redirect(url_for('batch_licenses')) - - # Kunde einfügen (erbt Test-Status von Lizenz) - cur.execute(""" - INSERT INTO customers (name, email, is_test, created_at) - VALUES (%s, %s, %s, NOW()) - RETURNING id - """, (name, email, is_test)) - customer_id = cur.fetchone()[0] - - # Audit-Log für neuen Kunden - log_audit('CREATE', 'customer', customer_id, - new_values={'name': name, 'email': email, 'is_test': is_test}) - else: - # Bestehender Kunde - hole Infos - cur.execute("SELECT name, email, is_test FROM customers WHERE id = %s", (customer_id,)) - customer_data = cur.fetchone() - if not customer_data: - flash('Kunde nicht gefunden!', 'error') - return redirect(url_for('batch_licenses')) - name = customer_data[0] - email = customer_data[1] - - # Wenn Kunde Test-Kunde ist, Lizenzen auch als Test markieren - if customer_data[2]: # is_test des Kunden - is_test = True - - # Prüfe Ressourcen-Verfügbarkeit für gesamten Batch - total_domains_needed = domain_count * quantity - total_ipv4s_needed = ipv4_count * quantity - total_phones_needed = phone_count * quantity - - cur.execute(""" - SELECT - (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s) as domains, - (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s) as ipv4s, - (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s) as phones - """, (is_test, is_test, is_test)) - available = cur.fetchone() - - if available[0] < total_domains_needed: - flash(f"Nicht genügend Domains verfügbar (benötigt: {total_domains_needed}, verfügbar: {available[0]})", 'error') - return redirect(url_for('batch_licenses')) - if available[1] < total_ipv4s_needed: - flash(f"Nicht genügend IPv4-Adressen verfügbar (benötigt: {total_ipv4s_needed}, verfügbar: {available[1]})", 'error') - return redirect(url_for('batch_licenses')) - if available[2] < total_phones_needed: - flash(f"Nicht genügend Telefonnummern verfügbar (benötigt: {total_phones_needed}, verfügbar: {available[2]})", 'error') - return redirect(url_for('batch_licenses')) - - # Lizenzen generieren und speichern - generated_licenses = [] - for i in range(quantity): - # Eindeutigen Key generieren - attempts = 0 - while attempts < 10: - license_key = generate_license_key(license_type) - cur.execute("SELECT 1 FROM licenses WHERE license_key = %s", (license_key,)) - if not cur.fetchone(): - break - attempts += 1 - - # Lizenz einfügen - cur.execute(""" - INSERT INTO licenses (license_key, customer_id, license_type, is_test, - valid_from, valid_until, is_active, - domain_count, ipv4_count, phone_count, device_limit) - VALUES (%s, %s, %s, %s, %s, %s, true, %s, %s, %s, %s) - RETURNING id - """, (license_key, customer_id, license_type, is_test, valid_from, valid_until, - domain_count, ipv4_count, phone_count, device_limit)) - license_id = cur.fetchone()[0] - - # Ressourcen für diese Lizenz zuweisen - # Domains - if domain_count > 0: - cur.execute(""" - SELECT id FROM resource_pools - WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s - LIMIT %s FOR UPDATE - """, (is_test, domain_count)) - for (resource_id,) in cur.fetchall(): - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], resource_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, resource_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (resource_id, license_id, session['username'], get_client_ip())) - - # IPv4s - if ipv4_count > 0: - cur.execute(""" - SELECT id FROM resource_pools - WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s - LIMIT %s FOR UPDATE - """, (is_test, ipv4_count)) - for (resource_id,) in cur.fetchall(): - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], resource_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, resource_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (resource_id, license_id, session['username'], get_client_ip())) - - # Telefonnummern - if phone_count > 0: - cur.execute(""" - SELECT id FROM resource_pools - WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s - LIMIT %s FOR UPDATE - """, (is_test, phone_count)) - for (resource_id,) in cur.fetchall(): - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], resource_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, resource_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (resource_id, license_id, session['username'], get_client_ip())) - - generated_licenses.append({ - 'id': license_id, - 'key': license_key, - 'type': license_type - }) - - conn.commit() - - # Audit-Log - log_audit('CREATE_BATCH', 'license', - new_values={'customer': name, 'quantity': quantity, 'type': license_type}, - additional_info=f"Batch-Generierung von {quantity} Lizenzen") - - # Session für Export speichern - session['batch_export'] = { - 'customer': name, - 'email': email, - 'licenses': generated_licenses, - 'valid_from': valid_from, - 'valid_until': valid_until, - 'timestamp': datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() - } - - flash(f'{quantity} Lizenzen erfolgreich generiert!', 'success') - return render_template("batch_result.html", - customer=name, - email=email, - licenses=generated_licenses, - valid_from=valid_from, - valid_until=valid_until) - - except Exception as e: - conn.rollback() - logging.error(f"Fehler bei Batch-Generierung: {str(e)}") - flash('Fehler bei der Batch-Generierung!', 'error') - return redirect(url_for('batch_licenses')) - finally: - cur.close() - conn.close() - - # GET Request - return render_template("batch_form.html") - -# @app.route("/batch/export") -# @login_required -def export_batch(): - """Exportiert die zuletzt generierten Batch-Lizenzen""" - batch_data = session.get('batch_export') - if not batch_data: - flash('Keine Batch-Daten zum Exportieren vorhanden!', 'error') - return redirect(url_for('batch_licenses')) - - # CSV generieren - output = io.StringIO() - output.write('\ufeff') # UTF-8 BOM für Excel - - # Header - output.write(f"Kunde: {batch_data['customer']}\n") - output.write(f"E-Mail: {batch_data['email']}\n") - output.write(f"Generiert am: {datetime.fromisoformat(batch_data['timestamp']).strftime('%d.%m.%Y %H:%M')}\n") - output.write(f"Gültig von: {batch_data['valid_from']} bis {batch_data['valid_until']}\n") - output.write("\n") - output.write("Nr;Lizenzschlüssel;Typ\n") - - # Lizenzen - for i, license in enumerate(batch_data['licenses'], 1): - typ_text = "Vollversion" if license['type'] == 'full' else "Testversion" - output.write(f"{i};{license['key']};{typ_text}\n") - - output.seek(0) - - # Audit-Log - log_audit('EXPORT', 'batch_licenses', - additional_info=f"Export von {len(batch_data['licenses'])} Batch-Lizenzen") - - return send_file( - io.BytesIO(output.getvalue().encode('utf-8-sig')), - mimetype='text/csv', - as_attachment=True, - download_name=f"batch_licenses_{batch_data['customer'].replace(' ', '_')}_{datetime.now(ZoneInfo('Europe/Berlin')).strftime('%Y%m%d_%H%M%S')}.csv" - ) - -# @app.route("/licenses") -# @login_required -def licenses(): - # Redirect zur kombinierten Ansicht - return redirect("/customers-licenses") - -# @app.route("/license/edit/", methods=["GET", "POST"]) -# @login_required -def edit_license(license_id): - conn = get_connection() - cur = conn.cursor() - - if request.method == "POST": - # Alte Werte für Audit-Log abrufen - cur.execute(""" - SELECT license_key, license_type, valid_from, valid_until, is_active, is_test, device_limit - FROM licenses WHERE id = %s - """, (license_id,)) - old_license = cur.fetchone() - - # Update license - license_key = request.form["license_key"] - license_type = request.form["license_type"] - valid_from = request.form["valid_from"] - valid_until = request.form["valid_until"] - is_active = request.form.get("is_active") == "on" - is_test = request.form.get("is_test") == "on" - device_limit = int(request.form.get("device_limit", 3)) - - cur.execute(""" - UPDATE licenses - SET license_key = %s, license_type = %s, valid_from = %s, - valid_until = %s, is_active = %s, is_test = %s, device_limit = %s - WHERE id = %s - """, (license_key, license_type, valid_from, valid_until, is_active, is_test, device_limit, license_id)) - - conn.commit() - - # Audit-Log - log_audit('UPDATE', 'license', license_id, - old_values={ - 'license_key': old_license[0], - 'license_type': old_license[1], - 'valid_from': str(old_license[2]), - 'valid_until': str(old_license[3]), - 'is_active': old_license[4], - 'is_test': old_license[5], - 'device_limit': old_license[6] - }, - new_values={ - 'license_key': license_key, - 'license_type': license_type, - 'valid_from': valid_from, - 'valid_until': valid_until, - 'is_active': is_active, - 'is_test': is_test, - 'device_limit': device_limit - }) - - cur.close() - conn.close() - - # Redirect zurück zu customers-licenses mit beibehaltenen Parametern - redirect_url = "/customers-licenses" - - # Behalte show_test Parameter bei (aus Form oder GET-Parameter) - show_test = request.form.get('show_test') or request.args.get('show_test') - if show_test == 'true': - redirect_url += "?show_test=true" - - # Behalte customer_id bei wenn vorhanden - if request.referrer and 'customer_id=' in request.referrer: - import re - match = re.search(r'customer_id=(\d+)', request.referrer) - if match: - connector = "&" if "?" in redirect_url else "?" - redirect_url += f"{connector}customer_id={match.group(1)}" - - return redirect(redirect_url) - - # Get license data - cur.execute(""" - SELECT l.id, l.license_key, c.name, c.email, l.license_type, - l.valid_from, l.valid_until, l.is_active, c.id, l.is_test, l.device_limit - FROM licenses l - JOIN customers c ON l.customer_id = c.id - WHERE l.id = %s - """, (license_id,)) - - license = cur.fetchone() - cur.close() - conn.close() - - if not license: - return redirect("/licenses") - - return render_template("edit_license.html", license=license, username=session.get('username')) - -# @app.route("/license/delete/", methods=["POST"]) -# @login_required -def delete_license(license_id): - conn = get_connection() - cur = conn.cursor() - - # Lizenzdetails für Audit-Log abrufen - cur.execute(""" - SELECT l.license_key, c.name, l.license_type - FROM licenses l - JOIN customers c ON l.customer_id = c.id - WHERE l.id = %s - """, (license_id,)) - license_info = cur.fetchone() - - cur.execute("DELETE FROM licenses WHERE id = %s", (license_id,)) - - conn.commit() - - # Audit-Log - if license_info: - log_audit('DELETE', 'license', license_id, - old_values={ - 'license_key': license_info[0], - 'customer_name': license_info[1], - 'license_type': license_info[2] - }) - - cur.close() - conn.close() - - return redirect("/licenses") - -# @app.route("/customers") -# @login_required -def customers(): - # Redirect zur kombinierten Ansicht - return redirect("/customers-licenses") - -# @app.route("/customer/edit/", methods=["GET", "POST"]) -# @login_required -def edit_customer(customer_id): - conn = get_connection() - cur = conn.cursor() - - if request.method == "POST": - # Alte Werte für Audit-Log abrufen - cur.execute("SELECT name, email, is_test FROM customers WHERE id = %s", (customer_id,)) - old_customer = cur.fetchone() - - # Update customer - name = request.form["name"] - email = request.form["email"] - is_test = request.form.get("is_test") == "on" - - cur.execute(""" - UPDATE customers - SET name = %s, email = %s, is_test = %s - WHERE id = %s - """, (name, email, is_test, customer_id)) - - conn.commit() - - # Audit-Log - log_audit('UPDATE', 'customer', customer_id, - old_values={ - 'name': old_customer[0], - 'email': old_customer[1], - 'is_test': old_customer[2] - }, - new_values={ - 'name': name, - 'email': email, - 'is_test': is_test - }) - - cur.close() - conn.close() - - # Redirect zurück zu customers-licenses mit beibehaltenen Parametern - redirect_url = "/customers-licenses" - - # Behalte show_test Parameter bei (aus Form oder GET-Parameter) - show_test = request.form.get('show_test') or request.args.get('show_test') - if show_test == 'true': - redirect_url += "?show_test=true" - - # Behalte customer_id bei (immer der aktuelle Kunde) - connector = "&" if "?" in redirect_url else "?" - redirect_url += f"{connector}customer_id={customer_id}" - - return redirect(redirect_url) - - # Get customer data with licenses - cur.execute(""" - SELECT id, name, email, created_at, is_test FROM customers WHERE id = %s - """, (customer_id,)) - - customer = cur.fetchone() - if not customer: - cur.close() - conn.close() - return "Kunde nicht gefunden", 404 - - - # Get customer's licenses - cur.execute(""" - SELECT id, license_key, license_type, valid_from, valid_until, is_active - FROM licenses - WHERE customer_id = %s - ORDER BY valid_until DESC - """, (customer_id,)) - - licenses = cur.fetchall() - - cur.close() - conn.close() - - if not customer: - return redirect("/customers-licenses") - - return render_template("edit_customer.html", customer=customer, licenses=licenses, username=session.get('username')) - -# @app.route("/customer/create", methods=["GET", "POST"]) -# @login_required -def create_customer(): - """Erstellt einen neuen Kunden ohne Lizenz""" - if request.method == "POST": - name = request.form.get('name') - email = request.form.get('email') - is_test = request.form.get('is_test') == 'on' - - if not name or not email: - flash("Name und E-Mail sind Pflichtfelder!", "error") - return render_template("create_customer.html", username=session.get('username')) - - conn = get_connection() - cur = conn.cursor() - - try: - # Prüfen ob E-Mail bereits existiert - cur.execute("SELECT id, name FROM customers WHERE email = %s", (email,)) - existing = cur.fetchone() - if existing: - flash(f"Ein Kunde mit der E-Mail '{email}' existiert bereits: {existing[1]}", "error") - return render_template("create_customer.html", username=session.get('username')) - - # Kunde erstellen - cur.execute(""" - INSERT INTO customers (name, email, created_at, is_test) - VALUES (%s, %s, %s, %s) RETURNING id - """, (name, email, datetime.now(), is_test)) - - customer_id = cur.fetchone()[0] - conn.commit() - - # Audit-Log - log_audit('CREATE', 'customer', customer_id, - new_values={ - 'name': name, - 'email': email, - 'is_test': is_test - }) - - flash(f"Kunde '{name}' wurde erfolgreich angelegt!", "success") - return redirect(f"/customer/edit/{customer_id}") - - except Exception as e: - conn.rollback() - flash(f"Fehler beim Anlegen des Kunden: {str(e)}", "error") - return render_template("create_customer.html", username=session.get('username')) - finally: - cur.close() - conn.close() - - # GET Request - Formular anzeigen - return render_template("create_customer.html", username=session.get('username')) - -# @app.route("/customer/delete/", methods=["POST"]) -# @login_required -def delete_customer(customer_id): - conn = get_connection() - cur = conn.cursor() - - # Prüfen ob Kunde Lizenzen hat - cur.execute("SELECT COUNT(*) FROM licenses WHERE customer_id = %s", (customer_id,)) - license_count = cur.fetchone()[0] - - if license_count > 0: - # Kunde hat Lizenzen - nicht löschen - cur.close() - conn.close() - return redirect("/customers") - - # Kundendetails für Audit-Log abrufen - cur.execute("SELECT name, email FROM customers WHERE id = %s", (customer_id,)) - customer_info = cur.fetchone() - - # Kunde löschen wenn keine Lizenzen vorhanden - cur.execute("DELETE FROM customers WHERE id = %s", (customer_id,)) - - conn.commit() - - # Audit-Log - if customer_info: - log_audit('DELETE', 'customer', customer_id, - old_values={ - 'name': customer_info[0], - 'email': customer_info[1] - }) - - cur.close() - conn.close() - - return redirect("/customers") - -# @app.route("/customers-licenses") -# @login_required -def customers_licenses(): - """Kombinierte Ansicht für Kunden und deren Lizenzen""" - conn = get_connection() - cur = conn.cursor() - - # Hole alle Kunden mit Lizenzstatistiken (inkl. Testkunden wenn gewünscht) - show_test = request.args.get('show_test', 'false').lower() == 'true' - - query = """ - SELECT - c.id, - c.name, - c.email, - c.created_at, - COUNT(l.id) as total_licenses, - COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE THEN 1 END) as active_licenses, - COUNT(CASE WHEN l.valid_until < CURRENT_DATE THEN 1 END) as expired_licenses - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - """ - - if not show_test: - query += " WHERE c.is_test = FALSE" - - query += """ - GROUP BY c.id, c.name, c.email, c.created_at - ORDER BY c.name - """ - - cur.execute(query) - customers = cur.fetchall() - - # Hole ausgewählten Kunden nur wenn explizit in URL angegeben - selected_customer_id = request.args.get('customer_id', type=int) - licenses = [] - selected_customer = None - - if customers and selected_customer_id: - # Hole Daten des ausgewählten Kunden - for customer in customers: - if customer[0] == selected_customer_id: - selected_customer = customer - break - - # Hole Lizenzen des ausgewählten Kunden - if selected_customer: - cur.execute(""" - SELECT - l.id, - l.license_key, - l.license_type, - l.valid_from, - l.valid_until, - l.is_active, - CASE - WHEN l.is_active = FALSE THEN 'deaktiviert' - WHEN l.valid_until < CURRENT_DATE THEN 'abgelaufen' - WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'läuft bald ab' - ELSE 'aktiv' - END as status, - l.domain_count, - l.ipv4_count, - l.phone_count, - l.device_limit, - (SELECT COUNT(*) FROM device_registrations WHERE license_id = l.id AND is_active = TRUE) as active_devices, - -- Actual resource counts - (SELECT COUNT(*) FROM license_resources lr - JOIN resource_pools rp ON lr.resource_id = rp.id - WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'domain') as actual_domain_count, - (SELECT COUNT(*) FROM license_resources lr - JOIN resource_pools rp ON lr.resource_id = rp.id - WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'ipv4') as actual_ipv4_count, - (SELECT COUNT(*) FROM license_resources lr - JOIN resource_pools rp ON lr.resource_id = rp.id - WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'phone') as actual_phone_count - FROM licenses l - WHERE l.customer_id = %s - ORDER BY l.created_at DESC, l.id DESC - """, (selected_customer_id,)) - licenses = cur.fetchall() - - cur.close() - conn.close() - - return render_template("customers_licenses.html", - customers=customers, - selected_customer=selected_customer, - selected_customer_id=selected_customer_id, - licenses=licenses, - show_test=show_test) - -# @app.route("/api/customer//licenses") -# @login_required -def api_customer_licenses(customer_id): - """API-Endpoint für AJAX-Abruf der Lizenzen eines Kunden""" - conn = get_connection() - cur = conn.cursor() - - # Hole Lizenzen des Kunden - cur.execute(""" - SELECT - l.id, - l.license_key, - l.license_type, - l.valid_from, - l.valid_until, - l.is_active, - CASE - WHEN l.is_active = FALSE THEN 'deaktiviert' - WHEN l.valid_until < CURRENT_DATE THEN 'abgelaufen' - WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'läuft bald ab' - ELSE 'aktiv' - END as status, - l.domain_count, - l.ipv4_count, - l.phone_count, - l.device_limit, - (SELECT COUNT(*) FROM device_registrations WHERE license_id = l.id AND is_active = TRUE) as active_devices, - -- Actual resource counts - (SELECT COUNT(*) FROM license_resources lr - JOIN resource_pools rp ON lr.resource_id = rp.id - WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'domain') as actual_domain_count, - (SELECT COUNT(*) FROM license_resources lr - JOIN resource_pools rp ON lr.resource_id = rp.id - WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'ipv4') as actual_ipv4_count, - (SELECT COUNT(*) FROM license_resources lr - JOIN resource_pools rp ON lr.resource_id = rp.id - WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'phone') as actual_phone_count - FROM licenses l - WHERE l.customer_id = %s - ORDER BY l.created_at DESC, l.id DESC - """, (customer_id,)) - - licenses = [] - for row in cur.fetchall(): - license_id = row[0] - - # Hole die konkreten zugewiesenen Ressourcen für diese Lizenz - cur.execute(""" - SELECT rp.id, rp.resource_type, rp.resource_value, lr.assigned_at - FROM resource_pools rp - JOIN license_resources lr ON rp.id = lr.resource_id - WHERE lr.license_id = %s AND lr.is_active = true - ORDER BY rp.resource_type, rp.resource_value - """, (license_id,)) - - resources = { - 'domains': [], - 'ipv4s': [], - 'phones': [] - } - - for res_row in cur.fetchall(): - resource_info = { - 'id': res_row[0], - 'value': res_row[2], - 'assigned_at': res_row[3].strftime('%d.%m.%Y') if res_row[3] else '' - } - - if res_row[1] == 'domain': - resources['domains'].append(resource_info) - elif res_row[1] == 'ipv4': - resources['ipv4s'].append(resource_info) - elif res_row[1] == 'phone': - resources['phones'].append(resource_info) - - licenses.append({ - 'id': row[0], - 'license_key': row[1], - 'license_type': row[2], - 'valid_from': row[3].strftime('%d.%m.%Y') if row[3] else '', - 'valid_until': row[4].strftime('%d.%m.%Y') if row[4] else '', - 'is_active': row[5], - 'status': row[6], - 'domain_count': row[7], # limit - 'ipv4_count': row[8], # limit - 'phone_count': row[9], # limit - 'device_limit': row[10], - 'active_devices': row[11], - 'actual_domain_count': row[12], # actual count - 'actual_ipv4_count': row[13], # actual count - 'actual_phone_count': row[14], # actual count - 'resources': resources - }) - - cur.close() - conn.close() - - return jsonify({ - 'success': True, - 'licenses': licenses, - 'count': len(licenses) - }) - -# @app.route("/api/customer//quick-stats") -# @login_required -def api_customer_quick_stats(customer_id): - """API-Endpoint für Schnellstatistiken eines Kunden""" - conn = get_connection() - cur = conn.cursor() - - # Hole Kundenstatistiken - cur.execute(""" - SELECT - COUNT(l.id) as total_licenses, - COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE THEN 1 END) as active_licenses, - COUNT(CASE WHEN l.valid_until < CURRENT_DATE THEN 1 END) as expired_licenses, - COUNT(CASE WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' AND l.valid_until >= CURRENT_DATE THEN 1 END) as expiring_soon - FROM licenses l - WHERE l.customer_id = %s - """, (customer_id,)) - - stats = cur.fetchone() - - cur.close() - conn.close() - - return jsonify({ - 'success': True, - 'stats': { - 'total': stats[0], - 'active': stats[1], - 'expired': stats[2], - 'expiring_soon': stats[3] - } - }) - -# @app.route("/api/license//quick-edit", methods=['POST']) -# @login_required -def api_license_quick_edit(license_id): - """API-Endpoint für schnelle Lizenz-Bearbeitung""" - conn = get_connection() - cur = conn.cursor() - - try: - data = request.get_json() - - # Hole alte Werte für Audit-Log - cur.execute(""" - SELECT is_active, valid_until, license_type - FROM licenses WHERE id = %s - """, (license_id,)) - old_values = cur.fetchone() - - if not old_values: - return jsonify({'success': False, 'error': 'Lizenz nicht gefunden'}), 404 - - # Update-Felder vorbereiten - updates = [] - params = [] - new_values = {} - - if 'is_active' in data: - updates.append("is_active = %s") - params.append(data['is_active']) - new_values['is_active'] = data['is_active'] - - if 'valid_until' in data: - updates.append("valid_until = %s") - params.append(data['valid_until']) - new_values['valid_until'] = data['valid_until'] - - if 'license_type' in data: - updates.append("license_type = %s") - params.append(data['license_type']) - new_values['license_type'] = data['license_type'] - - if updates: - params.append(license_id) - cur.execute(f""" - UPDATE licenses - SET {', '.join(updates)} - WHERE id = %s - """, params) - - conn.commit() - - # Audit-Log - log_audit('UPDATE', 'license', license_id, - old_values={ - 'is_active': old_values[0], - 'valid_until': old_values[1].isoformat() if old_values[1] else None, - 'license_type': old_values[2] - }, - new_values=new_values) - - cur.close() - conn.close() - - return jsonify({'success': True}) - - except Exception as e: - conn.rollback() - cur.close() - conn.close() - return jsonify({'success': False, 'error': str(e)}), 500 - -# @app.route("/api/license//resources") -# @login_required -def api_license_resources(license_id): - """API-Endpoint für detaillierte Ressourcen-Informationen einer Lizenz""" - conn = get_connection() - cur = conn.cursor() - - try: - # Hole die konkreten zugewiesenen Ressourcen für diese Lizenz - cur.execute(""" - SELECT rp.id, rp.resource_type, rp.resource_value, lr.assigned_at - FROM resource_pools rp - JOIN license_resources lr ON rp.id = lr.resource_id - WHERE lr.license_id = %s AND lr.is_active = true - ORDER BY rp.resource_type, rp.resource_value - """, (license_id,)) - - resources = { - 'domains': [], - 'ipv4s': [], - 'phones': [] - } - - for row in cur.fetchall(): - resource_info = { - 'id': row[0], - 'value': row[2], - 'assigned_at': row[3].strftime('%d.%m.%Y') if row[3] else '' - } - - if row[1] == 'domain': - resources['domains'].append(resource_info) - elif row[1] == 'ipv4': - resources['ipv4s'].append(resource_info) - elif row[1] == 'phone': - resources['phones'].append(resource_info) - - cur.close() - conn.close() - - return jsonify({ - 'success': True, - 'resources': resources - }) - - except Exception as e: - cur.close() - conn.close() - return jsonify({'success': False, 'error': str(e)}), 500 - -# @app.route("/sessions") -# @login_required -def sessions(): - conn = get_connection() - cur = conn.cursor() - - # Sortierparameter - active_sort = request.args.get('active_sort', 'last_heartbeat') - active_order = request.args.get('active_order', 'desc') - ended_sort = request.args.get('ended_sort', 'ended_at') - ended_order = request.args.get('ended_order', 'desc') - - # Whitelist für erlaubte Sortierfelder - Aktive Sessions - active_sort_fields = { - 'customer': 'c.name', - 'license': 'l.license_key', - 'ip': 's.ip_address', - 'started': 's.started_at', - 'last_heartbeat': 's.last_heartbeat', - 'inactive': 'minutes_inactive' - } - - # Whitelist für erlaubte Sortierfelder - Beendete Sessions - ended_sort_fields = { - 'customer': 'c.name', - 'license': 'l.license_key', - 'ip': 's.ip_address', - 'started': 's.started_at', - 'ended_at': 's.ended_at', - 'duration': 'duration_minutes' - } - - # Validierung - if active_sort not in active_sort_fields: - active_sort = 'last_heartbeat' - if ended_sort not in ended_sort_fields: - ended_sort = 'ended_at' - if active_order not in ['asc', 'desc']: - active_order = 'desc' - if ended_order not in ['asc', 'desc']: - ended_order = 'desc' - - # Aktive Sessions abrufen - cur.execute(f""" - SELECT s.id, s.session_id, l.license_key, c.name, s.ip_address, - s.user_agent, s.started_at, s.last_heartbeat, - EXTRACT(EPOCH FROM (NOW() - s.last_heartbeat))/60 as minutes_inactive - FROM sessions s - JOIN licenses l ON s.license_id = l.id - JOIN customers c ON l.customer_id = c.id - WHERE s.is_active = TRUE - ORDER BY {active_sort_fields[active_sort]} {active_order.upper()} - """) - active_sessions = cur.fetchall() - - # Inaktive Sessions der letzten 24 Stunden - cur.execute(f""" - SELECT s.id, s.session_id, l.license_key, c.name, s.ip_address, - s.started_at, s.ended_at, - EXTRACT(EPOCH FROM (s.ended_at - s.started_at))/60 as duration_minutes - FROM sessions s - JOIN licenses l ON s.license_id = l.id - JOIN customers c ON l.customer_id = c.id - WHERE s.is_active = FALSE - AND s.ended_at > NOW() - INTERVAL '24 hours' - ORDER BY {ended_sort_fields[ended_sort]} {ended_order.upper()} - LIMIT 50 - """) - recent_sessions = cur.fetchall() - - cur.close() - conn.close() - - return render_template("sessions.html", - active_sessions=active_sessions, - recent_sessions=recent_sessions, - active_sort=active_sort, - active_order=active_order, - ended_sort=ended_sort, - ended_order=ended_order, - username=session.get('username')) - -# @app.route("/session/end/", methods=["POST"]) -# @login_required -def end_session(session_id): - conn = get_connection() - cur = conn.cursor() - - # Session beenden - cur.execute(""" - UPDATE sessions - SET is_active = FALSE, ended_at = NOW() - WHERE id = %s AND is_active = TRUE - """, (session_id,)) - - conn.commit() - cur.close() - conn.close() - - return redirect("/sessions") - -# @app.route("/export/licenses") -# @login_required -def export_licenses(): - conn = get_connection() - cur = conn.cursor() - - # Alle Lizenzen mit Kundeninformationen abrufen (ohne Testdaten, außer explizit gewünscht) - include_test = request.args.get('include_test', 'false').lower() == 'true' - customer_id = request.args.get('customer_id', type=int) - - query = """ - SELECT l.id, l.license_key, c.name as customer_name, c.email as customer_email, - l.license_type, l.valid_from, l.valid_until, l.is_active, l.is_test, - CASE - WHEN l.is_active = FALSE THEN 'Deaktiviert' - WHEN l.valid_until < CURRENT_DATE THEN 'Abgelaufen' - WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'Läuft bald ab' - ELSE 'Aktiv' - END as status - FROM licenses l - JOIN customers c ON l.customer_id = c.id - """ - - # Build WHERE clause - where_conditions = [] - params = [] - - if not include_test: - where_conditions.append("l.is_test = FALSE") - - if customer_id: - where_conditions.append("l.customer_id = %s") - params.append(customer_id) - - if where_conditions: - query += " WHERE " + " AND ".join(where_conditions) - - query += " ORDER BY l.id" - - cur.execute(query, params) - - # Spaltennamen - columns = ['ID', 'Lizenzschlüssel', 'Kunde', 'E-Mail', 'Typ', - 'Gültig von', 'Gültig bis', 'Aktiv', 'Testdaten', 'Status'] - - # Daten in DataFrame - data = cur.fetchall() - df = pd.DataFrame(data, columns=columns) - - # Datumsformatierung - df['Gültig von'] = pd.to_datetime(df['Gültig von']).dt.strftime('%d.%m.%Y') - df['Gültig bis'] = pd.to_datetime(df['Gültig bis']).dt.strftime('%d.%m.%Y') - - # Typ und Aktiv Status anpassen - df['Typ'] = df['Typ'].replace({'full': 'Vollversion', 'test': 'Testversion'}) - df['Aktiv'] = df['Aktiv'].replace({True: 'Ja', False: 'Nein'}) - df['Testdaten'] = df['Testdaten'].replace({True: 'Ja', False: 'Nein'}) - - cur.close() - conn.close() - - # Export Format - export_format = request.args.get('format', 'excel') - - # Audit-Log - log_audit('EXPORT', 'license', - additional_info=f"Export aller Lizenzen als {export_format.upper()}") - filename = f'lizenzen_export_{datetime.now(ZoneInfo("Europe/Berlin")).strftime("%Y%m%d_%H%M%S")}' - - if export_format == 'csv': - # CSV Export - output = io.StringIO() - df.to_csv(output, index=False, encoding='utf-8-sig', sep=';') - output.seek(0) - - return send_file( - io.BytesIO(output.getvalue().encode('utf-8-sig')), - mimetype='text/csv', - as_attachment=True, - download_name=f'{filename}.csv' - ) - else: - # Excel Export - output = io.BytesIO() - with pd.ExcelWriter(output, engine='openpyxl') as writer: - df.to_excel(writer, sheet_name='Lizenzen', index=False) - - # Formatierung - worksheet = writer.sheets['Lizenzen'] - for column in worksheet.columns: - max_length = 0 - column_letter = column[0].column_letter - for cell in column: - try: - if len(str(cell.value)) > max_length: - max_length = len(str(cell.value)) - except: - pass - adjusted_width = min(max_length + 2, 50) - worksheet.column_dimensions[column_letter].width = adjusted_width - - output.seek(0) - - return send_file( - output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=f'{filename}.xlsx' - ) - -# @app.route("/export/audit") -# @login_required -def export_audit(): - conn = get_connection() - cur = conn.cursor() - - # Holen der Filter-Parameter - filter_user = request.args.get('user', '') - filter_action = request.args.get('action', '') - filter_entity = request.args.get('entity', '') - export_format = request.args.get('format', 'excel') - - # SQL Query mit Filtern - query = """ - SELECT id, timestamp, username, action, entity_type, entity_id, - old_values, new_values, ip_address, user_agent, additional_info - FROM audit_log - WHERE 1=1 - """ - params = [] - - if filter_user: - query += " AND username ILIKE %s" - params.append(f'%{filter_user}%') - - if filter_action: - query += " AND action = %s" - params.append(filter_action) - - if filter_entity: - query += " AND entity_type = %s" - params.append(filter_entity) - - query += " ORDER BY timestamp DESC" - - cur.execute(query, params) - audit_logs = cur.fetchall() - cur.close() - conn.close() - - # Daten für Export vorbereiten - data = [] - for log in audit_logs: - action_text = { - 'CREATE': 'Erstellt', - 'UPDATE': 'Bearbeitet', - 'DELETE': 'Gelöscht', - 'LOGIN': 'Anmeldung', - 'LOGOUT': 'Abmeldung', - 'AUTO_LOGOUT': 'Auto-Logout', - 'EXPORT': 'Export', - 'GENERATE_KEY': 'Key generiert', - 'CREATE_BATCH': 'Batch erstellt', - 'BACKUP': 'Backup erstellt', - 'LOGIN_2FA_SUCCESS': '2FA-Anmeldung', - 'LOGIN_2FA_BACKUP': '2FA-Backup-Code', - 'LOGIN_2FA_FAILED': '2FA-Fehlgeschlagen', - 'LOGIN_BLOCKED': 'Login-Blockiert', - 'RESTORE': 'Wiederhergestellt', - 'PASSWORD_CHANGE': 'Passwort geändert', - '2FA_ENABLED': '2FA aktiviert', - '2FA_DISABLED': '2FA deaktiviert' - }.get(log[3], log[3]) - - data.append({ - 'ID': log[0], - 'Zeitstempel': log[1].strftime('%d.%m.%Y %H:%M:%S'), - 'Benutzer': log[2], - 'Aktion': action_text, - 'Entität': log[4], - 'Entität-ID': log[5] or '', - 'IP-Adresse': log[8] or '', - 'Zusatzinfo': log[10] or '' - }) - - # DataFrame erstellen - df = pd.DataFrame(data) - - # Timestamp für Dateiname - timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') - filename = f'audit_log_export_{timestamp}' - - # Audit Log für Export - log_audit('EXPORT', 'audit_log', - additional_info=f"{export_format.upper()} Export mit {len(data)} Einträgen") - - if export_format == 'csv': - # CSV Export - output = io.StringIO() - # UTF-8 BOM für Excel - output.write('\ufeff') - df.to_csv(output, index=False, sep=';', encoding='utf-8') - output.seek(0) - - return send_file( - io.BytesIO(output.getvalue().encode('utf-8')), - mimetype='text/csv;charset=utf-8', - as_attachment=True, - download_name=f'{filename}.csv' - ) - else: - # Excel Export - output = BytesIO() - with pd.ExcelWriter(output, engine='openpyxl') as writer: - df.to_excel(writer, index=False, sheet_name='Audit Log') - - # Spaltenbreiten anpassen - worksheet = writer.sheets['Audit Log'] - for idx, col in enumerate(df.columns): - max_length = max( - df[col].astype(str).map(len).max(), - len(col) - ) + 2 - worksheet.column_dimensions[get_column_letter(idx + 1)].width = min(max_length, 50) - - output.seek(0) - - return send_file( - output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=f'{filename}.xlsx' - ) - -# @app.route("/export/customers") -# @login_required -def export_customers(): - conn = get_connection() - cur = conn.cursor() - - # Check if test data should be included - include_test = request.args.get('include_test', 'false').lower() == 'true' - - # Build query based on test data filter - if include_test: - # Include all customers - query = """ - SELECT c.id, c.name, c.email, c.created_at, c.is_test, - COUNT(l.id) as total_licenses, - COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE THEN 1 END) as active_licenses, - COUNT(CASE WHEN l.valid_until < CURRENT_DATE THEN 1 END) as expired_licenses - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - GROUP BY c.id, c.name, c.email, c.created_at, c.is_test - ORDER BY c.id - """ - else: - # Exclude test customers and test licenses - query = """ - SELECT c.id, c.name, c.email, c.created_at, c.is_test, - COUNT(CASE WHEN l.is_test = FALSE THEN 1 END) as total_licenses, - COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE AND l.is_test = FALSE THEN 1 END) as active_licenses, - COUNT(CASE WHEN l.valid_until < CURRENT_DATE AND l.is_test = FALSE THEN 1 END) as expired_licenses - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - WHERE c.is_test = FALSE - GROUP BY c.id, c.name, c.email, c.created_at, c.is_test - ORDER BY c.id - """ - - cur.execute(query) - - # Spaltennamen - columns = ['ID', 'Name', 'E-Mail', 'Erstellt am', 'Testdaten', - 'Lizenzen gesamt', 'Aktive Lizenzen', 'Abgelaufene Lizenzen'] - - # Daten in DataFrame - data = cur.fetchall() - df = pd.DataFrame(data, columns=columns) - - # Datumsformatierung - df['Erstellt am'] = pd.to_datetime(df['Erstellt am']).dt.strftime('%d.%m.%Y %H:%M') - - # Testdaten formatting - df['Testdaten'] = df['Testdaten'].replace({True: 'Ja', False: 'Nein'}) - - cur.close() - conn.close() - - # Export Format - export_format = request.args.get('format', 'excel') - - # Audit-Log - log_audit('EXPORT', 'customer', - additional_info=f"Export aller Kunden als {export_format.upper()}") - filename = f'kunden_export_{datetime.now(ZoneInfo("Europe/Berlin")).strftime("%Y%m%d_%H%M%S")}' - - if export_format == 'csv': - # CSV Export - output = io.StringIO() - df.to_csv(output, index=False, encoding='utf-8-sig', sep=';') - output.seek(0) - - return send_file( - io.BytesIO(output.getvalue().encode('utf-8-sig')), - mimetype='text/csv', - as_attachment=True, - download_name=f'{filename}.csv' - ) - else: - # Excel Export - output = io.BytesIO() - with pd.ExcelWriter(output, engine='openpyxl') as writer: - df.to_excel(writer, sheet_name='Kunden', index=False) - - # Formatierung - worksheet = writer.sheets['Kunden'] - for column in worksheet.columns: - max_length = 0 - column_letter = column[0].column_letter - for cell in column: - try: - if len(str(cell.value)) > max_length: - max_length = len(str(cell.value)) - except: - pass - adjusted_width = min(max_length + 2, 50) - worksheet.column_dimensions[column_letter].width = adjusted_width - - output.seek(0) - - return send_file( - output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=f'{filename}.xlsx' - ) - -# @app.route("/export/sessions") -# @login_required -def export_sessions(): - conn = get_connection() - cur = conn.cursor() - - # Holen des Session-Typs (active oder ended) - session_type = request.args.get('type', 'active') - export_format = request.args.get('format', 'excel') - - # Daten je nach Typ abrufen - if session_type == 'active': - # Aktive Lizenz-Sessions - cur.execute(""" - SELECT s.id, l.license_key, c.name as customer_name, s.session_id, - s.started_at, s.last_heartbeat, - EXTRACT(EPOCH FROM (NOW() - s.started_at))::INT as duration_seconds, - s.ip_address, s.user_agent - FROM sessions s - JOIN licenses l ON s.license_id = l.id - JOIN customers c ON l.customer_id = c.id - WHERE s.is_active = true - ORDER BY s.last_heartbeat DESC - """) - sessions = cur.fetchall() - - # Daten für Export vorbereiten - data = [] - for sess in sessions: - duration = sess[6] - hours = duration // 3600 - minutes = (duration % 3600) // 60 - seconds = duration % 60 - - data.append({ - 'Session-ID': sess[0], - 'Lizenzschlüssel': sess[1], - 'Kunde': sess[2], - 'Session-ID (Tech)': sess[3], - 'Startzeit': sess[4].strftime('%d.%m.%Y %H:%M:%S'), - 'Letzte Aktivität': sess[5].strftime('%d.%m.%Y %H:%M:%S'), - 'Dauer': f"{hours}h {minutes}m {seconds}s", - 'IP-Adresse': sess[7], - 'Browser': sess[8] - }) - - sheet_name = 'Aktive Sessions' - filename_prefix = 'aktive_sessions' - else: - # Beendete Lizenz-Sessions - cur.execute(""" - SELECT s.id, l.license_key, c.name as customer_name, s.session_id, - s.started_at, s.ended_at, - EXTRACT(EPOCH FROM (s.ended_at - s.started_at))::INT as duration_seconds, - s.ip_address, s.user_agent - FROM sessions s - JOIN licenses l ON s.license_id = l.id - JOIN customers c ON l.customer_id = c.id - WHERE s.is_active = false AND s.ended_at IS NOT NULL - ORDER BY s.ended_at DESC - LIMIT 1000 - """) - sessions = cur.fetchall() - - # Daten für Export vorbereiten - data = [] - for sess in sessions: - duration = sess[6] if sess[6] else 0 - hours = duration // 3600 - minutes = (duration % 3600) // 60 - seconds = duration % 60 - - data.append({ - 'Session-ID': sess[0], - 'Lizenzschlüssel': sess[1], - 'Kunde': sess[2], - 'Session-ID (Tech)': sess[3], - 'Startzeit': sess[4].strftime('%d.%m.%Y %H:%M:%S'), - 'Endzeit': sess[5].strftime('%d.%m.%Y %H:%M:%S') if sess[5] else '', - 'Dauer': f"{hours}h {minutes}m {seconds}s", - 'IP-Adresse': sess[7], - 'Browser': sess[8] - }) - - sheet_name = 'Beendete Sessions' - filename_prefix = 'beendete_sessions' - - cur.close() - conn.close() - - # DataFrame erstellen - df = pd.DataFrame(data) - - # Timestamp für Dateiname - timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') - filename = f'{filename_prefix}_export_{timestamp}' - - # Audit Log für Export - log_audit('EXPORT', 'sessions', - additional_info=f"{export_format.upper()} Export von {session_type} Sessions mit {len(data)} Einträgen") - - if export_format == 'csv': - # CSV Export - output = io.StringIO() - # UTF-8 BOM für Excel - output.write('\ufeff') - df.to_csv(output, index=False, sep=';', encoding='utf-8') - output.seek(0) - - return send_file( - io.BytesIO(output.getvalue().encode('utf-8')), - mimetype='text/csv;charset=utf-8', - as_attachment=True, - download_name=f'{filename}.csv' - ) - else: - # Excel Export - output = BytesIO() - with pd.ExcelWriter(output, engine='openpyxl') as writer: - df.to_excel(writer, index=False, sheet_name=sheet_name) - - # Spaltenbreiten anpassen - worksheet = writer.sheets[sheet_name] - for idx, col in enumerate(df.columns): - max_length = max( - df[col].astype(str).map(len).max(), - len(col) - ) + 2 - worksheet.column_dimensions[get_column_letter(idx + 1)].width = min(max_length, 50) - - output.seek(0) - - return send_file( - output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=f'{filename}.xlsx' - ) - -# @app.route("/export/resources") -# @login_required -def export_resources(): - conn = get_connection() - cur = conn.cursor() - - # Holen der Filter-Parameter - filter_type = request.args.get('type', '') - filter_status = request.args.get('status', '') - search_query = request.args.get('search', '') - show_test = request.args.get('show_test', 'false').lower() == 'true' - export_format = request.args.get('format', 'excel') - - # SQL Query mit Filtern - query = """ - SELECT r.id, r.resource_type, r.resource_value, r.status, r.allocated_to_license, - r.created_at, r.status_changed_at, - l.license_key, c.name as customer_name, c.email as customer_email, - l.license_type - FROM resource_pools r - LEFT JOIN licenses l ON r.allocated_to_license = l.id - LEFT JOIN customers c ON l.customer_id = c.id - WHERE 1=1 - """ - params = [] - - # Filter für Testdaten - if not show_test: - query += " AND (r.is_test = false OR r.is_test IS NULL)" - - # Filter für Ressourcentyp - if filter_type: - query += " AND r.resource_type = %s" - params.append(filter_type) - - # Filter für Status - if filter_status: - query += " AND r.status = %s" - params.append(filter_status) - - # Suchfilter - if search_query: - query += " AND (r.resource_value ILIKE %s OR l.license_key ILIKE %s OR c.name ILIKE %s)" - params.extend([f'%{search_query}%', f'%{search_query}%', f'%{search_query}%']) - - query += " ORDER BY r.id DESC" - - cur.execute(query, params) - resources = cur.fetchall() - cur.close() - conn.close() - - # Daten für Export vorbereiten - data = [] - for res in resources: - status_text = { - 'available': 'Verfügbar', - 'allocated': 'Zugewiesen', - 'quarantine': 'Quarantäne' - }.get(res[3], res[3]) - - type_text = { - 'domain': 'Domain', - 'ipv4': 'IPv4', - 'phone': 'Telefon' - }.get(res[1], res[1]) - - data.append({ - 'ID': res[0], - 'Typ': type_text, - 'Ressource': res[2], - 'Status': status_text, - 'Lizenzschlüssel': res[7] or '', - 'Kunde': res[8] or '', - 'Kunden-Email': res[9] or '', - 'Lizenztyp': res[10] or '', - 'Erstellt am': res[5].strftime('%d.%m.%Y %H:%M:%S') if res[5] else '', - 'Zugewiesen am': res[6].strftime('%d.%m.%Y %H:%M:%S') if res[6] else '' - }) - - # DataFrame erstellen - df = pd.DataFrame(data) - - # Timestamp für Dateiname - timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') - filename = f'resources_export_{timestamp}' - - # Audit Log für Export - log_audit('EXPORT', 'resources', - additional_info=f"{export_format.upper()} Export mit {len(data)} Ressourcen") - - if export_format == 'csv': - # CSV Export - output = io.StringIO() - # UTF-8 BOM für Excel - output.write('\ufeff') - df.to_csv(output, index=False, sep=';', encoding='utf-8') - output.seek(0) - - return send_file( - io.BytesIO(output.getvalue().encode('utf-8')), - mimetype='text/csv;charset=utf-8', - as_attachment=True, - download_name=f'{filename}.csv' - ) - else: - # Excel Export - output = BytesIO() - with pd.ExcelWriter(output, engine='openpyxl') as writer: - df.to_excel(writer, index=False, sheet_name='Resources') - - # Spaltenbreiten anpassen - worksheet = writer.sheets['Resources'] - for idx, col in enumerate(df.columns): - max_length = max( - df[col].astype(str).map(len).max(), - len(col) - ) + 2 - worksheet.column_dimensions[get_column_letter(idx + 1)].width = min(max_length, 50) - - output.seek(0) - - return send_file( - output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=f'{filename}.xlsx' - ) - -# @app.route("/audit") -# @login_required -def audit_log(): - conn = get_connection() - cur = conn.cursor() - - # Parameter - filter_user = request.args.get('user', '').strip() - filter_action = request.args.get('action', '').strip() - filter_entity = request.args.get('entity', '').strip() - page = request.args.get('page', 1, type=int) - sort = request.args.get('sort', 'timestamp') - order = request.args.get('order', 'desc') - per_page = 50 - - # Whitelist für erlaubte Sortierfelder - allowed_sort_fields = { - 'timestamp': 'timestamp', - 'username': 'username', - 'action': 'action', - 'entity': 'entity_type', - 'ip': 'ip_address' - } - - # Validierung - if sort not in allowed_sort_fields: - sort = 'timestamp' - if order not in ['asc', 'desc']: - order = 'desc' - - sort_field = allowed_sort_fields[sort] - - # SQL Query mit optionalen Filtern - query = """ - SELECT id, timestamp, username, action, entity_type, entity_id, - old_values, new_values, ip_address, user_agent, additional_info - FROM audit_log - WHERE 1=1 - """ - - params = [] - - # Filter - if filter_user: - query += " AND LOWER(username) LIKE LOWER(%s)" - params.append(f'%{filter_user}%') - - if filter_action: - query += " AND action = %s" - params.append(filter_action) - - if filter_entity: - query += " AND entity_type = %s" - params.append(filter_entity) - - # Gesamtanzahl für Pagination - count_query = "SELECT COUNT(*) FROM (" + query + ") as count_table" - cur.execute(count_query, params) - total = cur.fetchone()[0] - - # Pagination - offset = (page - 1) * per_page - query += f" ORDER BY {sort_field} {order.upper()} LIMIT %s OFFSET %s" - params.extend([per_page, offset]) - - cur.execute(query, params) - logs = cur.fetchall() - - # JSON-Werte parsen - parsed_logs = [] - for log in logs: - parsed_log = list(log) - # old_values und new_values sind bereits Dictionaries (JSONB) - # Keine Konvertierung nötig - parsed_logs.append(parsed_log) - - # Pagination Info - total_pages = (total + per_page - 1) // per_page - - cur.close() - conn.close() - - return render_template("audit_log.html", - logs=parsed_logs, - filter_user=filter_user, - filter_action=filter_action, - filter_entity=filter_entity, - page=page, - total_pages=total_pages, - total=total, - sort=sort, - order=order, - username=session.get('username')) - -# @app.route("/backups") -# @login_required -def backups(): - """Zeigt die Backup-Historie an""" - conn = get_connection() - cur = conn.cursor() - - # Letztes erfolgreiches Backup für Dashboard - cur.execute(""" - SELECT created_at, filesize, duration_seconds - FROM backup_history - WHERE status = 'success' - ORDER BY created_at DESC - LIMIT 1 - """) - last_backup = cur.fetchone() - - # Alle Backups abrufen - cur.execute(""" - SELECT id, filename, filesize, backup_type, status, error_message, - created_at, created_by, tables_count, records_count, - duration_seconds, is_encrypted - FROM backup_history - ORDER BY created_at DESC - """) - backups = cur.fetchall() - - cur.close() - conn.close() - - return render_template("backups.html", - backups=backups, - last_backup=last_backup, - username=session.get('username')) - -# @app.route("/backup/create", methods=["POST"]) -# @login_required -def create_backup_route(): - """Erstellt ein manuelles Backup""" - username = session.get('username') - success, result = create_backup(backup_type="manual", created_by=username) - - if success: - return jsonify({ - 'success': True, - 'message': f'Backup erfolgreich erstellt: {result}' - }) - else: - return jsonify({ - 'success': False, - 'message': f'Backup fehlgeschlagen: {result}' - }), 500 - -# @app.route("/backup/restore/", methods=["POST"]) -# @login_required -def restore_backup_route(backup_id): - """Stellt ein Backup wieder her""" - encryption_key = request.form.get('encryption_key') - - success, message = restore_backup(backup_id, encryption_key) - - if success: - return jsonify({ - 'success': True, - 'message': message - }) - else: - return jsonify({ - 'success': False, - 'message': message - }), 500 - -# @app.route("/backup/download/") -# @login_required -def download_backup(backup_id): - """Lädt eine Backup-Datei herunter""" - conn = get_connection() - cur = conn.cursor() - - cur.execute(""" - SELECT filename, filepath - FROM backup_history - WHERE id = %s - """, (backup_id,)) - backup_info = cur.fetchone() - - cur.close() - conn.close() - - if not backup_info: - return "Backup nicht gefunden", 404 - - filename, filepath = backup_info - filepath = Path(filepath) - - if not filepath.exists(): - return "Backup-Datei nicht gefunden", 404 - - # Audit-Log - log_audit('DOWNLOAD', 'backup', backup_id, - additional_info=f"Backup heruntergeladen: {filename}") - - return send_file(filepath, as_attachment=True, download_name=filename) - -# @app.route("/backup/delete/", methods=["DELETE"]) -# @login_required -def delete_backup(backup_id): - """Löscht ein Backup""" - conn = get_connection() - cur = conn.cursor() - - try: - # Backup-Informationen abrufen - cur.execute(""" - SELECT filename, filepath - FROM backup_history - WHERE id = %s - """, (backup_id,)) - backup_info = cur.fetchone() - - if not backup_info: - return jsonify({ - 'success': False, - 'message': 'Backup nicht gefunden' - }), 404 - - filename, filepath = backup_info - filepath = Path(filepath) - - # Datei löschen, wenn sie existiert - if filepath.exists(): - filepath.unlink() - - # Aus Datenbank löschen - cur.execute(""" - DELETE FROM backup_history - WHERE id = %s - """, (backup_id,)) - - conn.commit() - - # Audit-Log - log_audit('DELETE', 'backup', backup_id, - additional_info=f"Backup gelöscht: {filename}") - - return jsonify({ - 'success': True, - 'message': f'Backup "{filename}" wurde erfolgreich gelöscht' - }) - - except Exception as e: - conn.rollback() - return jsonify({ - 'success': False, - 'message': f'Fehler beim Löschen des Backups: {str(e)}' - }), 500 - finally: - cur.close() - conn.close() - -# @app.route("/security/blocked-ips") -# @login_required -def blocked_ips(): - """Zeigt alle gesperrten IPs an""" - conn = get_connection() - cur = conn.cursor() - - cur.execute(""" - SELECT - ip_address, - attempt_count, - first_attempt, - last_attempt, - blocked_until, - last_username_tried, - last_error_message - FROM login_attempts - WHERE blocked_until IS NOT NULL - ORDER BY blocked_until DESC - """) - - blocked_ips_list = [] - for ip in cur.fetchall(): - blocked_ips_list.append({ - 'ip_address': ip[0], - 'attempt_count': ip[1], - 'first_attempt': ip[2].strftime('%d.%m.%Y %H:%M'), - 'last_attempt': ip[3].strftime('%d.%m.%Y %H:%M'), - 'blocked_until': ip[4].strftime('%d.%m.%Y %H:%M'), - 'is_active': ip[4] > datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None), - 'last_username': ip[5], - 'last_error': ip[6] - }) - - cur.close() - conn.close() - - return render_template("blocked_ips.html", - blocked_ips=blocked_ips_list, - username=session.get('username')) - -# @app.route("/security/unblock-ip", methods=["POST"]) -# @login_required -def unblock_ip(): - """Entsperrt eine IP-Adresse""" - ip_address = request.form.get('ip_address') - - if ip_address: - conn = get_connection() - cur = conn.cursor() - - cur.execute(""" - UPDATE login_attempts - SET blocked_until = NULL - WHERE ip_address = %s - """, (ip_address,)) - - conn.commit() - cur.close() - conn.close() - - # Audit-Log - log_audit('UNBLOCK_IP', 'security', - additional_info=f"IP {ip_address} manuell entsperrt") - - return redirect(url_for('blocked_ips')) - -# @app.route("/security/clear-attempts", methods=["POST"]) -# @login_required -def clear_attempts(): - """Löscht alle Login-Versuche für eine IP""" - ip_address = request.form.get('ip_address') - - if ip_address: - reset_login_attempts(ip_address) - - # Audit-Log - log_audit('CLEAR_ATTEMPTS', 'security', - additional_info=f"Login-Versuche für IP {ip_address} zurückgesetzt") - - return redirect(url_for('blocked_ips')) - -# API Endpoints for License Management -# @app.route("/api/license//toggle", methods=["POST"]) -# @login_required -def toggle_license_api(license_id): - """Toggle license active status via API""" - try: - data = request.get_json() - is_active = data.get('is_active', False) - - conn = get_connection() - cur = conn.cursor() - - # Update license status - cur.execute(""" - UPDATE licenses - SET is_active = %s - WHERE id = %s - """, (is_active, license_id)) - - conn.commit() - - # Log the action - log_audit('UPDATE', 'license', license_id, - new_values={'is_active': is_active}, - additional_info=f"Lizenz {'aktiviert' if is_active else 'deaktiviert'} via Toggle") - - cur.close() - conn.close() - - return jsonify({'success': True, 'message': 'Status erfolgreich geändert'}) - except Exception as e: - return jsonify({'success': False, 'message': str(e)}), 500 - -# @app.route("/api/licenses/bulk-activate", methods=["POST"]) -# @login_required -def bulk_activate_licenses(): - """Activate multiple licenses at once""" - try: - data = request.get_json() - license_ids = data.get('ids', []) - - if not license_ids: - return jsonify({'success': False, 'message': 'Keine Lizenzen ausgewählt'}), 400 - - conn = get_connection() - cur = conn.cursor() - - # Update all selected licenses (nur Live-Daten) - cur.execute(""" - UPDATE licenses - SET is_active = TRUE - WHERE id = ANY(%s) AND is_test = FALSE - """, (license_ids,)) - - affected_rows = cur.rowcount - conn.commit() - - # Log the bulk action - log_audit('BULK_UPDATE', 'licenses', None, - new_values={'is_active': True, 'count': affected_rows}, - additional_info=f"{affected_rows} Lizenzen aktiviert") - - cur.close() - conn.close() - - return jsonify({'success': True, 'message': f'{affected_rows} Lizenzen aktiviert'}) - except Exception as e: - return jsonify({'success': False, 'message': str(e)}), 500 - -# @app.route("/api/licenses/bulk-deactivate", methods=["POST"]) -# @login_required -def bulk_deactivate_licenses(): - """Deactivate multiple licenses at once""" - try: - data = request.get_json() - license_ids = data.get('ids', []) - - if not license_ids: - return jsonify({'success': False, 'message': 'Keine Lizenzen ausgewählt'}), 400 - - conn = get_connection() - cur = conn.cursor() - - # Update all selected licenses (nur Live-Daten) - cur.execute(""" - UPDATE licenses - SET is_active = FALSE - WHERE id = ANY(%s) AND is_test = FALSE - """, (license_ids,)) - - affected_rows = cur.rowcount - conn.commit() - - # Log the bulk action - log_audit('BULK_UPDATE', 'licenses', None, - new_values={'is_active': False, 'count': affected_rows}, - additional_info=f"{affected_rows} Lizenzen deaktiviert") - - cur.close() - conn.close() - - return jsonify({'success': True, 'message': f'{affected_rows} Lizenzen deaktiviert'}) - except Exception as e: - return jsonify({'success': False, 'message': str(e)}), 500 - -# @app.route("/api/license//devices") -# @login_required -def get_license_devices(license_id): - """Hole alle registrierten Geräte einer Lizenz""" - try: - conn = get_connection() - cur = conn.cursor() - - # Prüfe ob Lizenz existiert und hole device_limit - cur.execute(""" - SELECT device_limit FROM licenses WHERE id = %s - """, (license_id,)) - license_data = cur.fetchone() - - if not license_data: - return jsonify({'success': False, 'message': 'Lizenz nicht gefunden'}), 404 - - device_limit = license_data[0] - - # Hole alle Geräte für diese Lizenz - cur.execute(""" - SELECT id, hardware_id, device_name, operating_system, - first_seen, last_seen, is_active, ip_address - FROM device_registrations - WHERE license_id = %s - ORDER BY is_active DESC, last_seen DESC - """, (license_id,)) - - devices = [] - for row in cur.fetchall(): - devices.append({ - 'id': row[0], - 'hardware_id': row[1], - 'device_name': row[2] or 'Unbekanntes Gerät', - 'operating_system': row[3] or 'Unbekannt', - 'first_seen': row[4].strftime('%d.%m.%Y %H:%M') if row[4] else '', - 'last_seen': row[5].strftime('%d.%m.%Y %H:%M') if row[5] else '', - 'is_active': row[6], - 'ip_address': row[7] or '-' - }) - - cur.close() - conn.close() - - return jsonify({ - 'success': True, - 'devices': devices, - 'device_limit': device_limit, - 'active_count': sum(1 for d in devices if d['is_active']) - }) - - except Exception as e: - logging.error(f"Fehler beim Abrufen der Geräte: {str(e)}") - return jsonify({'success': False, 'message': 'Fehler beim Abrufen der Geräte'}), 500 - -# @app.route("/api/license//register-device", methods=["POST"]) -# def register_device(license_id): - # """Registriere ein neues Gerät für eine Lizenz""" - # try: - # data = request.get_json() - # hardware_id = data.get('hardware_id') - # device_name = data.get('device_name', '') - # operating_system = data.get('operating_system', '') - - # if not hardware_id: - # return jsonify({'success': False, 'message': 'Hardware-ID fehlt'}), 400 - - # conn = get_connection() - # cur = conn.cursor() - - # Prüfe ob Lizenz existiert und aktiv ist - # cur.execute(""" - # SELECT device_limit, is_active, valid_until - # FROM licenses - # WHERE id = %s - # """, (license_id,)) - # license_data = cur.fetchone() - - # if not license_data: - # return jsonify({'success': False, 'message': 'Lizenz nicht gefunden'}), 404 - - # device_limit, is_active, valid_until = license_data - - # Prüfe ob Lizenz aktiv und gültig ist - # if not is_active: - # return jsonify({'success': False, 'message': 'Lizenz ist deaktiviert'}), 403 - - # if valid_until < datetime.now(ZoneInfo("Europe/Berlin")).date(): - # return jsonify({'success': False, 'message': 'Lizenz ist abgelaufen'}), 403 - - # Prüfe ob Gerät bereits registriert ist - # cur.execute(""" - # SELECT id, is_active FROM device_registrations - # WHERE license_id = %s AND hardware_id = %s - # """, (license_id, hardware_id)) - # existing_device = cur.fetchone() - - # if existing_device: - # device_id, is_device_active = existing_device - # if is_device_active: - # Gerät ist bereits aktiv, update last_seen - # cur.execute(""" - # UPDATE device_registrations - # SET last_seen = CURRENT_TIMESTAMP, - # ip_address = %s, - # user_agent = %s - # WHERE id = %s - # """, (get_client_ip(), request.headers.get('User-Agent', ''), device_id)) - # conn.commit() - # return jsonify({'success': True, 'message': 'Gerät bereits registriert', 'device_id': device_id}) - # else: - # Gerät war deaktiviert, prüfe ob wir es reaktivieren können - # cur.execute(""" - # SELECT COUNT(*) FROM device_registrations - # WHERE license_id = %s AND is_active = TRUE - # """, (license_id,)) - # active_count = cur.fetchone()[0] - - # if active_count >= device_limit: - # return jsonify({'success': False, 'message': f'Gerätelimit erreicht ({device_limit} Geräte)'}), 403 - - # Reaktiviere das Gerät - # cur.execute(""" - # UPDATE device_registrations - # SET is_active = TRUE, - # last_seen = CURRENT_TIMESTAMP, - # deactivated_at = NULL, - # deactivated_by = NULL, - # ip_address = %s, - # user_agent = %s - # WHERE id = %s - # """, (get_client_ip(), request.headers.get('User-Agent', ''), device_id)) - # conn.commit() - # return jsonify({'success': True, 'message': 'Gerät reaktiviert', 'device_id': device_id}) - - # Neues Gerät - prüfe Gerätelimit - # cur.execute(""" - # SELECT COUNT(*) FROM device_registrations - # WHERE license_id = %s AND is_active = TRUE - # """, (license_id,)) - # active_count = cur.fetchone()[0] - - # if active_count >= device_limit: - # return jsonify({'success': False, 'message': f'Gerätelimit erreicht ({device_limit} Geräte)'}), 403 - - # Registriere neues Gerät - # cur.execute(""" - # INSERT INTO device_registrations - # (license_id, hardware_id, device_name, operating_system, ip_address, user_agent) - # VALUES (%s, %s, %s, %s, %s, %s) - # RETURNING id - # """, (license_id, hardware_id, device_name, operating_system, - # get_client_ip(), request.headers.get('User-Agent', ''))) - # device_id = cur.fetchone()[0] - - # conn.commit() - - # Audit Log - # log_audit('DEVICE_REGISTER', 'device', device_id, - # new_values={'license_id': license_id, 'hardware_id': hardware_id}) - - # cur.close() - # conn.close() - - # return jsonify({'success': True, 'message': 'Gerät erfolgreich registriert', 'device_id': device_id}) - - # except Exception as e: - # logging.error(f"Fehler bei Geräte-Registrierung: {str(e)}") - # return jsonify({'success': False, 'message': 'Fehler bei der Registrierung'}), 500 - -# @app.route("/api/license//deactivate-device/", methods=["POST"]) -# @login_required -def deactivate_device(license_id, device_id): - """Deaktiviere ein registriertes Gerät""" - try: - conn = get_connection() - cur = conn.cursor() - - # Prüfe ob das Gerät zu dieser Lizenz gehört - cur.execute(""" - SELECT id FROM device_registrations - WHERE id = %s AND license_id = %s AND is_active = TRUE - """, (device_id, license_id)) - - if not cur.fetchone(): - return jsonify({'success': False, 'message': 'Gerät nicht gefunden oder bereits deaktiviert'}), 404 - - # Deaktiviere das Gerät - cur.execute(""" - UPDATE device_registrations - SET is_active = FALSE, - deactivated_at = CURRENT_TIMESTAMP, - deactivated_by = %s - WHERE id = %s - """, (session['username'], device_id)) - - conn.commit() - - # Audit Log - log_audit('DEVICE_DEACTIVATE', 'device', device_id, - old_values={'is_active': True}, - new_values={'is_active': False}) - - cur.close() - conn.close() - - return jsonify({'success': True, 'message': 'Gerät erfolgreich deaktiviert'}) - - except Exception as e: - logging.error(f"Fehler beim Deaktivieren des Geräts: {str(e)}") - return jsonify({'success': False, 'message': 'Fehler beim Deaktivieren'}), 500 - -# @app.route("/api/licenses/bulk-delete", methods=["POST"]) -# @login_required -def bulk_delete_licenses(): - """Delete multiple licenses at once""" - try: - data = request.get_json() - license_ids = data.get('ids', []) - - if not license_ids: - return jsonify({'success': False, 'message': 'Keine Lizenzen ausgewählt'}), 400 - - conn = get_connection() - cur = conn.cursor() - - # Get license info for audit log (nur Live-Daten) - cur.execute(""" - SELECT license_key - FROM licenses - WHERE id = ANY(%s) AND is_test = FALSE - """, (license_ids,)) - license_keys = [row[0] for row in cur.fetchall()] - - # Delete all selected licenses (nur Live-Daten) - cur.execute(""" - DELETE FROM licenses - WHERE id = ANY(%s) AND is_test = FALSE - """, (license_ids,)) - - affected_rows = cur.rowcount - conn.commit() - - # Log the bulk action - log_audit('BULK_DELETE', 'licenses', None, - old_values={'license_keys': license_keys, 'count': affected_rows}, - additional_info=f"{affected_rows} Lizenzen gelöscht") - - cur.close() - conn.close() - - return jsonify({'success': True, 'message': f'{affected_rows} Lizenzen gelöscht'}) - except Exception as e: - return jsonify({'success': False, 'message': str(e)}), 500 - -# ===================== RESOURCE POOL MANAGEMENT ===================== - -# @app.route('/resources') -# @login_required -def resources(): - """Resource Pool Hauptübersicht""" - conn = get_connection() - cur = conn.cursor() - - # Prüfe ob Testdaten angezeigt werden sollen (gleiche Logik wie bei Kunden) - show_test = request.args.get('show_test', 'false').lower() == 'true' - - # Statistiken abrufen - cur.execute(""" - SELECT - resource_type, - COUNT(*) FILTER (WHERE status = 'available') as available, - COUNT(*) FILTER (WHERE status = 'allocated') as allocated, - COUNT(*) FILTER (WHERE status = 'quarantine') as quarantine, - COUNT(*) as total - FROM resource_pools - WHERE is_test = %s - GROUP BY resource_type - """, (show_test,)) - - stats = {} - for row in cur.fetchall(): - stats[row[0]] = { - 'available': row[1], - 'allocated': row[2], - 'quarantine': row[3], - 'total': row[4], - 'available_percent': round((row[1] / row[4] * 100) if row[4] > 0 else 0, 1) - } - - # Letzte Aktivitäten (gefiltert nach Test/Live) - cur.execute(""" - SELECT - rh.action, - rh.action_by, - rh.action_at, - rp.resource_type, - rp.resource_value, - rh.details - FROM resource_history rh - JOIN resource_pools rp ON rh.resource_id = rp.id - WHERE rp.is_test = %s - ORDER BY rh.action_at DESC - LIMIT 10 - """, (show_test,)) - recent_activities = cur.fetchall() - - # Ressourcen-Liste mit Pagination - page = request.args.get('page', 1, type=int) - per_page = 50 - offset = (page - 1) * per_page - - resource_type = request.args.get('type', '') - status_filter = request.args.get('status', '') - search = request.args.get('search', '') - - # Sortierung - sort_by = request.args.get('sort', 'id') - sort_order = request.args.get('order', 'desc') - - # Base Query - query = """ - SELECT - rp.id, - rp.resource_type, - rp.resource_value, - rp.status, - rp.allocated_to_license, - l.license_key, - c.name as customer_name, - rp.status_changed_at, - rp.quarantine_reason, - rp.quarantine_until, - c.id as customer_id - FROM resource_pools rp - LEFT JOIN licenses l ON rp.allocated_to_license = l.id - LEFT JOIN customers c ON l.customer_id = c.id - WHERE rp.is_test = %s - """ - params = [show_test] - - if resource_type: - query += " AND rp.resource_type = %s" - params.append(resource_type) - - if status_filter: - query += " AND rp.status = %s" - params.append(status_filter) - - if search: - query += " AND rp.resource_value ILIKE %s" - params.append(f'%{search}%') - - # Count total - count_query = f"SELECT COUNT(*) FROM ({query}) as cnt" - cur.execute(count_query, params) - total = cur.fetchone()[0] - total_pages = (total + per_page - 1) // per_page - - # Get paginated results with dynamic sorting - sort_column_map = { - 'id': 'rp.id', - 'type': 'rp.resource_type', - 'resource': 'rp.resource_value', - 'status': 'rp.status', - 'assigned': 'c.name', - 'changed': 'rp.status_changed_at' - } - - sort_column = sort_column_map.get(sort_by, 'rp.id') - sort_direction = 'ASC' if sort_order == 'asc' else 'DESC' - - query += f" ORDER BY {sort_column} {sort_direction} LIMIT %s OFFSET %s" - params.extend([per_page, offset]) - - cur.execute(query, params) - resources = cur.fetchall() - - cur.close() - conn.close() - - return render_template('resources.html', - stats=stats, - resources=resources, - recent_activities=recent_activities, - page=page, - total_pages=total_pages, - total=total, - resource_type=resource_type, - status_filter=status_filter, - search=search, - show_test=show_test, - sort_by=sort_by, - sort_order=sort_order, - datetime=datetime, - timedelta=timedelta) - -# @app.route('/resources/add', methods=['GET', 'POST']) -# @login_required -def add_resources(): - """Ressourcen zum Pool hinzufügen""" - # Hole show_test Parameter für die Anzeige - show_test = request.args.get('show_test', 'false').lower() == 'true' - - if request.method == 'POST': - resource_type = request.form.get('resource_type') - resources_text = request.form.get('resources_text', '') - is_test = request.form.get('is_test') == 'on' # Checkbox für Testdaten - - # Parse resources (one per line) - resources = [r.strip() for r in resources_text.split('\n') if r.strip()] - - if not resources: - flash('Keine Ressourcen angegeben', 'error') - return redirect(url_for('add_resources', show_test=show_test)) - - conn = get_connection() - cur = conn.cursor() - - added = 0 - duplicates = 0 - - for resource_value in resources: - try: - cur.execute(""" - INSERT INTO resource_pools (resource_type, resource_value, status_changed_by, is_test) - VALUES (%s, %s, %s, %s) - ON CONFLICT (resource_type, resource_value) DO NOTHING - """, (resource_type, resource_value, session['username'], is_test)) - - if cur.rowcount > 0: - added += 1 - # Get the inserted ID - cur.execute("SELECT id FROM resource_pools WHERE resource_type = %s AND resource_value = %s", - (resource_type, resource_value)) - resource_id = cur.fetchone()[0] - - # Log in history - cur.execute(""" - INSERT INTO resource_history (resource_id, action, action_by, ip_address) - VALUES (%s, 'created', %s, %s) - """, (resource_id, session['username'], get_client_ip())) - else: - duplicates += 1 - - except Exception as e: - app.logger.error(f"Error adding resource {resource_value}: {e}") - - conn.commit() - cur.close() - conn.close() - - log_audit('CREATE', 'resource_pool', None, - new_values={'type': resource_type, 'added': added, 'duplicates': duplicates, 'is_test': is_test}, - additional_info=f"{added} {'Test-' if is_test else ''}Ressourcen hinzugefügt, {duplicates} Duplikate übersprungen") - - flash(f'{added} {"Test-" if is_test else ""}Ressourcen hinzugefügt, {duplicates} Duplikate übersprungen', 'success') - return redirect(url_for('resources', show_test=show_test)) - - return render_template('add_resources.html', show_test=show_test) - -# @app.route('/resources/quarantine/', methods=['POST']) -# @login_required -def quarantine_resource(resource_id): - """Ressource in Quarantäne setzen""" - reason = request.form.get('reason', 'review') - until_date = request.form.get('until_date') - notes = request.form.get('notes', '') - - conn = get_connection() - cur = conn.cursor() - - # Get current resource info - cur.execute("SELECT resource_type, resource_value, status FROM resource_pools WHERE id = %s", (resource_id,)) - resource = cur.fetchone() - - if not resource: - flash('Ressource nicht gefunden', 'error') - return redirect(url_for('resources')) - - old_status = resource[2] - - # Update resource - cur.execute(""" - UPDATE resource_pools - SET status = 'quarantine', - quarantine_reason = %s, - quarantine_until = %s, - notes = %s, - status_changed_at = CURRENT_TIMESTAMP, - status_changed_by = %s - WHERE id = %s - """, (reason, until_date if until_date else None, notes, session['username'], resource_id)) - - # Log in history - cur.execute(""" - INSERT INTO resource_history (resource_id, action, action_by, ip_address, details) - VALUES (%s, 'quarantined', %s, %s, %s) - """, (resource_id, session['username'], get_client_ip(), - Json({'reason': reason, 'until': until_date, 'notes': notes, 'old_status': old_status}))) - - conn.commit() - cur.close() - conn.close() - - log_audit('UPDATE', 'resource', resource_id, - old_values={'status': old_status}, - new_values={'status': 'quarantine', 'reason': reason}, - additional_info=f"Ressource {resource[0]}: {resource[1]} in Quarantäne") - - flash('Ressource in Quarantäne gesetzt', 'success') - - # Redirect mit allen aktuellen Filtern - return redirect(url_for('resources', - show_test=request.args.get('show_test', request.form.get('show_test', 'false')), - type=request.args.get('type', request.form.get('type', '')), - status=request.args.get('status', request.form.get('status', '')), - search=request.args.get('search', request.form.get('search', '')))) - -# @app.route('/resources/release', methods=['POST']) -# @login_required -def release_resources(): - """Ressourcen aus Quarantäne freigeben""" - resource_ids = request.form.getlist('resource_ids') - - if not resource_ids: - flash('Keine Ressourcen ausgewählt', 'error') - return redirect(url_for('resources')) - - conn = get_connection() - cur = conn.cursor() - - released = 0 - for resource_id in resource_ids: - cur.execute(""" - UPDATE resource_pools - SET status = 'available', - quarantine_reason = NULL, - quarantine_until = NULL, - allocated_to_license = NULL, - status_changed_at = CURRENT_TIMESTAMP, - status_changed_by = %s - WHERE id = %s AND status = 'quarantine' - """, (session['username'], resource_id)) - - if cur.rowcount > 0: - released += 1 - # Log in history - cur.execute(""" - INSERT INTO resource_history (resource_id, action, action_by, ip_address) - VALUES (%s, 'released', %s, %s) - """, (resource_id, session['username'], get_client_ip())) - - conn.commit() - cur.close() - conn.close() - - log_audit('UPDATE', 'resource_pool', None, - new_values={'released': released}, - additional_info=f"{released} Ressourcen aus Quarantäne freigegeben") - - flash(f'{released} Ressourcen freigegeben', 'success') - - # Redirect mit allen aktuellen Filtern - return redirect(url_for('resources', - show_test=request.args.get('show_test', request.form.get('show_test', 'false')), - type=request.args.get('type', request.form.get('type', '')), - status=request.args.get('status', request.form.get('status', '')), - search=request.args.get('search', request.form.get('search', '')))) - -# @app.route('/api/resources/allocate', methods=['POST']) -# @login_required -def allocate_resources_api(): - """API für Ressourcen-Zuweisung bei Lizenzerstellung""" - data = request.json - license_id = data.get('license_id') - domain_count = data.get('domain_count', 1) - ipv4_count = data.get('ipv4_count', 1) - phone_count = data.get('phone_count', 1) - - conn = get_connection() - cur = conn.cursor() - - try: - allocated = {'domains': [], 'ipv4s': [], 'phones': []} - - # Allocate domains - if domain_count > 0: - cur.execute(""" - SELECT id, resource_value FROM resource_pools - WHERE resource_type = 'domain' AND status = 'available' - LIMIT %s FOR UPDATE - """, (domain_count,)) - domains = cur.fetchall() - - if len(domains) < domain_count: - raise ValueError(f"Nicht genügend Domains verfügbar (benötigt: {domain_count}, verfügbar: {len(domains)})") - - for domain_id, domain_value in domains: - # Update resource status - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', - allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, - status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], domain_id)) - - # Create assignment - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, domain_id, session['username'])) - - # Log history - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (domain_id, license_id, session['username'], get_client_ip())) - - allocated['domains'].append(domain_value) - - # Allocate IPv4s (similar logic) - if ipv4_count > 0: - cur.execute(""" - SELECT id, resource_value FROM resource_pools - WHERE resource_type = 'ipv4' AND status = 'available' - LIMIT %s FOR UPDATE - """, (ipv4_count,)) - ipv4s = cur.fetchall() - - if len(ipv4s) < ipv4_count: - raise ValueError(f"Nicht genügend IPv4-Adressen verfügbar") - - for ipv4_id, ipv4_value in ipv4s: - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', - allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, - status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], ipv4_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, ipv4_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (ipv4_id, license_id, session['username'], get_client_ip())) - - allocated['ipv4s'].append(ipv4_value) - - # Allocate phones (similar logic) - if phone_count > 0: - cur.execute(""" - SELECT id, resource_value FROM resource_pools - WHERE resource_type = 'phone' AND status = 'available' - LIMIT %s FOR UPDATE - """, (phone_count,)) - phones = cur.fetchall() - - if len(phones) < phone_count: - raise ValueError(f"Nicht genügend Telefonnummern verfügbar") - - for phone_id, phone_value in phones: - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', - allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, - status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], phone_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, phone_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (phone_id, license_id, session['username'], get_client_ip())) - - allocated['phones'].append(phone_value) - - # Update license resource counts - cur.execute(""" - UPDATE licenses - SET domain_count = %s, - ipv4_count = %s, - phone_count = %s - WHERE id = %s - """, (domain_count, ipv4_count, phone_count, license_id)) - - conn.commit() - cur.close() - conn.close() - - return jsonify({ - 'success': True, - 'allocated': allocated - }) - - except Exception as e: - conn.rollback() - cur.close() - conn.close() - return jsonify({ - 'success': False, - 'error': str(e) - }), 400 - -# @app.route('/api/resources/check-availability', methods=['GET']) -# @login_required -def check_resource_availability(): - """Prüft verfügbare Ressourcen""" - resource_type = request.args.get('type', '') - count = request.args.get('count', 10, type=int) - show_test = request.args.get('show_test', 'false').lower() == 'true' - - conn = get_connection() - cur = conn.cursor() - - if resource_type: - # Spezifische Ressourcen für einen Typ - cur.execute(""" - SELECT id, resource_value - FROM resource_pools - WHERE status = 'available' - AND resource_type = %s - AND is_test = %s - ORDER BY resource_value - LIMIT %s - """, (resource_type, show_test, count)) - - resources = [] - for row in cur.fetchall(): - resources.append({ - 'id': row[0], - 'value': row[1] - }) - - cur.close() - conn.close() - - return jsonify({ - 'available': resources, - 'type': resource_type, - 'count': len(resources) - }) - else: - # Zusammenfassung aller Typen - cur.execute(""" - SELECT - resource_type, - COUNT(*) as available - FROM resource_pools - WHERE status = 'available' - AND is_test = %s - GROUP BY resource_type - """, (show_test,)) - - availability = {} - for row in cur.fetchall(): - availability[row[0]] = row[1] - - cur.close() - conn.close() - - return jsonify(availability) - -# @app.route('/api/global-search', methods=['GET']) -# @login_required -def global_search(): - """Global search API endpoint for searching customers and licenses""" - query = request.args.get('q', '').strip() - - if not query or len(query) < 2: - return jsonify({'customers': [], 'licenses': []}) - - conn = get_connection() - cur = conn.cursor() - - # Search pattern with wildcards - search_pattern = f'%{query}%' - - # Search customers - cur.execute(""" - SELECT id, name, email, company_name - FROM customers - WHERE (LOWER(name) LIKE LOWER(%s) - OR LOWER(email) LIKE LOWER(%s) - OR LOWER(company_name) LIKE LOWER(%s)) - AND is_test = FALSE - ORDER BY name - LIMIT 5 - """, (search_pattern, search_pattern, search_pattern)) - - customers = [] - for row in cur.fetchall(): - customers.append({ - 'id': row[0], - 'name': row[1], - 'email': row[2], - 'company_name': row[3] - }) - - # Search licenses - cur.execute(""" - SELECT l.id, l.license_key, c.name as customer_name - FROM licenses l - JOIN customers c ON l.customer_id = c.id - WHERE LOWER(l.license_key) LIKE LOWER(%s) - AND l.is_test = FALSE - ORDER BY l.created_at DESC - LIMIT 5 - """, (search_pattern,)) - - licenses = [] - for row in cur.fetchall(): - licenses.append({ - 'id': row[0], - 'license_key': row[1], - 'customer_name': row[2] - }) - - cur.close() - conn.close() - - return jsonify({ - 'customers': customers, - 'licenses': licenses - }) - -# @app.route('/resources/history/') -# @login_required -def resource_history(resource_id): - """Zeigt die komplette Historie einer Ressource""" - conn = get_connection() - cur = conn.cursor() - - # Get complete resource info using named columns - cur.execute(""" - SELECT id, resource_type, resource_value, status, allocated_to_license, - status_changed_at, status_changed_by, quarantine_reason, - quarantine_until, created_at, notes - FROM resource_pools - WHERE id = %s - """, (resource_id,)) - row = cur.fetchone() - - if not row: - flash('Ressource nicht gefunden', 'error') - return redirect(url_for('resources')) - - # Create resource object with named attributes - resource = { - 'id': row[0], - 'resource_type': row[1], - 'resource_value': row[2], - 'status': row[3], - 'allocated_to_license': row[4], - 'status_changed_at': row[5], - 'status_changed_by': row[6], - 'quarantine_reason': row[7], - 'quarantine_until': row[8], - 'created_at': row[9], - 'notes': row[10] - } - - # Get license info if allocated - license_info = None - if resource['allocated_to_license']: - cur.execute("SELECT license_key FROM licenses WHERE id = %s", - (resource['allocated_to_license'],)) - lic = cur.fetchone() - if lic: - license_info = {'license_key': lic[0]} - - # Get history with named columns - cur.execute(""" - SELECT - rh.action, - rh.action_by, - rh.action_at, - rh.details, - rh.license_id, - rh.ip_address - FROM resource_history rh - WHERE rh.resource_id = %s - ORDER BY rh.action_at DESC - """, (resource_id,)) - - history = [] - for row in cur.fetchall(): - history.append({ - 'action': row[0], - 'action_by': row[1], - 'action_at': row[2], - 'details': row[3], - 'license_id': row[4], - 'ip_address': row[5] - }) - - cur.close() - conn.close() - - # Convert to object-like for template - class ResourceObj: - def __init__(self, data): - for key, value in data.items(): - setattr(self, key, value) - - resource_obj = ResourceObj(resource) - history_objs = [ResourceObj(h) for h in history] - - return render_template('resource_history.html', - resource=resource_obj, - license_info=license_info, - history=history_objs) - -# @app.route('/resources/metrics') -# @login_required -def resources_metrics(): - """Dashboard für Resource Metrics und Reports""" - conn = get_connection() - cur = conn.cursor() - - # Overall stats with fallback values - cur.execute(""" - SELECT - COUNT(DISTINCT resource_id) as total_resources, - COALESCE(AVG(performance_score), 0) as avg_performance, - COALESCE(SUM(cost), 0) as total_cost, - COALESCE(SUM(revenue), 0) as total_revenue, - COALESCE(SUM(issues_count), 0) as total_issues - FROM resource_metrics - WHERE metric_date >= CURRENT_DATE - INTERVAL '30 days' - """) - row = cur.fetchone() - - # Calculate ROI - roi = 0 - if row[2] > 0: # if total_cost > 0 - roi = row[3] / row[2] # revenue / cost - - stats = { - 'total_resources': row[0] or 0, - 'avg_performance': row[1] or 0, - 'total_cost': row[2] or 0, - 'total_revenue': row[3] or 0, - 'total_issues': row[4] or 0, - 'roi': roi - } - - # Performance by type - cur.execute(""" - SELECT - rp.resource_type, - COALESCE(AVG(rm.performance_score), 0) as avg_score, - COUNT(DISTINCT rp.id) as resource_count - FROM resource_pools rp - LEFT JOIN resource_metrics rm ON rp.id = rm.resource_id - AND rm.metric_date >= CURRENT_DATE - INTERVAL '30 days' - GROUP BY rp.resource_type - ORDER BY rp.resource_type - """) - performance_by_type = cur.fetchall() - - # Utilization data - cur.execute(""" - SELECT - resource_type, - COUNT(*) FILTER (WHERE status = 'allocated') as allocated, - COUNT(*) as total, - ROUND(COUNT(*) FILTER (WHERE status = 'allocated') * 100.0 / COUNT(*), 1) as allocated_percent - FROM resource_pools - GROUP BY resource_type - """) - utilization_rows = cur.fetchall() - utilization_data = [ - { - 'type': row[0].upper(), - 'allocated': row[1], - 'total': row[2], - 'allocated_percent': row[3] - } - for row in utilization_rows - ] - - # Top performing resources - cur.execute(""" - SELECT - rp.id, - rp.resource_type, - rp.resource_value, - COALESCE(AVG(rm.performance_score), 0) as avg_score, - COALESCE(SUM(rm.revenue), 0) as total_revenue, - COALESCE(SUM(rm.cost), 1) as total_cost, - CASE - WHEN COALESCE(SUM(rm.cost), 0) = 0 THEN 0 - ELSE COALESCE(SUM(rm.revenue), 0) / COALESCE(SUM(rm.cost), 1) - END as roi - FROM resource_pools rp - LEFT JOIN resource_metrics rm ON rp.id = rm.resource_id - AND rm.metric_date >= CURRENT_DATE - INTERVAL '30 days' - WHERE rp.status != 'quarantine' - GROUP BY rp.id, rp.resource_type, rp.resource_value - HAVING AVG(rm.performance_score) IS NOT NULL - ORDER BY avg_score DESC - LIMIT 10 - """) - top_rows = cur.fetchall() - top_performers = [ - { - 'id': row[0], - 'resource_type': row[1], - 'resource_value': row[2], - 'avg_score': row[3], - 'roi': row[6] - } - for row in top_rows - ] - - # Resources with issues - cur.execute(""" - SELECT - rp.id, - rp.resource_type, - rp.resource_value, - rp.status, - COALESCE(SUM(rm.issues_count), 0) as total_issues - FROM resource_pools rp - LEFT JOIN resource_metrics rm ON rp.id = rm.resource_id - AND rm.metric_date >= CURRENT_DATE - INTERVAL '30 days' - WHERE rm.issues_count > 0 OR rp.status = 'quarantine' - GROUP BY rp.id, rp.resource_type, rp.resource_value, rp.status - HAVING SUM(rm.issues_count) > 0 - ORDER BY total_issues DESC - LIMIT 10 - """) - problem_rows = cur.fetchall() - problem_resources = [ - { - 'id': row[0], - 'resource_type': row[1], - 'resource_value': row[2], - 'status': row[3], - 'total_issues': row[4] - } - for row in problem_rows - ] - - # Daily metrics for trend chart (last 30 days) - cur.execute(""" - SELECT - metric_date, - COALESCE(AVG(performance_score), 0) as avg_performance, - COALESCE(SUM(issues_count), 0) as total_issues - FROM resource_metrics - WHERE metric_date >= CURRENT_DATE - INTERVAL '30 days' - GROUP BY metric_date - ORDER BY metric_date - """) - daily_rows = cur.fetchall() - daily_metrics = [ - { - 'date': row[0].strftime('%d.%m'), - 'performance': float(row[1]), - 'issues': int(row[2]) - } - for row in daily_rows - ] - - cur.close() - conn.close() - - return render_template('resource_metrics.html', - stats=stats, - performance_by_type=performance_by_type, - utilization_data=utilization_data, - top_performers=top_performers, - problem_resources=problem_resources, - daily_metrics=daily_metrics) - -# @app.route('/resources/report', methods=['GET']) -# @login_required -def resources_report(): - """Generiert Ressourcen-Reports oder zeigt Report-Formular""" - # Prüfe ob Download angefordert wurde - if request.args.get('download') == 'true': - report_type = request.args.get('type', 'usage') - format_type = request.args.get('format', 'excel') - date_from = request.args.get('from', (datetime.now(ZoneInfo("Europe/Berlin")) - timedelta(days=30)).strftime('%Y-%m-%d')) - date_to = request.args.get('to', datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y-%m-%d')) - - conn = get_connection() - cur = conn.cursor() - - if report_type == 'usage': - # Auslastungsreport - query = """ - SELECT - rp.resource_type, - rp.resource_value, - rp.status, - COUNT(DISTINCT rh.license_id) as unique_licenses, - COUNT(rh.id) as total_allocations, - MIN(rh.action_at) as first_used, - MAX(rh.action_at) as last_used - FROM resource_pools rp - LEFT JOIN resource_history rh ON rp.id = rh.resource_id - AND rh.action = 'allocated' - AND rh.action_at BETWEEN %s AND %s - GROUP BY rp.id, rp.resource_type, rp.resource_value, rp.status - ORDER BY rp.resource_type, total_allocations DESC - """ - cur.execute(query, (date_from, date_to)) - columns = ['Typ', 'Ressource', 'Status', 'Unique Lizenzen', 'Gesamt Zuweisungen', 'Erste Nutzung', 'Letzte Nutzung'] - - elif report_type == 'performance': - # Performance-Report - query = """ - SELECT - rp.resource_type, - rp.resource_value, - AVG(rm.performance_score) as avg_performance, - SUM(rm.usage_count) as total_usage, - SUM(rm.revenue) as total_revenue, - SUM(rm.cost) as total_cost, - SUM(rm.revenue - rm.cost) as profit, - SUM(rm.issues_count) as total_issues - FROM resource_pools rp - JOIN resource_metrics rm ON rp.id = rm.resource_id - WHERE rm.metric_date BETWEEN %s AND %s - GROUP BY rp.id, rp.resource_type, rp.resource_value - ORDER BY profit DESC - """ - cur.execute(query, (date_from, date_to)) - columns = ['Typ', 'Ressource', 'Durchschn. Performance', 'Gesamt Nutzung', 'Umsatz', 'Kosten', 'Gewinn', 'Issues'] - - elif report_type == 'compliance': - # Compliance-Report - query = """ - SELECT - rh.action_at, - rh.action, - rh.action_by, - rp.resource_type, - rp.resource_value, - l.license_key, - c.name as customer_name, - rh.ip_address - FROM resource_history rh - JOIN resource_pools rp ON rh.resource_id = rp.id - LEFT JOIN licenses l ON rh.license_id = l.id - LEFT JOIN customers c ON l.customer_id = c.id - WHERE rh.action_at BETWEEN %s AND %s - ORDER BY rh.action_at DESC - """ - cur.execute(query, (date_from, date_to)) - columns = ['Zeit', 'Aktion', 'Von', 'Typ', 'Ressource', 'Lizenz', 'Kunde', 'IP-Adresse'] - - else: # inventory report - # Inventar-Report - query = """ - SELECT - resource_type, - COUNT(*) FILTER (WHERE status = 'available') as available, - COUNT(*) FILTER (WHERE status = 'allocated') as allocated, - COUNT(*) FILTER (WHERE status = 'quarantine') as quarantine, - COUNT(*) as total - FROM resource_pools - GROUP BY resource_type - ORDER BY resource_type - """ - cur.execute(query) - columns = ['Typ', 'Verfügbar', 'Zugeteilt', 'Quarantäne', 'Gesamt'] - - # Convert to DataFrame - data = cur.fetchall() - df = pd.DataFrame(data, columns=columns) - - cur.close() - conn.close() - - # Generate file - timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') - filename = f"resource_report_{report_type}_{timestamp}" - - if format_type == 'excel': - output = io.BytesIO() - with pd.ExcelWriter(output, engine='openpyxl') as writer: - df.to_excel(writer, sheet_name='Report', index=False) - - # Auto-adjust columns width - worksheet = writer.sheets['Report'] - for column in worksheet.columns: - max_length = 0 - column = [cell for cell in column] - for cell in column: - try: - if len(str(cell.value)) > max_length: - max_length = len(str(cell.value)) - except: - pass - adjusted_width = (max_length + 2) - worksheet.column_dimensions[column[0].column_letter].width = adjusted_width - - output.seek(0) - - log_audit('EXPORT', 'resource_report', None, - new_values={'type': report_type, 'format': 'excel', 'rows': len(df)}, - additional_info=f"Resource Report {report_type} exportiert") - - return send_file(output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=f'{filename}.xlsx') - - else: # CSV - output = io.StringIO() - df.to_csv(output, index=False, sep=';', encoding='utf-8-sig') - output.seek(0) - - log_audit('EXPORT', 'resource_report', None, - new_values={'type': report_type, 'format': 'csv', 'rows': len(df)}, - additional_info=f"Resource Report {report_type} exportiert") - - return send_file(io.BytesIO(output.getvalue().encode('utf-8-sig')), - mimetype='text/csv', - as_attachment=True, - download_name=f'{filename}.csv') - - # Wenn kein Download, zeige Report-Formular - return render_template('resource_report.html', - datetime=datetime, - timedelta=timedelta, - username=session.get('username')) - -if __name__ == "__main__": - app.run(host="0.0.0.0", port=5000) diff --git a/v2_adminpanel/app.py.backup_before_cleanup_20250616_223919 b/v2_adminpanel/app.py.backup_before_cleanup_20250616_223919 deleted file mode 100644 index 4e6204a..0000000 --- a/v2_adminpanel/app.py.backup_before_cleanup_20250616_223919 +++ /dev/null @@ -1,4475 +0,0 @@ -import os -import sys -import time -import json -import logging -import requests -import re -import random -import base64 -from io import BytesIO -from datetime import datetime, timedelta -from zoneinfo import ZoneInfo -from pathlib import Path - -# Add current directory to Python path to ensure modules can be imported -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) - -from flask import Flask, render_template, request, redirect, session, url_for, send_file, jsonify, flash -from flask_session import Session -from werkzeug.middleware.proxy_fix import ProxyFix -from apscheduler.schedulers.background import BackgroundScheduler -import pandas as pd -from psycopg2.extras import Json - -# Import our new modules -import config -from db import get_connection, get_db_connection, get_db_cursor, execute_query -from auth.decorators import login_required -from auth.password import hash_password, verify_password -from auth.two_factor import ( - generate_totp_secret, generate_qr_code, verify_totp, - generate_backup_codes, hash_backup_code, verify_backup_code -) -from auth.rate_limiting import ( - check_ip_blocked, record_failed_attempt, - reset_login_attempts, get_login_attempts -) -from utils.network import get_client_ip -from utils.audit import log_audit -from utils.license import generate_license_key, validate_license_key -from utils.backup import create_backup, restore_backup, get_or_create_encryption_key -from utils.export import ( - create_excel_export, format_datetime_for_export, - prepare_license_export_data, prepare_customer_export_data, - prepare_session_export_data, prepare_audit_export_data -) -from models import get_user_by_username - -app = Flask(__name__) -# Load configuration from config module -app.config['SECRET_KEY'] = config.SECRET_KEY -app.config['SESSION_TYPE'] = config.SESSION_TYPE -app.config['JSON_AS_ASCII'] = config.JSON_AS_ASCII -app.config['JSONIFY_MIMETYPE'] = config.JSONIFY_MIMETYPE -app.config['PERMANENT_SESSION_LIFETIME'] = config.PERMANENT_SESSION_LIFETIME -app.config['SESSION_COOKIE_HTTPONLY'] = config.SESSION_COOKIE_HTTPONLY -app.config['SESSION_COOKIE_SECURE'] = config.SESSION_COOKIE_SECURE -app.config['SESSION_COOKIE_SAMESITE'] = config.SESSION_COOKIE_SAMESITE -app.config['SESSION_COOKIE_NAME'] = config.SESSION_COOKIE_NAME -app.config['SESSION_REFRESH_EACH_REQUEST'] = config.SESSION_REFRESH_EACH_REQUEST -Session(app) - -# ProxyFix für korrekte IP-Adressen hinter Nginx -app.wsgi_app = ProxyFix( - app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1 -) - -# Configuration is now loaded from config module - -# Scheduler für automatische Backups -scheduler = BackgroundScheduler() -scheduler.start() - -# Logging konfigurieren -logging.basicConfig(level=logging.INFO) - -# Import and register blueprints -from routes.auth_routes import auth_bp -from routes.admin_routes import admin_bp -from routes.license_routes import license_bp -from routes.customer_routes import customer_bp -from routes.resource_routes import resource_bp -from routes.session_routes import session_bp -from routes.batch_routes import batch_bp -from routes.api_routes import api_bp -from routes.export_routes import export_bp - -# Register blueprints -app.register_blueprint(auth_bp) -app.register_blueprint(admin_bp) -app.register_blueprint(license_bp) -app.register_blueprint(customer_bp) -app.register_blueprint(resource_bp) -app.register_blueprint(session_bp) -app.register_blueprint(batch_bp) -app.register_blueprint(api_bp) -app.register_blueprint(export_bp) - - -# Scheduled Backup Job -def scheduled_backup(): - """Führt ein geplantes Backup aus""" - logging.info("Starte geplantes Backup...") - create_backup(backup_type="scheduled", created_by="scheduler") - -# Scheduler konfigurieren - täglich um 3:00 Uhr -scheduler.add_job( - scheduled_backup, - 'cron', - hour=config.SCHEDULER_CONFIG['backup_hour'], - minute=config.SCHEDULER_CONFIG['backup_minute'], - id='daily_backup', - replace_existing=True -) - - -def verify_recaptcha(response): - """Verifiziert die reCAPTCHA v2 Response mit Google""" - secret_key = config.RECAPTCHA_SECRET_KEY - - # Wenn kein Secret Key konfiguriert ist, CAPTCHA als bestanden werten (für PoC) - if not secret_key: - logging.warning("RECAPTCHA_SECRET_KEY nicht konfiguriert - CAPTCHA wird übersprungen") - return True - - # Verifizierung bei Google - try: - verify_url = 'https://www.google.com/recaptcha/api/siteverify' - data = { - 'secret': secret_key, - 'response': response - } - - # Timeout für Request setzen - r = requests.post(verify_url, data=data, timeout=5) - result = r.json() - - # Log für Debugging - if not result.get('success'): - logging.warning(f"reCAPTCHA Validierung fehlgeschlagen: {result.get('error-codes', [])}") - - return result.get('success', False) - - except requests.exceptions.RequestException as e: - logging.error(f"reCAPTCHA Verifizierung fehlgeschlagen: {str(e)}") - # Bei Netzwerkfehlern CAPTCHA als bestanden werten - return True - except Exception as e: - logging.error(f"Unerwarteter Fehler bei reCAPTCHA: {str(e)}") - return False - - -# @app.route("/login", methods=["GET", "POST"]) -# def login(): - # Timing-Attack Schutz - Start Zeit merken - # start_time = time.time() - - # IP-Adresse ermitteln - # ip_address = get_client_ip() - - # Prüfen ob IP gesperrt ist - # is_blocked, blocked_until = check_ip_blocked(ip_address) - # if is_blocked: - # time_remaining = (blocked_until - datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None)).total_seconds() / 3600 - # error_msg = f"IP GESPERRT! Noch {time_remaining:.1f} Stunden warten." - # return render_template("login.html", error=error_msg, error_type="blocked") - - # Anzahl bisheriger Versuche - # attempt_count = get_login_attempts(ip_address) - - # if request.method == "POST": - # username = request.form.get("username") - # password = request.form.get("password") - # captcha_response = request.form.get("g-recaptcha-response") - - # CAPTCHA-Prüfung nur wenn Keys konfiguriert sind - # recaptcha_site_key = config.RECAPTCHA_SITE_KEY - # if attempt_count >= config.CAPTCHA_AFTER_ATTEMPTS and recaptcha_site_key: - # if not captcha_response: - # Timing-Attack Schutz - # elapsed = time.time() - start_time - # if elapsed < 1.0: - # time.sleep(1.0 - elapsed) - # return render_template("login.html", - # error="CAPTCHA ERFORDERLICH!", - # show_captcha=True, - # error_type="captcha", - # attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - attempt_count), - # recaptcha_site_key=recaptcha_site_key) - - # CAPTCHA validieren - # if not verify_recaptcha(captcha_response): - # Timing-Attack Schutz - # elapsed = time.time() - start_time - # if elapsed < 1.0: - # time.sleep(1.0 - elapsed) - # return render_template("login.html", - # error="CAPTCHA UNGÜLTIG! Bitte erneut versuchen.", - # show_captcha=True, - # error_type="captcha", - # attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - attempt_count), - # recaptcha_site_key=recaptcha_site_key) - - # Check user in database first, fallback to env vars - # user = get_user_by_username(username) - # login_success = False - # needs_2fa = False - - # if user: - # Database user authentication - # if verify_password(password, user['password_hash']): - # login_success = True - # needs_2fa = user['totp_enabled'] - # else: - # Fallback to environment variables for backward compatibility - # if username in config.ADMIN_USERS and password == config.ADMIN_USERS[username]: - # login_success = True - - # Timing-Attack Schutz - Mindestens 1 Sekunde warten - # elapsed = time.time() - start_time - # if elapsed < 1.0: - # time.sleep(1.0 - elapsed) - - # if login_success: - # Erfolgreicher Login - # if needs_2fa: - # Store temporary session for 2FA verification - # session['temp_username'] = username - # session['temp_user_id'] = user['id'] - # session['awaiting_2fa'] = True - # return redirect(url_for('verify_2fa')) - # else: - # Complete login without 2FA - # session.permanent = True # Aktiviert das Timeout - # session['logged_in'] = True - # session['username'] = username - # session['user_id'] = user['id'] if user else None - # session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() - # reset_login_attempts(ip_address) - # log_audit('LOGIN_SUCCESS', 'user', - # additional_info=f"Erfolgreiche Anmeldung von IP: {ip_address}") - # return redirect(url_for('dashboard')) - # else: - # Fehlgeschlagener Login - # error_message = record_failed_attempt(ip_address, username) - # new_attempt_count = get_login_attempts(ip_address) - - # Prüfen ob jetzt gesperrt - # is_now_blocked, _ = check_ip_blocked(ip_address) - # if is_now_blocked: - # log_audit('LOGIN_BLOCKED', 'security', - # additional_info=f"IP {ip_address} wurde nach {config.MAX_LOGIN_ATTEMPTS} Versuchen gesperrt") - - # return render_template("login.html", - # error=error_message, - # show_captcha=(new_attempt_count >= config.CAPTCHA_AFTER_ATTEMPTS and config.RECAPTCHA_SITE_KEY), - # error_type="failed", - # attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - new_attempt_count), - # recaptcha_site_key=config.RECAPTCHA_SITE_KEY) - - # GET Request - # return render_template("login.html", - # show_captcha=(attempt_count >= config.CAPTCHA_AFTER_ATTEMPTS and config.RECAPTCHA_SITE_KEY), - # attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - attempt_count), - # recaptcha_site_key=config.RECAPTCHA_SITE_KEY) - -# @app.route("/logout") -# def logout(): - # username = session.get('username', 'unknown') - # log_audit('LOGOUT', 'user', additional_info=f"Abmeldung") - # session.pop('logged_in', None) - # session.pop('username', None) - # session.pop('user_id', None) - # session.pop('temp_username', None) - # session.pop('temp_user_id', None) - # session.pop('awaiting_2fa', None) - # return redirect(url_for('login')) - -# @app.route("/verify-2fa", methods=["GET", "POST"]) -# def verify_2fa(): - # if not session.get('awaiting_2fa'): - # return redirect(url_for('login')) - - # if request.method == "POST": - # token = request.form.get('token', '').replace(' ', '') - # username = session.get('temp_username') - # user_id = session.get('temp_user_id') - - # if not username or not user_id: - # flash('Session expired. Please login again.', 'error') - # return redirect(url_for('login')) - - # user = get_user_by_username(username) - # if not user: - # flash('User not found.', 'error') - # return redirect(url_for('login')) - - # Check if it's a backup code - # if len(token) == 8 and token.isupper(): - # Try backup code - # backup_codes = json.loads(user['backup_codes']) if user['backup_codes'] else [] - # if verify_backup_code(token, backup_codes): - # Remove used backup code - # code_hash = hash_backup_code(token) - # backup_codes.remove(code_hash) - - # conn = get_connection() - # cur = conn.cursor() - # cur.execute("UPDATE users SET backup_codes = %s WHERE id = %s", - # (json.dumps(backup_codes), user_id)) - # conn.commit() - # cur.close() - # conn.close() - - # Complete login - # session.permanent = True - # session['logged_in'] = True - # session['username'] = username - # session['user_id'] = user_id - # session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() - # session.pop('temp_username', None) - # session.pop('temp_user_id', None) - # session.pop('awaiting_2fa', None) - - # flash('Login successful using backup code. Please generate new backup codes.', 'warning') - # log_audit('LOGIN_2FA_BACKUP', 'user', additional_info=f"2FA login with backup code") - # return redirect(url_for('dashboard')) - # else: - # Try TOTP token - # if verify_totp(user['totp_secret'], token): - # Complete login - # session.permanent = True - # session['logged_in'] = True - # session['username'] = username - # session['user_id'] = user_id - # session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() - # session.pop('temp_username', None) - # session.pop('temp_user_id', None) - # session.pop('awaiting_2fa', None) - - # log_audit('LOGIN_2FA_SUCCESS', 'user', additional_info=f"2FA login successful") - # return redirect(url_for('dashboard')) - - # Failed verification - # conn = get_connection() - # cur = conn.cursor() - # cur.execute("UPDATE users SET failed_2fa_attempts = failed_2fa_attempts + 1, last_failed_2fa = %s WHERE id = %s", - # (datetime.now(), user_id)) - # conn.commit() - # cur.close() - # conn.close() - - # flash('Invalid authentication code. Please try again.', 'error') - # log_audit('LOGIN_2FA_FAILED', 'user', additional_info=f"Failed 2FA attempt") - - # return render_template('verify_2fa.html') - -# @app.route("/profile") -# @login_required -def profile(): - user = get_user_by_username(session['username']) - if not user: - # For environment-based users, redirect with message - flash('Bitte führen Sie das Migrations-Script aus, um Passwort-Änderung und 2FA zu aktivieren.', 'info') - return redirect(url_for('dashboard')) - return render_template('profile.html', user=user) - -# @app.route("/profile/change-password", methods=["POST"]) -# @login_required -def change_password(): - current_password = request.form.get('current_password') - new_password = request.form.get('new_password') - confirm_password = request.form.get('confirm_password') - - user = get_user_by_username(session['username']) - - # Verify current password - if not verify_password(current_password, user['password_hash']): - flash('Current password is incorrect.', 'error') - return redirect(url_for('profile')) - - # Check new password - if new_password != confirm_password: - flash('New passwords do not match.', 'error') - return redirect(url_for('profile')) - - if len(new_password) < 8: - flash('Password must be at least 8 characters long.', 'error') - return redirect(url_for('profile')) - - # Update password - new_hash = hash_password(new_password) - conn = get_connection() - cur = conn.cursor() - cur.execute("UPDATE users SET password_hash = %s, last_password_change = %s WHERE id = %s", - (new_hash, datetime.now(), user['id'])) - conn.commit() - cur.close() - conn.close() - - log_audit('PASSWORD_CHANGE', 'user', entity_id=user['id'], - additional_info="Password changed successfully") - flash('Password changed successfully.', 'success') - return redirect(url_for('profile')) - -# @app.route("/profile/setup-2fa") -# @login_required -def setup_2fa(): - user = get_user_by_username(session['username']) - - if user['totp_enabled']: - flash('2FA is already enabled for your account.', 'info') - return redirect(url_for('profile')) - - # Generate new TOTP secret - totp_secret = generate_totp_secret() - session['temp_totp_secret'] = totp_secret - - # Generate QR code - qr_code = generate_qr_code(user['username'], totp_secret) - - return render_template('setup_2fa.html', - totp_secret=totp_secret, - qr_code=qr_code) - -# @app.route("/profile/enable-2fa", methods=["POST"]) -# @login_required -def enable_2fa(): - token = request.form.get('token', '').replace(' ', '') - totp_secret = session.get('temp_totp_secret') - - if not totp_secret: - flash('2FA setup session expired. Please try again.', 'error') - return redirect(url_for('setup_2fa')) - - # Verify the token - if not verify_totp(totp_secret, token): - flash('Invalid authentication code. Please try again.', 'error') - return redirect(url_for('setup_2fa')) - - # Generate backup codes - backup_codes = generate_backup_codes() - hashed_codes = [hash_backup_code(code) for code in backup_codes] - - # Enable 2FA - conn = get_connection() - cur = conn.cursor() - cur.execute(""" - UPDATE users - SET totp_secret = %s, totp_enabled = TRUE, backup_codes = %s - WHERE username = %s - """, (totp_secret, json.dumps(hashed_codes), session['username'])) - conn.commit() - cur.close() - conn.close() - - session.pop('temp_totp_secret', None) - - log_audit('2FA_ENABLED', 'user', additional_info="2FA enabled successfully") - - # Show backup codes - return render_template('backup_codes.html', backup_codes=backup_codes) - -# @app.route("/profile/disable-2fa", methods=["POST"]) -# @login_required -def disable_2fa(): - password = request.form.get('password') - user = get_user_by_username(session['username']) - - # Verify password - if not verify_password(password, user['password_hash']): - flash('Incorrect password.', 'error') - return redirect(url_for('profile')) - - # Disable 2FA - conn = get_connection() - cur = conn.cursor() - cur.execute(""" - UPDATE users - SET totp_enabled = FALSE, totp_secret = NULL, backup_codes = NULL - WHERE username = %s - """, (session['username'],)) - conn.commit() - cur.close() - conn.close() - - log_audit('2FA_DISABLED', 'user', additional_info="2FA disabled") - flash('2FA has been disabled for your account.', 'success') - return redirect(url_for('profile')) - -# @app.route("/heartbeat", methods=['POST']) -# @login_required -def heartbeat(): - """Endpoint für Session Keep-Alive - aktualisiert last_activity""" - # Aktualisiere last_activity nur wenn explizit angefordert - session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() - # Force session save - session.modified = True - - return jsonify({ - 'status': 'ok', - 'last_activity': session['last_activity'], - 'username': session.get('username') - }) - -# @app.route("/api/generate-license-key", methods=['POST']) -# @login_required -def api_generate_key(): - """API Endpoint zur Generierung eines neuen Lizenzschlüssels""" - try: - # Lizenztyp aus Request holen (default: full) - data = request.get_json() or {} - license_type = data.get('type', 'full') - - # Key generieren - key = generate_license_key(license_type) - - # Prüfen ob Key bereits existiert (sehr unwahrscheinlich aber sicher ist sicher) - conn = get_connection() - cur = conn.cursor() - - # Wiederhole bis eindeutiger Key gefunden - attempts = 0 - while attempts < 10: # Max 10 Versuche - cur.execute("SELECT 1 FROM licenses WHERE license_key = %s", (key,)) - if not cur.fetchone(): - break # Key ist eindeutig - key = generate_license_key(license_type) - attempts += 1 - - cur.close() - conn.close() - - # Log für Audit - log_audit('GENERATE_KEY', 'license', - additional_info={'type': license_type, 'key': key}) - - return jsonify({ - 'success': True, - 'key': key, - 'type': license_type - }) - - except Exception as e: - logging.error(f"Fehler bei Key-Generierung: {str(e)}") - return jsonify({ - 'success': False, - 'error': 'Fehler bei der Key-Generierung' - }), 500 - -# @app.route("/api/customers", methods=['GET']) -# @login_required -def api_customers(): - """API Endpoint für die Kundensuche mit Select2""" - try: - # Suchparameter - search = request.args.get('q', '').strip() - page = request.args.get('page', 1, type=int) - per_page = 20 - customer_id = request.args.get('id', type=int) - - conn = get_connection() - cur = conn.cursor() - - # Einzelnen Kunden per ID abrufen - if customer_id: - cur.execute(""" - SELECT c.id, c.name, c.email, - COUNT(l.id) as license_count - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - WHERE c.id = %s - GROUP BY c.id, c.name, c.email - """, (customer_id,)) - - customer = cur.fetchone() - results = [] - if customer: - results.append({ - 'id': customer[0], - 'text': f"{customer[1]} ({customer[2]})", - 'name': customer[1], - 'email': customer[2], - 'license_count': customer[3] - }) - - cur.close() - conn.close() - - return jsonify({ - 'results': results, - 'pagination': {'more': False} - }) - - # SQL Query mit optionaler Suche - elif search: - cur.execute(""" - SELECT c.id, c.name, c.email, - COUNT(l.id) as license_count - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - WHERE LOWER(c.name) LIKE LOWER(%s) - OR LOWER(c.email) LIKE LOWER(%s) - GROUP BY c.id, c.name, c.email - ORDER BY c.name - LIMIT %s OFFSET %s - """, (f'%{search}%', f'%{search}%', per_page, (page - 1) * per_page)) - else: - cur.execute(""" - SELECT c.id, c.name, c.email, - COUNT(l.id) as license_count - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - GROUP BY c.id, c.name, c.email - ORDER BY c.name - LIMIT %s OFFSET %s - """, (per_page, (page - 1) * per_page)) - - customers = cur.fetchall() - - # Format für Select2 - results = [] - for customer in customers: - results.append({ - 'id': customer[0], - 'text': f"{customer[1]} - {customer[2]} ({customer[3]} Lizenzen)", - 'name': customer[1], - 'email': customer[2], - 'license_count': customer[3] - }) - - # Gesamtanzahl für Pagination - if search: - cur.execute(""" - SELECT COUNT(*) FROM customers - WHERE LOWER(name) LIKE LOWER(%s) - OR LOWER(email) LIKE LOWER(%s) - """, (f'%{search}%', f'%{search}%')) - else: - cur.execute("SELECT COUNT(*) FROM customers") - - total_count = cur.fetchone()[0] - - cur.close() - conn.close() - - # Select2 Response Format - return jsonify({ - 'results': results, - 'pagination': { - 'more': (page * per_page) < total_count - } - }) - - except Exception as e: - logging.error(f"Fehler bei Kundensuche: {str(e)}") - return jsonify({ - 'results': [], - 'error': 'Fehler bei der Kundensuche' - }), 500 - -# @app.route("/") -# @login_required -def dashboard(): - conn = get_connection() - cur = conn.cursor() - - # Statistiken abrufen - # Gesamtanzahl Kunden (ohne Testdaten) - cur.execute("SELECT COUNT(*) FROM customers WHERE is_test = FALSE") - total_customers = cur.fetchone()[0] - - # Gesamtanzahl Lizenzen (ohne Testdaten) - cur.execute("SELECT COUNT(*) FROM licenses WHERE is_test = FALSE") - total_licenses = cur.fetchone()[0] - - # Aktive Lizenzen (nicht abgelaufen und is_active = true, ohne Testdaten) - cur.execute(""" - SELECT COUNT(*) FROM licenses - WHERE valid_until >= CURRENT_DATE AND is_active = TRUE AND is_test = FALSE - """) - active_licenses = cur.fetchone()[0] - - # Aktive Sessions - cur.execute("SELECT COUNT(*) FROM sessions WHERE is_active = TRUE") - active_sessions_count = cur.fetchone()[0] - - # Abgelaufene Lizenzen (ohne Testdaten) - cur.execute(""" - SELECT COUNT(*) FROM licenses - WHERE valid_until < CURRENT_DATE AND is_test = FALSE - """) - expired_licenses = cur.fetchone()[0] - - # Deaktivierte Lizenzen (ohne Testdaten) - cur.execute(""" - SELECT COUNT(*) FROM licenses - WHERE is_active = FALSE AND is_test = FALSE - """) - inactive_licenses = cur.fetchone()[0] - - # Lizenzen die in den nächsten 30 Tagen ablaufen (ohne Testdaten) - cur.execute(""" - SELECT COUNT(*) FROM licenses - WHERE valid_until >= CURRENT_DATE - AND valid_until < CURRENT_DATE + INTERVAL '30 days' - AND is_active = TRUE - AND is_test = FALSE - """) - expiring_soon = cur.fetchone()[0] - - # Testlizenzen vs Vollversionen (ohne Testdaten) - cur.execute(""" - SELECT license_type, COUNT(*) - FROM licenses - WHERE is_test = FALSE - GROUP BY license_type - """) - license_types = dict(cur.fetchall()) - - # Anzahl Testdaten - cur.execute("SELECT COUNT(*) FROM licenses WHERE is_test = TRUE") - test_data_count = cur.fetchone()[0] - - # Anzahl Test-Kunden - cur.execute("SELECT COUNT(*) FROM customers WHERE is_test = TRUE") - test_customers_count = cur.fetchone()[0] - - # Anzahl Test-Ressourcen - cur.execute("SELECT COUNT(*) FROM resource_pools WHERE is_test = TRUE") - test_resources_count = cur.fetchone()[0] - - # Letzte 5 erstellten Lizenzen (ohne Testdaten) - cur.execute(""" - SELECT l.id, l.license_key, c.name, l.valid_until, - CASE - WHEN l.is_active = FALSE THEN 'deaktiviert' - WHEN l.valid_until < CURRENT_DATE THEN 'abgelaufen' - WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'läuft bald ab' - ELSE 'aktiv' - END as status - FROM licenses l - JOIN customers c ON l.customer_id = c.id - WHERE l.is_test = FALSE - ORDER BY l.id DESC - LIMIT 5 - """) - recent_licenses = cur.fetchall() - - # Bald ablaufende Lizenzen (nächste 30 Tage, ohne Testdaten) - cur.execute(""" - SELECT l.id, l.license_key, c.name, l.valid_until, - l.valid_until - CURRENT_DATE as days_left - FROM licenses l - JOIN customers c ON l.customer_id = c.id - WHERE l.valid_until >= CURRENT_DATE - AND l.valid_until < CURRENT_DATE + INTERVAL '30 days' - AND l.is_active = TRUE - AND l.is_test = FALSE - ORDER BY l.valid_until - LIMIT 10 - """) - expiring_licenses = cur.fetchall() - - # Letztes Backup - cur.execute(""" - SELECT created_at, filesize, duration_seconds, backup_type, status - FROM backup_history - ORDER BY created_at DESC - LIMIT 1 - """) - last_backup_info = cur.fetchone() - - # Sicherheitsstatistiken - # Gesperrte IPs - cur.execute(""" - SELECT COUNT(*) FROM login_attempts - WHERE blocked_until IS NOT NULL AND blocked_until > CURRENT_TIMESTAMP - """) - blocked_ips_count = cur.fetchone()[0] - - # Fehlversuche heute - cur.execute(""" - SELECT COALESCE(SUM(attempt_count), 0) FROM login_attempts - WHERE last_attempt::date = CURRENT_DATE - """) - failed_attempts_today = cur.fetchone()[0] - - # Letzte 5 Sicherheitsereignisse - cur.execute(""" - SELECT - la.ip_address, - la.attempt_count, - la.last_attempt, - la.blocked_until, - la.last_username_tried, - la.last_error_message - FROM login_attempts la - ORDER BY la.last_attempt DESC - LIMIT 5 - """) - recent_security_events = [] - for event in cur.fetchall(): - recent_security_events.append({ - 'ip_address': event[0], - 'attempt_count': event[1], - 'last_attempt': event[2].strftime('%d.%m %H:%M'), - 'blocked_until': event[3].strftime('%d.%m %H:%M') if event[3] and event[3] > datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None) else None, - 'username_tried': event[4], - 'error_message': event[5] - }) - - # Sicherheitslevel berechnen - if blocked_ips_count > 5 or failed_attempts_today > 50: - security_level = 'danger' - security_level_text = 'KRITISCH' - elif blocked_ips_count > 2 or failed_attempts_today > 20: - security_level = 'warning' - security_level_text = 'ERHÖHT' - else: - security_level = 'success' - security_level_text = 'NORMAL' - - # Resource Pool Statistiken (nur Live-Daten, keine Testdaten) - cur.execute(""" - SELECT - resource_type, - COUNT(*) FILTER (WHERE status = 'available') as available, - COUNT(*) FILTER (WHERE status = 'allocated') as allocated, - COUNT(*) FILTER (WHERE status = 'quarantine') as quarantine, - COUNT(*) as total - FROM resource_pools - WHERE is_test = FALSE - GROUP BY resource_type - """) - - resource_stats = {} - resource_warning = None - - for row in cur.fetchall(): - available_percent = round((row[1] / row[4] * 100) if row[4] > 0 else 0, 1) - resource_stats[row[0]] = { - 'available': row[1], - 'allocated': row[2], - 'quarantine': row[3], - 'total': row[4], - 'available_percent': available_percent, - 'warning_level': 'danger' if row[1] < 50 else 'warning' if row[1] < 100 else 'success' - } - - # Warnung bei niedrigem Bestand - if row[1] < 50: - if not resource_warning: - resource_warning = f"Niedriger Bestand bei {row[0].upper()}: nur noch {row[1]} verfügbar!" - else: - resource_warning += f" | {row[0].upper()}: {row[1]}" - - cur.close() - conn.close() - - stats = { - 'total_customers': total_customers, - 'total_licenses': total_licenses, - 'active_licenses': active_licenses, - 'expired_licenses': expired_licenses, - 'inactive_licenses': inactive_licenses, - 'expiring_soon': expiring_soon, - 'full_licenses': license_types.get('full', 0), - 'test_licenses': license_types.get('test', 0), - 'test_data_count': test_data_count, - 'test_customers_count': test_customers_count, - 'test_resources_count': test_resources_count, - 'recent_licenses': recent_licenses, - 'expiring_licenses': expiring_licenses, - 'active_sessions': active_sessions_count, - 'last_backup': last_backup_info, - # Sicherheitsstatistiken - 'blocked_ips_count': blocked_ips_count, - 'failed_attempts_today': failed_attempts_today, - 'recent_security_events': recent_security_events, - 'security_level': security_level, - 'security_level_text': security_level_text, - 'resource_stats': resource_stats - } - - return render_template("dashboard.html", - stats=stats, - resource_stats=resource_stats, - resource_warning=resource_warning, - username=session.get('username')) - -# @app.route("/create", methods=["GET", "POST"]) -# @login_required -def create_license(): - if request.method == "POST": - customer_id = request.form.get("customer_id") - license_key = request.form["license_key"].upper() # Immer Großbuchstaben - license_type = request.form["license_type"] - valid_from = request.form["valid_from"] - is_test = request.form.get("is_test") == "on" # Checkbox value - - # Berechne valid_until basierend auf Laufzeit - duration = int(request.form.get("duration", 1)) - duration_type = request.form.get("duration_type", "years") - - from datetime import datetime, timedelta - from dateutil.relativedelta import relativedelta - - start_date = datetime.strptime(valid_from, "%Y-%m-%d") - - if duration_type == "days": - end_date = start_date + timedelta(days=duration) - elif duration_type == "months": - end_date = start_date + relativedelta(months=duration) - else: # years - end_date = start_date + relativedelta(years=duration) - - # Ein Tag abziehen, da der Starttag mitgezählt wird - end_date = end_date - timedelta(days=1) - valid_until = end_date.strftime("%Y-%m-%d") - - # Validiere License Key Format - if not validate_license_key(license_key): - flash('Ungültiges License Key Format! Erwartet: AF-YYYYMMFT-XXXX-YYYY-ZZZZ', 'error') - return redirect(url_for('create_license')) - - # Resource counts - domain_count = int(request.form.get("domain_count", 1)) - ipv4_count = int(request.form.get("ipv4_count", 1)) - phone_count = int(request.form.get("phone_count", 1)) - device_limit = int(request.form.get("device_limit", 3)) - - conn = get_connection() - cur = conn.cursor() - - try: - # Prüfe ob neuer Kunde oder bestehender - if customer_id == "new": - # Neuer Kunde - name = request.form.get("customer_name") - email = request.form.get("email") - - if not name: - flash('Kundenname ist erforderlich!', 'error') - return redirect(url_for('create_license')) - - # Prüfe ob E-Mail bereits existiert - if email: - cur.execute("SELECT id, name FROM customers WHERE LOWER(email) = LOWER(%s)", (email,)) - existing = cur.fetchone() - if existing: - flash(f'E-Mail bereits vergeben für Kunde: {existing[1]}', 'error') - return redirect(url_for('create_license')) - - # Kunde einfügen (erbt Test-Status von Lizenz) - cur.execute(""" - INSERT INTO customers (name, email, is_test, created_at) - VALUES (%s, %s, %s, NOW()) - RETURNING id - """, (name, email, is_test)) - customer_id = cur.fetchone()[0] - customer_info = {'name': name, 'email': email, 'is_test': is_test} - - # Audit-Log für neuen Kunden - log_audit('CREATE', 'customer', customer_id, - new_values={'name': name, 'email': email, 'is_test': is_test}) - else: - # Bestehender Kunde - hole Infos für Audit-Log - cur.execute("SELECT name, email, is_test FROM customers WHERE id = %s", (customer_id,)) - customer_data = cur.fetchone() - if not customer_data: - flash('Kunde nicht gefunden!', 'error') - return redirect(url_for('create_license')) - customer_info = {'name': customer_data[0], 'email': customer_data[1]} - - # Wenn Kunde Test-Kunde ist, Lizenz auch als Test markieren - if customer_data[2]: # is_test des Kunden - is_test = True - - # Lizenz hinzufügen - cur.execute(""" - INSERT INTO licenses (license_key, customer_id, license_type, valid_from, valid_until, is_active, - domain_count, ipv4_count, phone_count, device_limit, is_test) - VALUES (%s, %s, %s, %s, %s, TRUE, %s, %s, %s, %s, %s) - RETURNING id - """, (license_key, customer_id, license_type, valid_from, valid_until, - domain_count, ipv4_count, phone_count, device_limit, is_test)) - license_id = cur.fetchone()[0] - - # Ressourcen zuweisen - try: - # Prüfe Verfügbarkeit - cur.execute(""" - SELECT - (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s) as domains, - (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s) as ipv4s, - (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s) as phones - """, (is_test, is_test, is_test)) - available = cur.fetchone() - - if available[0] < domain_count: - raise ValueError(f"Nicht genügend Domains verfügbar (benötigt: {domain_count}, verfügbar: {available[0]})") - if available[1] < ipv4_count: - raise ValueError(f"Nicht genügend IPv4-Adressen verfügbar (benötigt: {ipv4_count}, verfügbar: {available[1]})") - if available[2] < phone_count: - raise ValueError(f"Nicht genügend Telefonnummern verfügbar (benötigt: {phone_count}, verfügbar: {available[2]})") - - # Domains zuweisen - if domain_count > 0: - cur.execute(""" - SELECT id FROM resource_pools - WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s - LIMIT %s FOR UPDATE - """, (is_test, domain_count)) - for (resource_id,) in cur.fetchall(): - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], resource_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, resource_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (resource_id, license_id, session['username'], get_client_ip())) - - # IPv4s zuweisen - if ipv4_count > 0: - cur.execute(""" - SELECT id FROM resource_pools - WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s - LIMIT %s FOR UPDATE - """, (is_test, ipv4_count)) - for (resource_id,) in cur.fetchall(): - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], resource_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, resource_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (resource_id, license_id, session['username'], get_client_ip())) - - # Telefonnummern zuweisen - if phone_count > 0: - cur.execute(""" - SELECT id FROM resource_pools - WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s - LIMIT %s FOR UPDATE - """, (is_test, phone_count)) - for (resource_id,) in cur.fetchall(): - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], resource_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, resource_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (resource_id, license_id, session['username'], get_client_ip())) - - except ValueError as e: - conn.rollback() - flash(str(e), 'error') - return redirect(url_for('create_license')) - - conn.commit() - - # Audit-Log - log_audit('CREATE', 'license', license_id, - new_values={ - 'license_key': license_key, - 'customer_name': customer_info['name'], - 'customer_email': customer_info['email'], - 'license_type': license_type, - 'valid_from': valid_from, - 'valid_until': valid_until, - 'device_limit': device_limit, - 'is_test': is_test - }) - - flash(f'Lizenz {license_key} erfolgreich erstellt!', 'success') - - except Exception as e: - conn.rollback() - logging.error(f"Fehler beim Erstellen der Lizenz: {str(e)}") - flash('Fehler beim Erstellen der Lizenz!', 'error') - finally: - cur.close() - conn.close() - - # Preserve show_test parameter if present - redirect_url = "/create" - if request.args.get('show_test') == 'true': - redirect_url += "?show_test=true" - return redirect(redirect_url) - - # Unterstützung für vorausgewählten Kunden - preselected_customer_id = request.args.get('customer_id', type=int) - return render_template("index.html", username=session.get('username'), preselected_customer_id=preselected_customer_id) - -# @app.route("/batch", methods=["GET", "POST"]) -# @login_required -def batch_licenses(): - """Batch-Generierung mehrerer Lizenzen für einen Kunden""" - if request.method == "POST": - # Formulardaten - customer_id = request.form.get("customer_id") - license_type = request.form["license_type"] - quantity = int(request.form["quantity"]) - valid_from = request.form["valid_from"] - is_test = request.form.get("is_test") == "on" # Checkbox value - - # Berechne valid_until basierend auf Laufzeit - duration = int(request.form.get("duration", 1)) - duration_type = request.form.get("duration_type", "years") - - from datetime import datetime, timedelta - from dateutil.relativedelta import relativedelta - - start_date = datetime.strptime(valid_from, "%Y-%m-%d") - - if duration_type == "days": - end_date = start_date + timedelta(days=duration) - elif duration_type == "months": - end_date = start_date + relativedelta(months=duration) - else: # years - end_date = start_date + relativedelta(years=duration) - - # Ein Tag abziehen, da der Starttag mitgezählt wird - end_date = end_date - timedelta(days=1) - valid_until = end_date.strftime("%Y-%m-%d") - - # Resource counts - domain_count = int(request.form.get("domain_count", 1)) - ipv4_count = int(request.form.get("ipv4_count", 1)) - phone_count = int(request.form.get("phone_count", 1)) - device_limit = int(request.form.get("device_limit", 3)) - - # Sicherheitslimit - if quantity < 1 or quantity > 100: - flash('Anzahl muss zwischen 1 und 100 liegen!', 'error') - return redirect(url_for('batch_licenses')) - - conn = get_connection() - cur = conn.cursor() - - try: - # Prüfe ob neuer Kunde oder bestehender - if customer_id == "new": - # Neuer Kunde - name = request.form.get("customer_name") - email = request.form.get("email") - - if not name: - flash('Kundenname ist erforderlich!', 'error') - return redirect(url_for('batch_licenses')) - - # Prüfe ob E-Mail bereits existiert - if email: - cur.execute("SELECT id, name FROM customers WHERE LOWER(email) = LOWER(%s)", (email,)) - existing = cur.fetchone() - if existing: - flash(f'E-Mail bereits vergeben für Kunde: {existing[1]}', 'error') - return redirect(url_for('batch_licenses')) - - # Kunde einfügen (erbt Test-Status von Lizenz) - cur.execute(""" - INSERT INTO customers (name, email, is_test, created_at) - VALUES (%s, %s, %s, NOW()) - RETURNING id - """, (name, email, is_test)) - customer_id = cur.fetchone()[0] - - # Audit-Log für neuen Kunden - log_audit('CREATE', 'customer', customer_id, - new_values={'name': name, 'email': email, 'is_test': is_test}) - else: - # Bestehender Kunde - hole Infos - cur.execute("SELECT name, email, is_test FROM customers WHERE id = %s", (customer_id,)) - customer_data = cur.fetchone() - if not customer_data: - flash('Kunde nicht gefunden!', 'error') - return redirect(url_for('batch_licenses')) - name = customer_data[0] - email = customer_data[1] - - # Wenn Kunde Test-Kunde ist, Lizenzen auch als Test markieren - if customer_data[2]: # is_test des Kunden - is_test = True - - # Prüfe Ressourcen-Verfügbarkeit für gesamten Batch - total_domains_needed = domain_count * quantity - total_ipv4s_needed = ipv4_count * quantity - total_phones_needed = phone_count * quantity - - cur.execute(""" - SELECT - (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s) as domains, - (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s) as ipv4s, - (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s) as phones - """, (is_test, is_test, is_test)) - available = cur.fetchone() - - if available[0] < total_domains_needed: - flash(f"Nicht genügend Domains verfügbar (benötigt: {total_domains_needed}, verfügbar: {available[0]})", 'error') - return redirect(url_for('batch_licenses')) - if available[1] < total_ipv4s_needed: - flash(f"Nicht genügend IPv4-Adressen verfügbar (benötigt: {total_ipv4s_needed}, verfügbar: {available[1]})", 'error') - return redirect(url_for('batch_licenses')) - if available[2] < total_phones_needed: - flash(f"Nicht genügend Telefonnummern verfügbar (benötigt: {total_phones_needed}, verfügbar: {available[2]})", 'error') - return redirect(url_for('batch_licenses')) - - # Lizenzen generieren und speichern - generated_licenses = [] - for i in range(quantity): - # Eindeutigen Key generieren - attempts = 0 - while attempts < 10: - license_key = generate_license_key(license_type) - cur.execute("SELECT 1 FROM licenses WHERE license_key = %s", (license_key,)) - if not cur.fetchone(): - break - attempts += 1 - - # Lizenz einfügen - cur.execute(""" - INSERT INTO licenses (license_key, customer_id, license_type, is_test, - valid_from, valid_until, is_active, - domain_count, ipv4_count, phone_count, device_limit) - VALUES (%s, %s, %s, %s, %s, %s, true, %s, %s, %s, %s) - RETURNING id - """, (license_key, customer_id, license_type, is_test, valid_from, valid_until, - domain_count, ipv4_count, phone_count, device_limit)) - license_id = cur.fetchone()[0] - - # Ressourcen für diese Lizenz zuweisen - # Domains - if domain_count > 0: - cur.execute(""" - SELECT id FROM resource_pools - WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s - LIMIT %s FOR UPDATE - """, (is_test, domain_count)) - for (resource_id,) in cur.fetchall(): - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], resource_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, resource_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (resource_id, license_id, session['username'], get_client_ip())) - - # IPv4s - if ipv4_count > 0: - cur.execute(""" - SELECT id FROM resource_pools - WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s - LIMIT %s FOR UPDATE - """, (is_test, ipv4_count)) - for (resource_id,) in cur.fetchall(): - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], resource_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, resource_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (resource_id, license_id, session['username'], get_client_ip())) - - # Telefonnummern - if phone_count > 0: - cur.execute(""" - SELECT id FROM resource_pools - WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s - LIMIT %s FOR UPDATE - """, (is_test, phone_count)) - for (resource_id,) in cur.fetchall(): - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], resource_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, resource_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (resource_id, license_id, session['username'], get_client_ip())) - - generated_licenses.append({ - 'id': license_id, - 'key': license_key, - 'type': license_type - }) - - conn.commit() - - # Audit-Log - log_audit('CREATE_BATCH', 'license', - new_values={'customer': name, 'quantity': quantity, 'type': license_type}, - additional_info=f"Batch-Generierung von {quantity} Lizenzen") - - # Session für Export speichern - session['batch_export'] = { - 'customer': name, - 'email': email, - 'licenses': generated_licenses, - 'valid_from': valid_from, - 'valid_until': valid_until, - 'timestamp': datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() - } - - flash(f'{quantity} Lizenzen erfolgreich generiert!', 'success') - return render_template("batch_result.html", - customer=name, - email=email, - licenses=generated_licenses, - valid_from=valid_from, - valid_until=valid_until) - - except Exception as e: - conn.rollback() - logging.error(f"Fehler bei Batch-Generierung: {str(e)}") - flash('Fehler bei der Batch-Generierung!', 'error') - return redirect(url_for('batch_licenses')) - finally: - cur.close() - conn.close() - - # GET Request - return render_template("batch_form.html") - -# @app.route("/batch/export") -# @login_required -def export_batch(): - """Exportiert die zuletzt generierten Batch-Lizenzen""" - batch_data = session.get('batch_export') - if not batch_data: - flash('Keine Batch-Daten zum Exportieren vorhanden!', 'error') - return redirect(url_for('batch_licenses')) - - # CSV generieren - output = io.StringIO() - output.write('\ufeff') # UTF-8 BOM für Excel - - # Header - output.write(f"Kunde: {batch_data['customer']}\n") - output.write(f"E-Mail: {batch_data['email']}\n") - output.write(f"Generiert am: {datetime.fromisoformat(batch_data['timestamp']).strftime('%d.%m.%Y %H:%M')}\n") - output.write(f"Gültig von: {batch_data['valid_from']} bis {batch_data['valid_until']}\n") - output.write("\n") - output.write("Nr;Lizenzschlüssel;Typ\n") - - # Lizenzen - for i, license in enumerate(batch_data['licenses'], 1): - typ_text = "Vollversion" if license['type'] == 'full' else "Testversion" - output.write(f"{i};{license['key']};{typ_text}\n") - - output.seek(0) - - # Audit-Log - log_audit('EXPORT', 'batch_licenses', - additional_info=f"Export von {len(batch_data['licenses'])} Batch-Lizenzen") - - return send_file( - io.BytesIO(output.getvalue().encode('utf-8-sig')), - mimetype='text/csv', - as_attachment=True, - download_name=f"batch_licenses_{batch_data['customer'].replace(' ', '_')}_{datetime.now(ZoneInfo('Europe/Berlin')).strftime('%Y%m%d_%H%M%S')}.csv" - ) - -# @app.route("/licenses") -# @login_required -def licenses(): - # Redirect zur kombinierten Ansicht - return redirect("/customers-licenses") - -# @app.route("/license/edit/", methods=["GET", "POST"]) -# @login_required -def edit_license(license_id): - conn = get_connection() - cur = conn.cursor() - - if request.method == "POST": - # Alte Werte für Audit-Log abrufen - cur.execute(""" - SELECT license_key, license_type, valid_from, valid_until, is_active, is_test, device_limit - FROM licenses WHERE id = %s - """, (license_id,)) - old_license = cur.fetchone() - - # Update license - license_key = request.form["license_key"] - license_type = request.form["license_type"] - valid_from = request.form["valid_from"] - valid_until = request.form["valid_until"] - is_active = request.form.get("is_active") == "on" - is_test = request.form.get("is_test") == "on" - device_limit = int(request.form.get("device_limit", 3)) - - cur.execute(""" - UPDATE licenses - SET license_key = %s, license_type = %s, valid_from = %s, - valid_until = %s, is_active = %s, is_test = %s, device_limit = %s - WHERE id = %s - """, (license_key, license_type, valid_from, valid_until, is_active, is_test, device_limit, license_id)) - - conn.commit() - - # Audit-Log - log_audit('UPDATE', 'license', license_id, - old_values={ - 'license_key': old_license[0], - 'license_type': old_license[1], - 'valid_from': str(old_license[2]), - 'valid_until': str(old_license[3]), - 'is_active': old_license[4], - 'is_test': old_license[5], - 'device_limit': old_license[6] - }, - new_values={ - 'license_key': license_key, - 'license_type': license_type, - 'valid_from': valid_from, - 'valid_until': valid_until, - 'is_active': is_active, - 'is_test': is_test, - 'device_limit': device_limit - }) - - cur.close() - conn.close() - - # Redirect zurück zu customers-licenses mit beibehaltenen Parametern - redirect_url = "/customers-licenses" - - # Behalte show_test Parameter bei (aus Form oder GET-Parameter) - show_test = request.form.get('show_test') or request.args.get('show_test') - if show_test == 'true': - redirect_url += "?show_test=true" - - # Behalte customer_id bei wenn vorhanden - if request.referrer and 'customer_id=' in request.referrer: - import re - match = re.search(r'customer_id=(\d+)', request.referrer) - if match: - connector = "&" if "?" in redirect_url else "?" - redirect_url += f"{connector}customer_id={match.group(1)}" - - return redirect(redirect_url) - - # Get license data - cur.execute(""" - SELECT l.id, l.license_key, c.name, c.email, l.license_type, - l.valid_from, l.valid_until, l.is_active, c.id, l.is_test, l.device_limit - FROM licenses l - JOIN customers c ON l.customer_id = c.id - WHERE l.id = %s - """, (license_id,)) - - license = cur.fetchone() - cur.close() - conn.close() - - if not license: - return redirect("/licenses") - - return render_template("edit_license.html", license=license, username=session.get('username')) - -# @app.route("/license/delete/", methods=["POST"]) -# @login_required -def delete_license(license_id): - conn = get_connection() - cur = conn.cursor() - - # Lizenzdetails für Audit-Log abrufen - cur.execute(""" - SELECT l.license_key, c.name, l.license_type - FROM licenses l - JOIN customers c ON l.customer_id = c.id - WHERE l.id = %s - """, (license_id,)) - license_info = cur.fetchone() - - cur.execute("DELETE FROM licenses WHERE id = %s", (license_id,)) - - conn.commit() - - # Audit-Log - if license_info: - log_audit('DELETE', 'license', license_id, - old_values={ - 'license_key': license_info[0], - 'customer_name': license_info[1], - 'license_type': license_info[2] - }) - - cur.close() - conn.close() - - return redirect("/licenses") - -# @app.route("/customers") -# @login_required -def customers(): - # Redirect zur kombinierten Ansicht - return redirect("/customers-licenses") - -# @app.route("/customer/edit/", methods=["GET", "POST"]) -# @login_required -def edit_customer(customer_id): - conn = get_connection() - cur = conn.cursor() - - if request.method == "POST": - # Alte Werte für Audit-Log abrufen - cur.execute("SELECT name, email, is_test FROM customers WHERE id = %s", (customer_id,)) - old_customer = cur.fetchone() - - # Update customer - name = request.form["name"] - email = request.form["email"] - is_test = request.form.get("is_test") == "on" - - cur.execute(""" - UPDATE customers - SET name = %s, email = %s, is_test = %s - WHERE id = %s - """, (name, email, is_test, customer_id)) - - conn.commit() - - # Audit-Log - log_audit('UPDATE', 'customer', customer_id, - old_values={ - 'name': old_customer[0], - 'email': old_customer[1], - 'is_test': old_customer[2] - }, - new_values={ - 'name': name, - 'email': email, - 'is_test': is_test - }) - - cur.close() - conn.close() - - # Redirect zurück zu customers-licenses mit beibehaltenen Parametern - redirect_url = "/customers-licenses" - - # Behalte show_test Parameter bei (aus Form oder GET-Parameter) - show_test = request.form.get('show_test') or request.args.get('show_test') - if show_test == 'true': - redirect_url += "?show_test=true" - - # Behalte customer_id bei (immer der aktuelle Kunde) - connector = "&" if "?" in redirect_url else "?" - redirect_url += f"{connector}customer_id={customer_id}" - - return redirect(redirect_url) - - # Get customer data with licenses - cur.execute(""" - SELECT id, name, email, created_at, is_test FROM customers WHERE id = %s - """, (customer_id,)) - - customer = cur.fetchone() - if not customer: - cur.close() - conn.close() - return "Kunde nicht gefunden", 404 - - - # Get customer's licenses - cur.execute(""" - SELECT id, license_key, license_type, valid_from, valid_until, is_active - FROM licenses - WHERE customer_id = %s - ORDER BY valid_until DESC - """, (customer_id,)) - - licenses = cur.fetchall() - - cur.close() - conn.close() - - if not customer: - return redirect("/customers-licenses") - - return render_template("edit_customer.html", customer=customer, licenses=licenses, username=session.get('username')) - -# @app.route("/customer/create", methods=["GET", "POST"]) -# @login_required -def create_customer(): - """Erstellt einen neuen Kunden ohne Lizenz""" - if request.method == "POST": - name = request.form.get('name') - email = request.form.get('email') - is_test = request.form.get('is_test') == 'on' - - if not name or not email: - flash("Name und E-Mail sind Pflichtfelder!", "error") - return render_template("create_customer.html", username=session.get('username')) - - conn = get_connection() - cur = conn.cursor() - - try: - # Prüfen ob E-Mail bereits existiert - cur.execute("SELECT id, name FROM customers WHERE email = %s", (email,)) - existing = cur.fetchone() - if existing: - flash(f"Ein Kunde mit der E-Mail '{email}' existiert bereits: {existing[1]}", "error") - return render_template("create_customer.html", username=session.get('username')) - - # Kunde erstellen - cur.execute(""" - INSERT INTO customers (name, email, created_at, is_test) - VALUES (%s, %s, %s, %s) RETURNING id - """, (name, email, datetime.now(), is_test)) - - customer_id = cur.fetchone()[0] - conn.commit() - - # Audit-Log - log_audit('CREATE', 'customer', customer_id, - new_values={ - 'name': name, - 'email': email, - 'is_test': is_test - }) - - flash(f"Kunde '{name}' wurde erfolgreich angelegt!", "success") - return redirect(f"/customer/edit/{customer_id}") - - except Exception as e: - conn.rollback() - flash(f"Fehler beim Anlegen des Kunden: {str(e)}", "error") - return render_template("create_customer.html", username=session.get('username')) - finally: - cur.close() - conn.close() - - # GET Request - Formular anzeigen - return render_template("create_customer.html", username=session.get('username')) - -# @app.route("/customer/delete/", methods=["POST"]) -# @login_required -def delete_customer(customer_id): - conn = get_connection() - cur = conn.cursor() - - # Prüfen ob Kunde Lizenzen hat - cur.execute("SELECT COUNT(*) FROM licenses WHERE customer_id = %s", (customer_id,)) - license_count = cur.fetchone()[0] - - if license_count > 0: - # Kunde hat Lizenzen - nicht löschen - cur.close() - conn.close() - return redirect("/customers") - - # Kundendetails für Audit-Log abrufen - cur.execute("SELECT name, email FROM customers WHERE id = %s", (customer_id,)) - customer_info = cur.fetchone() - - # Kunde löschen wenn keine Lizenzen vorhanden - cur.execute("DELETE FROM customers WHERE id = %s", (customer_id,)) - - conn.commit() - - # Audit-Log - if customer_info: - log_audit('DELETE', 'customer', customer_id, - old_values={ - 'name': customer_info[0], - 'email': customer_info[1] - }) - - cur.close() - conn.close() - - return redirect("/customers") - -# @app.route("/customers-licenses") -# @login_required -def customers_licenses(): - """Kombinierte Ansicht für Kunden und deren Lizenzen""" - conn = get_connection() - cur = conn.cursor() - - # Hole alle Kunden mit Lizenzstatistiken (inkl. Testkunden wenn gewünscht) - show_test = request.args.get('show_test', 'false').lower() == 'true' - - query = """ - SELECT - c.id, - c.name, - c.email, - c.created_at, - COUNT(l.id) as total_licenses, - COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE THEN 1 END) as active_licenses, - COUNT(CASE WHEN l.valid_until < CURRENT_DATE THEN 1 END) as expired_licenses - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - """ - - if not show_test: - query += " WHERE c.is_test = FALSE" - - query += """ - GROUP BY c.id, c.name, c.email, c.created_at - ORDER BY c.name - """ - - cur.execute(query) - customers = cur.fetchall() - - # Hole ausgewählten Kunden nur wenn explizit in URL angegeben - selected_customer_id = request.args.get('customer_id', type=int) - licenses = [] - selected_customer = None - - if customers and selected_customer_id: - # Hole Daten des ausgewählten Kunden - for customer in customers: - if customer[0] == selected_customer_id: - selected_customer = customer - break - - # Hole Lizenzen des ausgewählten Kunden - if selected_customer: - cur.execute(""" - SELECT - l.id, - l.license_key, - l.license_type, - l.valid_from, - l.valid_until, - l.is_active, - CASE - WHEN l.is_active = FALSE THEN 'deaktiviert' - WHEN l.valid_until < CURRENT_DATE THEN 'abgelaufen' - WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'läuft bald ab' - ELSE 'aktiv' - END as status, - l.domain_count, - l.ipv4_count, - l.phone_count, - l.device_limit, - (SELECT COUNT(*) FROM device_registrations WHERE license_id = l.id AND is_active = TRUE) as active_devices, - -- Actual resource counts - (SELECT COUNT(*) FROM license_resources lr - JOIN resource_pools rp ON lr.resource_id = rp.id - WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'domain') as actual_domain_count, - (SELECT COUNT(*) FROM license_resources lr - JOIN resource_pools rp ON lr.resource_id = rp.id - WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'ipv4') as actual_ipv4_count, - (SELECT COUNT(*) FROM license_resources lr - JOIN resource_pools rp ON lr.resource_id = rp.id - WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'phone') as actual_phone_count - FROM licenses l - WHERE l.customer_id = %s - ORDER BY l.created_at DESC, l.id DESC - """, (selected_customer_id,)) - licenses = cur.fetchall() - - cur.close() - conn.close() - - return render_template("customers_licenses.html", - customers=customers, - selected_customer=selected_customer, - selected_customer_id=selected_customer_id, - licenses=licenses, - show_test=show_test) - -# @app.route("/api/customer//licenses") -# @login_required -def api_customer_licenses(customer_id): - """API-Endpoint für AJAX-Abruf der Lizenzen eines Kunden""" - conn = get_connection() - cur = conn.cursor() - - # Hole Lizenzen des Kunden - cur.execute(""" - SELECT - l.id, - l.license_key, - l.license_type, - l.valid_from, - l.valid_until, - l.is_active, - CASE - WHEN l.is_active = FALSE THEN 'deaktiviert' - WHEN l.valid_until < CURRENT_DATE THEN 'abgelaufen' - WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'läuft bald ab' - ELSE 'aktiv' - END as status, - l.domain_count, - l.ipv4_count, - l.phone_count, - l.device_limit, - (SELECT COUNT(*) FROM device_registrations WHERE license_id = l.id AND is_active = TRUE) as active_devices, - -- Actual resource counts - (SELECT COUNT(*) FROM license_resources lr - JOIN resource_pools rp ON lr.resource_id = rp.id - WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'domain') as actual_domain_count, - (SELECT COUNT(*) FROM license_resources lr - JOIN resource_pools rp ON lr.resource_id = rp.id - WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'ipv4') as actual_ipv4_count, - (SELECT COUNT(*) FROM license_resources lr - JOIN resource_pools rp ON lr.resource_id = rp.id - WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'phone') as actual_phone_count - FROM licenses l - WHERE l.customer_id = %s - ORDER BY l.created_at DESC, l.id DESC - """, (customer_id,)) - - licenses = [] - for row in cur.fetchall(): - license_id = row[0] - - # Hole die konkreten zugewiesenen Ressourcen für diese Lizenz - cur.execute(""" - SELECT rp.id, rp.resource_type, rp.resource_value, lr.assigned_at - FROM resource_pools rp - JOIN license_resources lr ON rp.id = lr.resource_id - WHERE lr.license_id = %s AND lr.is_active = true - ORDER BY rp.resource_type, rp.resource_value - """, (license_id,)) - - resources = { - 'domains': [], - 'ipv4s': [], - 'phones': [] - } - - for res_row in cur.fetchall(): - resource_info = { - 'id': res_row[0], - 'value': res_row[2], - 'assigned_at': res_row[3].strftime('%d.%m.%Y') if res_row[3] else '' - } - - if res_row[1] == 'domain': - resources['domains'].append(resource_info) - elif res_row[1] == 'ipv4': - resources['ipv4s'].append(resource_info) - elif res_row[1] == 'phone': - resources['phones'].append(resource_info) - - licenses.append({ - 'id': row[0], - 'license_key': row[1], - 'license_type': row[2], - 'valid_from': row[3].strftime('%d.%m.%Y') if row[3] else '', - 'valid_until': row[4].strftime('%d.%m.%Y') if row[4] else '', - 'is_active': row[5], - 'status': row[6], - 'domain_count': row[7], # limit - 'ipv4_count': row[8], # limit - 'phone_count': row[9], # limit - 'device_limit': row[10], - 'active_devices': row[11], - 'actual_domain_count': row[12], # actual count - 'actual_ipv4_count': row[13], # actual count - 'actual_phone_count': row[14], # actual count - 'resources': resources - }) - - cur.close() - conn.close() - - return jsonify({ - 'success': True, - 'licenses': licenses, - 'count': len(licenses) - }) - -# @app.route("/api/customer//quick-stats") -# @login_required -def api_customer_quick_stats(customer_id): - """API-Endpoint für Schnellstatistiken eines Kunden""" - conn = get_connection() - cur = conn.cursor() - - # Hole Kundenstatistiken - cur.execute(""" - SELECT - COUNT(l.id) as total_licenses, - COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE THEN 1 END) as active_licenses, - COUNT(CASE WHEN l.valid_until < CURRENT_DATE THEN 1 END) as expired_licenses, - COUNT(CASE WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' AND l.valid_until >= CURRENT_DATE THEN 1 END) as expiring_soon - FROM licenses l - WHERE l.customer_id = %s - """, (customer_id,)) - - stats = cur.fetchone() - - cur.close() - conn.close() - - return jsonify({ - 'success': True, - 'stats': { - 'total': stats[0], - 'active': stats[1], - 'expired': stats[2], - 'expiring_soon': stats[3] - } - }) - -# @app.route("/api/license//quick-edit", methods=['POST']) -# @login_required -def api_license_quick_edit(license_id): - """API-Endpoint für schnelle Lizenz-Bearbeitung""" - conn = get_connection() - cur = conn.cursor() - - try: - data = request.get_json() - - # Hole alte Werte für Audit-Log - cur.execute(""" - SELECT is_active, valid_until, license_type - FROM licenses WHERE id = %s - """, (license_id,)) - old_values = cur.fetchone() - - if not old_values: - return jsonify({'success': False, 'error': 'Lizenz nicht gefunden'}), 404 - - # Update-Felder vorbereiten - updates = [] - params = [] - new_values = {} - - if 'is_active' in data: - updates.append("is_active = %s") - params.append(data['is_active']) - new_values['is_active'] = data['is_active'] - - if 'valid_until' in data: - updates.append("valid_until = %s") - params.append(data['valid_until']) - new_values['valid_until'] = data['valid_until'] - - if 'license_type' in data: - updates.append("license_type = %s") - params.append(data['license_type']) - new_values['license_type'] = data['license_type'] - - if updates: - params.append(license_id) - cur.execute(f""" - UPDATE licenses - SET {', '.join(updates)} - WHERE id = %s - """, params) - - conn.commit() - - # Audit-Log - log_audit('UPDATE', 'license', license_id, - old_values={ - 'is_active': old_values[0], - 'valid_until': old_values[1].isoformat() if old_values[1] else None, - 'license_type': old_values[2] - }, - new_values=new_values) - - cur.close() - conn.close() - - return jsonify({'success': True}) - - except Exception as e: - conn.rollback() - cur.close() - conn.close() - return jsonify({'success': False, 'error': str(e)}), 500 - -# @app.route("/api/license//resources") -# @login_required -def api_license_resources(license_id): - """API-Endpoint für detaillierte Ressourcen-Informationen einer Lizenz""" - conn = get_connection() - cur = conn.cursor() - - try: - # Hole die konkreten zugewiesenen Ressourcen für diese Lizenz - cur.execute(""" - SELECT rp.id, rp.resource_type, rp.resource_value, lr.assigned_at - FROM resource_pools rp - JOIN license_resources lr ON rp.id = lr.resource_id - WHERE lr.license_id = %s AND lr.is_active = true - ORDER BY rp.resource_type, rp.resource_value - """, (license_id,)) - - resources = { - 'domains': [], - 'ipv4s': [], - 'phones': [] - } - - for row in cur.fetchall(): - resource_info = { - 'id': row[0], - 'value': row[2], - 'assigned_at': row[3].strftime('%d.%m.%Y') if row[3] else '' - } - - if row[1] == 'domain': - resources['domains'].append(resource_info) - elif row[1] == 'ipv4': - resources['ipv4s'].append(resource_info) - elif row[1] == 'phone': - resources['phones'].append(resource_info) - - cur.close() - conn.close() - - return jsonify({ - 'success': True, - 'resources': resources - }) - - except Exception as e: - cur.close() - conn.close() - return jsonify({'success': False, 'error': str(e)}), 500 - -# @app.route("/sessions") -# @login_required -def sessions(): - conn = get_connection() - cur = conn.cursor() - - # Sortierparameter - active_sort = request.args.get('active_sort', 'last_heartbeat') - active_order = request.args.get('active_order', 'desc') - ended_sort = request.args.get('ended_sort', 'ended_at') - ended_order = request.args.get('ended_order', 'desc') - - # Whitelist für erlaubte Sortierfelder - Aktive Sessions - active_sort_fields = { - 'customer': 'c.name', - 'license': 'l.license_key', - 'ip': 's.ip_address', - 'started': 's.started_at', - 'last_heartbeat': 's.last_heartbeat', - 'inactive': 'minutes_inactive' - } - - # Whitelist für erlaubte Sortierfelder - Beendete Sessions - ended_sort_fields = { - 'customer': 'c.name', - 'license': 'l.license_key', - 'ip': 's.ip_address', - 'started': 's.started_at', - 'ended_at': 's.ended_at', - 'duration': 'duration_minutes' - } - - # Validierung - if active_sort not in active_sort_fields: - active_sort = 'last_heartbeat' - if ended_sort not in ended_sort_fields: - ended_sort = 'ended_at' - if active_order not in ['asc', 'desc']: - active_order = 'desc' - if ended_order not in ['asc', 'desc']: - ended_order = 'desc' - - # Aktive Sessions abrufen - cur.execute(f""" - SELECT s.id, s.session_id, l.license_key, c.name, s.ip_address, - s.user_agent, s.started_at, s.last_heartbeat, - EXTRACT(EPOCH FROM (NOW() - s.last_heartbeat))/60 as minutes_inactive - FROM sessions s - JOIN licenses l ON s.license_id = l.id - JOIN customers c ON l.customer_id = c.id - WHERE s.is_active = TRUE - ORDER BY {active_sort_fields[active_sort]} {active_order.upper()} - """) - active_sessions = cur.fetchall() - - # Inaktive Sessions der letzten 24 Stunden - cur.execute(f""" - SELECT s.id, s.session_id, l.license_key, c.name, s.ip_address, - s.started_at, s.ended_at, - EXTRACT(EPOCH FROM (s.ended_at - s.started_at))/60 as duration_minutes - FROM sessions s - JOIN licenses l ON s.license_id = l.id - JOIN customers c ON l.customer_id = c.id - WHERE s.is_active = FALSE - AND s.ended_at > NOW() - INTERVAL '24 hours' - ORDER BY {ended_sort_fields[ended_sort]} {ended_order.upper()} - LIMIT 50 - """) - recent_sessions = cur.fetchall() - - cur.close() - conn.close() - - return render_template("sessions.html", - active_sessions=active_sessions, - recent_sessions=recent_sessions, - active_sort=active_sort, - active_order=active_order, - ended_sort=ended_sort, - ended_order=ended_order, - username=session.get('username')) - -# @app.route("/session/end/", methods=["POST"]) -# @login_required -def end_session(session_id): - conn = get_connection() - cur = conn.cursor() - - # Session beenden - cur.execute(""" - UPDATE sessions - SET is_active = FALSE, ended_at = NOW() - WHERE id = %s AND is_active = TRUE - """, (session_id,)) - - conn.commit() - cur.close() - conn.close() - - return redirect("/sessions") - -# @app.route("/export/licenses") -# @login_required -def export_licenses(): - conn = get_connection() - cur = conn.cursor() - - # Alle Lizenzen mit Kundeninformationen abrufen (ohne Testdaten, außer explizit gewünscht) - include_test = request.args.get('include_test', 'false').lower() == 'true' - customer_id = request.args.get('customer_id', type=int) - - query = """ - SELECT l.id, l.license_key, c.name as customer_name, c.email as customer_email, - l.license_type, l.valid_from, l.valid_until, l.is_active, l.is_test, - CASE - WHEN l.is_active = FALSE THEN 'Deaktiviert' - WHEN l.valid_until < CURRENT_DATE THEN 'Abgelaufen' - WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'Läuft bald ab' - ELSE 'Aktiv' - END as status - FROM licenses l - JOIN customers c ON l.customer_id = c.id - """ - - # Build WHERE clause - where_conditions = [] - params = [] - - if not include_test: - where_conditions.append("l.is_test = FALSE") - - if customer_id: - where_conditions.append("l.customer_id = %s") - params.append(customer_id) - - if where_conditions: - query += " WHERE " + " AND ".join(where_conditions) - - query += " ORDER BY l.id" - - cur.execute(query, params) - - # Spaltennamen - columns = ['ID', 'Lizenzschlüssel', 'Kunde', 'E-Mail', 'Typ', - 'Gültig von', 'Gültig bis', 'Aktiv', 'Testdaten', 'Status'] - - # Daten in DataFrame - data = cur.fetchall() - df = pd.DataFrame(data, columns=columns) - - # Datumsformatierung - df['Gültig von'] = pd.to_datetime(df['Gültig von']).dt.strftime('%d.%m.%Y') - df['Gültig bis'] = pd.to_datetime(df['Gültig bis']).dt.strftime('%d.%m.%Y') - - # Typ und Aktiv Status anpassen - df['Typ'] = df['Typ'].replace({'full': 'Vollversion', 'test': 'Testversion'}) - df['Aktiv'] = df['Aktiv'].replace({True: 'Ja', False: 'Nein'}) - df['Testdaten'] = df['Testdaten'].replace({True: 'Ja', False: 'Nein'}) - - cur.close() - conn.close() - - # Export Format - export_format = request.args.get('format', 'excel') - - # Audit-Log - log_audit('EXPORT', 'license', - additional_info=f"Export aller Lizenzen als {export_format.upper()}") - filename = f'lizenzen_export_{datetime.now(ZoneInfo("Europe/Berlin")).strftime("%Y%m%d_%H%M%S")}' - - if export_format == 'csv': - # CSV Export - output = io.StringIO() - df.to_csv(output, index=False, encoding='utf-8-sig', sep=';') - output.seek(0) - - return send_file( - io.BytesIO(output.getvalue().encode('utf-8-sig')), - mimetype='text/csv', - as_attachment=True, - download_name=f'{filename}.csv' - ) - else: - # Excel Export - output = io.BytesIO() - with pd.ExcelWriter(output, engine='openpyxl') as writer: - df.to_excel(writer, sheet_name='Lizenzen', index=False) - - # Formatierung - worksheet = writer.sheets['Lizenzen'] - for column in worksheet.columns: - max_length = 0 - column_letter = column[0].column_letter - for cell in column: - try: - if len(str(cell.value)) > max_length: - max_length = len(str(cell.value)) - except: - pass - adjusted_width = min(max_length + 2, 50) - worksheet.column_dimensions[column_letter].width = adjusted_width - - output.seek(0) - - return send_file( - output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=f'{filename}.xlsx' - ) - -# @app.route("/export/audit") -# @login_required -def export_audit(): - conn = get_connection() - cur = conn.cursor() - - # Holen der Filter-Parameter - filter_user = request.args.get('user', '') - filter_action = request.args.get('action', '') - filter_entity = request.args.get('entity', '') - export_format = request.args.get('format', 'excel') - - # SQL Query mit Filtern - query = """ - SELECT id, timestamp, username, action, entity_type, entity_id, - old_values, new_values, ip_address, user_agent, additional_info - FROM audit_log - WHERE 1=1 - """ - params = [] - - if filter_user: - query += " AND username ILIKE %s" - params.append(f'%{filter_user}%') - - if filter_action: - query += " AND action = %s" - params.append(filter_action) - - if filter_entity: - query += " AND entity_type = %s" - params.append(filter_entity) - - query += " ORDER BY timestamp DESC" - - cur.execute(query, params) - audit_logs = cur.fetchall() - cur.close() - conn.close() - - # Daten für Export vorbereiten - data = [] - for log in audit_logs: - action_text = { - 'CREATE': 'Erstellt', - 'UPDATE': 'Bearbeitet', - 'DELETE': 'Gelöscht', - 'LOGIN': 'Anmeldung', - 'LOGOUT': 'Abmeldung', - 'AUTO_LOGOUT': 'Auto-Logout', - 'EXPORT': 'Export', - 'GENERATE_KEY': 'Key generiert', - 'CREATE_BATCH': 'Batch erstellt', - 'BACKUP': 'Backup erstellt', - 'LOGIN_2FA_SUCCESS': '2FA-Anmeldung', - 'LOGIN_2FA_BACKUP': '2FA-Backup-Code', - 'LOGIN_2FA_FAILED': '2FA-Fehlgeschlagen', - 'LOGIN_BLOCKED': 'Login-Blockiert', - 'RESTORE': 'Wiederhergestellt', - 'PASSWORD_CHANGE': 'Passwort geändert', - '2FA_ENABLED': '2FA aktiviert', - '2FA_DISABLED': '2FA deaktiviert' - }.get(log[3], log[3]) - - data.append({ - 'ID': log[0], - 'Zeitstempel': log[1].strftime('%d.%m.%Y %H:%M:%S'), - 'Benutzer': log[2], - 'Aktion': action_text, - 'Entität': log[4], - 'Entität-ID': log[5] or '', - 'IP-Adresse': log[8] or '', - 'Zusatzinfo': log[10] or '' - }) - - # DataFrame erstellen - df = pd.DataFrame(data) - - # Timestamp für Dateiname - timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') - filename = f'audit_log_export_{timestamp}' - - # Audit Log für Export - log_audit('EXPORT', 'audit_log', - additional_info=f"{export_format.upper()} Export mit {len(data)} Einträgen") - - if export_format == 'csv': - # CSV Export - output = io.StringIO() - # UTF-8 BOM für Excel - output.write('\ufeff') - df.to_csv(output, index=False, sep=';', encoding='utf-8') - output.seek(0) - - return send_file( - io.BytesIO(output.getvalue().encode('utf-8')), - mimetype='text/csv;charset=utf-8', - as_attachment=True, - download_name=f'{filename}.csv' - ) - else: - # Excel Export - output = BytesIO() - with pd.ExcelWriter(output, engine='openpyxl') as writer: - df.to_excel(writer, index=False, sheet_name='Audit Log') - - # Spaltenbreiten anpassen - worksheet = writer.sheets['Audit Log'] - for idx, col in enumerate(df.columns): - max_length = max( - df[col].astype(str).map(len).max(), - len(col) - ) + 2 - worksheet.column_dimensions[get_column_letter(idx + 1)].width = min(max_length, 50) - - output.seek(0) - - return send_file( - output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=f'{filename}.xlsx' - ) - -# @app.route("/export/customers") -# @login_required -def export_customers(): - conn = get_connection() - cur = conn.cursor() - - # Check if test data should be included - include_test = request.args.get('include_test', 'false').lower() == 'true' - - # Build query based on test data filter - if include_test: - # Include all customers - query = """ - SELECT c.id, c.name, c.email, c.created_at, c.is_test, - COUNT(l.id) as total_licenses, - COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE THEN 1 END) as active_licenses, - COUNT(CASE WHEN l.valid_until < CURRENT_DATE THEN 1 END) as expired_licenses - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - GROUP BY c.id, c.name, c.email, c.created_at, c.is_test - ORDER BY c.id - """ - else: - # Exclude test customers and test licenses - query = """ - SELECT c.id, c.name, c.email, c.created_at, c.is_test, - COUNT(CASE WHEN l.is_test = FALSE THEN 1 END) as total_licenses, - COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE AND l.is_test = FALSE THEN 1 END) as active_licenses, - COUNT(CASE WHEN l.valid_until < CURRENT_DATE AND l.is_test = FALSE THEN 1 END) as expired_licenses - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - WHERE c.is_test = FALSE - GROUP BY c.id, c.name, c.email, c.created_at, c.is_test - ORDER BY c.id - """ - - cur.execute(query) - - # Spaltennamen - columns = ['ID', 'Name', 'E-Mail', 'Erstellt am', 'Testdaten', - 'Lizenzen gesamt', 'Aktive Lizenzen', 'Abgelaufene Lizenzen'] - - # Daten in DataFrame - data = cur.fetchall() - df = pd.DataFrame(data, columns=columns) - - # Datumsformatierung - df['Erstellt am'] = pd.to_datetime(df['Erstellt am']).dt.strftime('%d.%m.%Y %H:%M') - - # Testdaten formatting - df['Testdaten'] = df['Testdaten'].replace({True: 'Ja', False: 'Nein'}) - - cur.close() - conn.close() - - # Export Format - export_format = request.args.get('format', 'excel') - - # Audit-Log - log_audit('EXPORT', 'customer', - additional_info=f"Export aller Kunden als {export_format.upper()}") - filename = f'kunden_export_{datetime.now(ZoneInfo("Europe/Berlin")).strftime("%Y%m%d_%H%M%S")}' - - if export_format == 'csv': - # CSV Export - output = io.StringIO() - df.to_csv(output, index=False, encoding='utf-8-sig', sep=';') - output.seek(0) - - return send_file( - io.BytesIO(output.getvalue().encode('utf-8-sig')), - mimetype='text/csv', - as_attachment=True, - download_name=f'{filename}.csv' - ) - else: - # Excel Export - output = io.BytesIO() - with pd.ExcelWriter(output, engine='openpyxl') as writer: - df.to_excel(writer, sheet_name='Kunden', index=False) - - # Formatierung - worksheet = writer.sheets['Kunden'] - for column in worksheet.columns: - max_length = 0 - column_letter = column[0].column_letter - for cell in column: - try: - if len(str(cell.value)) > max_length: - max_length = len(str(cell.value)) - except: - pass - adjusted_width = min(max_length + 2, 50) - worksheet.column_dimensions[column_letter].width = adjusted_width - - output.seek(0) - - return send_file( - output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=f'{filename}.xlsx' - ) - -# @app.route("/export/sessions") -# @login_required -def export_sessions(): - conn = get_connection() - cur = conn.cursor() - - # Holen des Session-Typs (active oder ended) - session_type = request.args.get('type', 'active') - export_format = request.args.get('format', 'excel') - - # Daten je nach Typ abrufen - if session_type == 'active': - # Aktive Lizenz-Sessions - cur.execute(""" - SELECT s.id, l.license_key, c.name as customer_name, s.session_id, - s.started_at, s.last_heartbeat, - EXTRACT(EPOCH FROM (NOW() - s.started_at))::INT as duration_seconds, - s.ip_address, s.user_agent - FROM sessions s - JOIN licenses l ON s.license_id = l.id - JOIN customers c ON l.customer_id = c.id - WHERE s.is_active = true - ORDER BY s.last_heartbeat DESC - """) - sessions = cur.fetchall() - - # Daten für Export vorbereiten - data = [] - for sess in sessions: - duration = sess[6] - hours = duration // 3600 - minutes = (duration % 3600) // 60 - seconds = duration % 60 - - data.append({ - 'Session-ID': sess[0], - 'Lizenzschlüssel': sess[1], - 'Kunde': sess[2], - 'Session-ID (Tech)': sess[3], - 'Startzeit': sess[4].strftime('%d.%m.%Y %H:%M:%S'), - 'Letzte Aktivität': sess[5].strftime('%d.%m.%Y %H:%M:%S'), - 'Dauer': f"{hours}h {minutes}m {seconds}s", - 'IP-Adresse': sess[7], - 'Browser': sess[8] - }) - - sheet_name = 'Aktive Sessions' - filename_prefix = 'aktive_sessions' - else: - # Beendete Lizenz-Sessions - cur.execute(""" - SELECT s.id, l.license_key, c.name as customer_name, s.session_id, - s.started_at, s.ended_at, - EXTRACT(EPOCH FROM (s.ended_at - s.started_at))::INT as duration_seconds, - s.ip_address, s.user_agent - FROM sessions s - JOIN licenses l ON s.license_id = l.id - JOIN customers c ON l.customer_id = c.id - WHERE s.is_active = false AND s.ended_at IS NOT NULL - ORDER BY s.ended_at DESC - LIMIT 1000 - """) - sessions = cur.fetchall() - - # Daten für Export vorbereiten - data = [] - for sess in sessions: - duration = sess[6] if sess[6] else 0 - hours = duration // 3600 - minutes = (duration % 3600) // 60 - seconds = duration % 60 - - data.append({ - 'Session-ID': sess[0], - 'Lizenzschlüssel': sess[1], - 'Kunde': sess[2], - 'Session-ID (Tech)': sess[3], - 'Startzeit': sess[4].strftime('%d.%m.%Y %H:%M:%S'), - 'Endzeit': sess[5].strftime('%d.%m.%Y %H:%M:%S') if sess[5] else '', - 'Dauer': f"{hours}h {minutes}m {seconds}s", - 'IP-Adresse': sess[7], - 'Browser': sess[8] - }) - - sheet_name = 'Beendete Sessions' - filename_prefix = 'beendete_sessions' - - cur.close() - conn.close() - - # DataFrame erstellen - df = pd.DataFrame(data) - - # Timestamp für Dateiname - timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') - filename = f'{filename_prefix}_export_{timestamp}' - - # Audit Log für Export - log_audit('EXPORT', 'sessions', - additional_info=f"{export_format.upper()} Export von {session_type} Sessions mit {len(data)} Einträgen") - - if export_format == 'csv': - # CSV Export - output = io.StringIO() - # UTF-8 BOM für Excel - output.write('\ufeff') - df.to_csv(output, index=False, sep=';', encoding='utf-8') - output.seek(0) - - return send_file( - io.BytesIO(output.getvalue().encode('utf-8')), - mimetype='text/csv;charset=utf-8', - as_attachment=True, - download_name=f'{filename}.csv' - ) - else: - # Excel Export - output = BytesIO() - with pd.ExcelWriter(output, engine='openpyxl') as writer: - df.to_excel(writer, index=False, sheet_name=sheet_name) - - # Spaltenbreiten anpassen - worksheet = writer.sheets[sheet_name] - for idx, col in enumerate(df.columns): - max_length = max( - df[col].astype(str).map(len).max(), - len(col) - ) + 2 - worksheet.column_dimensions[get_column_letter(idx + 1)].width = min(max_length, 50) - - output.seek(0) - - return send_file( - output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=f'{filename}.xlsx' - ) - -# @app.route("/export/resources") -# @login_required -def export_resources(): - conn = get_connection() - cur = conn.cursor() - - # Holen der Filter-Parameter - filter_type = request.args.get('type', '') - filter_status = request.args.get('status', '') - search_query = request.args.get('search', '') - show_test = request.args.get('show_test', 'false').lower() == 'true' - export_format = request.args.get('format', 'excel') - - # SQL Query mit Filtern - query = """ - SELECT r.id, r.resource_type, r.resource_value, r.status, r.allocated_to_license, - r.created_at, r.status_changed_at, - l.license_key, c.name as customer_name, c.email as customer_email, - l.license_type - FROM resource_pools r - LEFT JOIN licenses l ON r.allocated_to_license = l.id - LEFT JOIN customers c ON l.customer_id = c.id - WHERE 1=1 - """ - params = [] - - # Filter für Testdaten - if not show_test: - query += " AND (r.is_test = false OR r.is_test IS NULL)" - - # Filter für Ressourcentyp - if filter_type: - query += " AND r.resource_type = %s" - params.append(filter_type) - - # Filter für Status - if filter_status: - query += " AND r.status = %s" - params.append(filter_status) - - # Suchfilter - if search_query: - query += " AND (r.resource_value ILIKE %s OR l.license_key ILIKE %s OR c.name ILIKE %s)" - params.extend([f'%{search_query}%', f'%{search_query}%', f'%{search_query}%']) - - query += " ORDER BY r.id DESC" - - cur.execute(query, params) - resources = cur.fetchall() - cur.close() - conn.close() - - # Daten für Export vorbereiten - data = [] - for res in resources: - status_text = { - 'available': 'Verfügbar', - 'allocated': 'Zugewiesen', - 'quarantine': 'Quarantäne' - }.get(res[3], res[3]) - - type_text = { - 'domain': 'Domain', - 'ipv4': 'IPv4', - 'phone': 'Telefon' - }.get(res[1], res[1]) - - data.append({ - 'ID': res[0], - 'Typ': type_text, - 'Ressource': res[2], - 'Status': status_text, - 'Lizenzschlüssel': res[7] or '', - 'Kunde': res[8] or '', - 'Kunden-Email': res[9] or '', - 'Lizenztyp': res[10] or '', - 'Erstellt am': res[5].strftime('%d.%m.%Y %H:%M:%S') if res[5] else '', - 'Zugewiesen am': res[6].strftime('%d.%m.%Y %H:%M:%S') if res[6] else '' - }) - - # DataFrame erstellen - df = pd.DataFrame(data) - - # Timestamp für Dateiname - timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') - filename = f'resources_export_{timestamp}' - - # Audit Log für Export - log_audit('EXPORT', 'resources', - additional_info=f"{export_format.upper()} Export mit {len(data)} Ressourcen") - - if export_format == 'csv': - # CSV Export - output = io.StringIO() - # UTF-8 BOM für Excel - output.write('\ufeff') - df.to_csv(output, index=False, sep=';', encoding='utf-8') - output.seek(0) - - return send_file( - io.BytesIO(output.getvalue().encode('utf-8')), - mimetype='text/csv;charset=utf-8', - as_attachment=True, - download_name=f'{filename}.csv' - ) - else: - # Excel Export - output = BytesIO() - with pd.ExcelWriter(output, engine='openpyxl') as writer: - df.to_excel(writer, index=False, sheet_name='Resources') - - # Spaltenbreiten anpassen - worksheet = writer.sheets['Resources'] - for idx, col in enumerate(df.columns): - max_length = max( - df[col].astype(str).map(len).max(), - len(col) - ) + 2 - worksheet.column_dimensions[get_column_letter(idx + 1)].width = min(max_length, 50) - - output.seek(0) - - return send_file( - output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=f'{filename}.xlsx' - ) - -# @app.route("/audit") -# @login_required -def audit_log(): - conn = get_connection() - cur = conn.cursor() - - # Parameter - filter_user = request.args.get('user', '').strip() - filter_action = request.args.get('action', '').strip() - filter_entity = request.args.get('entity', '').strip() - page = request.args.get('page', 1, type=int) - sort = request.args.get('sort', 'timestamp') - order = request.args.get('order', 'desc') - per_page = 50 - - # Whitelist für erlaubte Sortierfelder - allowed_sort_fields = { - 'timestamp': 'timestamp', - 'username': 'username', - 'action': 'action', - 'entity': 'entity_type', - 'ip': 'ip_address' - } - - # Validierung - if sort not in allowed_sort_fields: - sort = 'timestamp' - if order not in ['asc', 'desc']: - order = 'desc' - - sort_field = allowed_sort_fields[sort] - - # SQL Query mit optionalen Filtern - query = """ - SELECT id, timestamp, username, action, entity_type, entity_id, - old_values, new_values, ip_address, user_agent, additional_info - FROM audit_log - WHERE 1=1 - """ - - params = [] - - # Filter - if filter_user: - query += " AND LOWER(username) LIKE LOWER(%s)" - params.append(f'%{filter_user}%') - - if filter_action: - query += " AND action = %s" - params.append(filter_action) - - if filter_entity: - query += " AND entity_type = %s" - params.append(filter_entity) - - # Gesamtanzahl für Pagination - count_query = "SELECT COUNT(*) FROM (" + query + ") as count_table" - cur.execute(count_query, params) - total = cur.fetchone()[0] - - # Pagination - offset = (page - 1) * per_page - query += f" ORDER BY {sort_field} {order.upper()} LIMIT %s OFFSET %s" - params.extend([per_page, offset]) - - cur.execute(query, params) - logs = cur.fetchall() - - # JSON-Werte parsen - parsed_logs = [] - for log in logs: - parsed_log = list(log) - # old_values und new_values sind bereits Dictionaries (JSONB) - # Keine Konvertierung nötig - parsed_logs.append(parsed_log) - - # Pagination Info - total_pages = (total + per_page - 1) // per_page - - cur.close() - conn.close() - - return render_template("audit_log.html", - logs=parsed_logs, - filter_user=filter_user, - filter_action=filter_action, - filter_entity=filter_entity, - page=page, - total_pages=total_pages, - total=total, - sort=sort, - order=order, - username=session.get('username')) - -# @app.route("/backups") -# @login_required -def backups(): - """Zeigt die Backup-Historie an""" - conn = get_connection() - cur = conn.cursor() - - # Letztes erfolgreiches Backup für Dashboard - cur.execute(""" - SELECT created_at, filesize, duration_seconds - FROM backup_history - WHERE status = 'success' - ORDER BY created_at DESC - LIMIT 1 - """) - last_backup = cur.fetchone() - - # Alle Backups abrufen - cur.execute(""" - SELECT id, filename, filesize, backup_type, status, error_message, - created_at, created_by, tables_count, records_count, - duration_seconds, is_encrypted - FROM backup_history - ORDER BY created_at DESC - """) - backups = cur.fetchall() - - cur.close() - conn.close() - - return render_template("backups.html", - backups=backups, - last_backup=last_backup, - username=session.get('username')) - -# @app.route("/backup/create", methods=["POST"]) -# @login_required -def create_backup_route(): - """Erstellt ein manuelles Backup""" - username = session.get('username') - success, result = create_backup(backup_type="manual", created_by=username) - - if success: - return jsonify({ - 'success': True, - 'message': f'Backup erfolgreich erstellt: {result}' - }) - else: - return jsonify({ - 'success': False, - 'message': f'Backup fehlgeschlagen: {result}' - }), 500 - -# @app.route("/backup/restore/", methods=["POST"]) -# @login_required -def restore_backup_route(backup_id): - """Stellt ein Backup wieder her""" - encryption_key = request.form.get('encryption_key') - - success, message = restore_backup(backup_id, encryption_key) - - if success: - return jsonify({ - 'success': True, - 'message': message - }) - else: - return jsonify({ - 'success': False, - 'message': message - }), 500 - -# @app.route("/backup/download/") -# @login_required -def download_backup(backup_id): - """Lädt eine Backup-Datei herunter""" - conn = get_connection() - cur = conn.cursor() - - cur.execute(""" - SELECT filename, filepath - FROM backup_history - WHERE id = %s - """, (backup_id,)) - backup_info = cur.fetchone() - - cur.close() - conn.close() - - if not backup_info: - return "Backup nicht gefunden", 404 - - filename, filepath = backup_info - filepath = Path(filepath) - - if not filepath.exists(): - return "Backup-Datei nicht gefunden", 404 - - # Audit-Log - log_audit('DOWNLOAD', 'backup', backup_id, - additional_info=f"Backup heruntergeladen: {filename}") - - return send_file(filepath, as_attachment=True, download_name=filename) - -# @app.route("/backup/delete/", methods=["DELETE"]) -# @login_required -def delete_backup(backup_id): - """Löscht ein Backup""" - conn = get_connection() - cur = conn.cursor() - - try: - # Backup-Informationen abrufen - cur.execute(""" - SELECT filename, filepath - FROM backup_history - WHERE id = %s - """, (backup_id,)) - backup_info = cur.fetchone() - - if not backup_info: - return jsonify({ - 'success': False, - 'message': 'Backup nicht gefunden' - }), 404 - - filename, filepath = backup_info - filepath = Path(filepath) - - # Datei löschen, wenn sie existiert - if filepath.exists(): - filepath.unlink() - - # Aus Datenbank löschen - cur.execute(""" - DELETE FROM backup_history - WHERE id = %s - """, (backup_id,)) - - conn.commit() - - # Audit-Log - log_audit('DELETE', 'backup', backup_id, - additional_info=f"Backup gelöscht: {filename}") - - return jsonify({ - 'success': True, - 'message': f'Backup "{filename}" wurde erfolgreich gelöscht' - }) - - except Exception as e: - conn.rollback() - return jsonify({ - 'success': False, - 'message': f'Fehler beim Löschen des Backups: {str(e)}' - }), 500 - finally: - cur.close() - conn.close() - -# @app.route("/security/blocked-ips") -# @login_required -def blocked_ips(): - """Zeigt alle gesperrten IPs an""" - conn = get_connection() - cur = conn.cursor() - - cur.execute(""" - SELECT - ip_address, - attempt_count, - first_attempt, - last_attempt, - blocked_until, - last_username_tried, - last_error_message - FROM login_attempts - WHERE blocked_until IS NOT NULL - ORDER BY blocked_until DESC - """) - - blocked_ips_list = [] - for ip in cur.fetchall(): - blocked_ips_list.append({ - 'ip_address': ip[0], - 'attempt_count': ip[1], - 'first_attempt': ip[2].strftime('%d.%m.%Y %H:%M'), - 'last_attempt': ip[3].strftime('%d.%m.%Y %H:%M'), - 'blocked_until': ip[4].strftime('%d.%m.%Y %H:%M'), - 'is_active': ip[4] > datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None), - 'last_username': ip[5], - 'last_error': ip[6] - }) - - cur.close() - conn.close() - - return render_template("blocked_ips.html", - blocked_ips=blocked_ips_list, - username=session.get('username')) - -# @app.route("/security/unblock-ip", methods=["POST"]) -# @login_required -def unblock_ip(): - """Entsperrt eine IP-Adresse""" - ip_address = request.form.get('ip_address') - - if ip_address: - conn = get_connection() - cur = conn.cursor() - - cur.execute(""" - UPDATE login_attempts - SET blocked_until = NULL - WHERE ip_address = %s - """, (ip_address,)) - - conn.commit() - cur.close() - conn.close() - - # Audit-Log - log_audit('UNBLOCK_IP', 'security', - additional_info=f"IP {ip_address} manuell entsperrt") - - return redirect(url_for('blocked_ips')) - -# @app.route("/security/clear-attempts", methods=["POST"]) -# @login_required -def clear_attempts(): - """Löscht alle Login-Versuche für eine IP""" - ip_address = request.form.get('ip_address') - - if ip_address: - reset_login_attempts(ip_address) - - # Audit-Log - log_audit('CLEAR_ATTEMPTS', 'security', - additional_info=f"Login-Versuche für IP {ip_address} zurückgesetzt") - - return redirect(url_for('blocked_ips')) - -# API Endpoints for License Management -# @app.route("/api/license//toggle", methods=["POST"]) -# @login_required -def toggle_license_api(license_id): - """Toggle license active status via API""" - try: - data = request.get_json() - is_active = data.get('is_active', False) - - conn = get_connection() - cur = conn.cursor() - - # Update license status - cur.execute(""" - UPDATE licenses - SET is_active = %s - WHERE id = %s - """, (is_active, license_id)) - - conn.commit() - - # Log the action - log_audit('UPDATE', 'license', license_id, - new_values={'is_active': is_active}, - additional_info=f"Lizenz {'aktiviert' if is_active else 'deaktiviert'} via Toggle") - - cur.close() - conn.close() - - return jsonify({'success': True, 'message': 'Status erfolgreich geändert'}) - except Exception as e: - return jsonify({'success': False, 'message': str(e)}), 500 - -# @app.route("/api/licenses/bulk-activate", methods=["POST"]) -# @login_required -def bulk_activate_licenses(): - """Activate multiple licenses at once""" - try: - data = request.get_json() - license_ids = data.get('ids', []) - - if not license_ids: - return jsonify({'success': False, 'message': 'Keine Lizenzen ausgewählt'}), 400 - - conn = get_connection() - cur = conn.cursor() - - # Update all selected licenses (nur Live-Daten) - cur.execute(""" - UPDATE licenses - SET is_active = TRUE - WHERE id = ANY(%s) AND is_test = FALSE - """, (license_ids,)) - - affected_rows = cur.rowcount - conn.commit() - - # Log the bulk action - log_audit('BULK_UPDATE', 'licenses', None, - new_values={'is_active': True, 'count': affected_rows}, - additional_info=f"{affected_rows} Lizenzen aktiviert") - - cur.close() - conn.close() - - return jsonify({'success': True, 'message': f'{affected_rows} Lizenzen aktiviert'}) - except Exception as e: - return jsonify({'success': False, 'message': str(e)}), 500 - -# @app.route("/api/licenses/bulk-deactivate", methods=["POST"]) -# @login_required -def bulk_deactivate_licenses(): - """Deactivate multiple licenses at once""" - try: - data = request.get_json() - license_ids = data.get('ids', []) - - if not license_ids: - return jsonify({'success': False, 'message': 'Keine Lizenzen ausgewählt'}), 400 - - conn = get_connection() - cur = conn.cursor() - - # Update all selected licenses (nur Live-Daten) - cur.execute(""" - UPDATE licenses - SET is_active = FALSE - WHERE id = ANY(%s) AND is_test = FALSE - """, (license_ids,)) - - affected_rows = cur.rowcount - conn.commit() - - # Log the bulk action - log_audit('BULK_UPDATE', 'licenses', None, - new_values={'is_active': False, 'count': affected_rows}, - additional_info=f"{affected_rows} Lizenzen deaktiviert") - - cur.close() - conn.close() - - return jsonify({'success': True, 'message': f'{affected_rows} Lizenzen deaktiviert'}) - except Exception as e: - return jsonify({'success': False, 'message': str(e)}), 500 - -# @app.route("/api/license//devices") -# @login_required -def get_license_devices(license_id): - """Hole alle registrierten Geräte einer Lizenz""" - try: - conn = get_connection() - cur = conn.cursor() - - # Prüfe ob Lizenz existiert und hole device_limit - cur.execute(""" - SELECT device_limit FROM licenses WHERE id = %s - """, (license_id,)) - license_data = cur.fetchone() - - if not license_data: - return jsonify({'success': False, 'message': 'Lizenz nicht gefunden'}), 404 - - device_limit = license_data[0] - - # Hole alle Geräte für diese Lizenz - cur.execute(""" - SELECT id, hardware_id, device_name, operating_system, - first_seen, last_seen, is_active, ip_address - FROM device_registrations - WHERE license_id = %s - ORDER BY is_active DESC, last_seen DESC - """, (license_id,)) - - devices = [] - for row in cur.fetchall(): - devices.append({ - 'id': row[0], - 'hardware_id': row[1], - 'device_name': row[2] or 'Unbekanntes Gerät', - 'operating_system': row[3] or 'Unbekannt', - 'first_seen': row[4].strftime('%d.%m.%Y %H:%M') if row[4] else '', - 'last_seen': row[5].strftime('%d.%m.%Y %H:%M') if row[5] else '', - 'is_active': row[6], - 'ip_address': row[7] or '-' - }) - - cur.close() - conn.close() - - return jsonify({ - 'success': True, - 'devices': devices, - 'device_limit': device_limit, - 'active_count': sum(1 for d in devices if d['is_active']) - }) - - except Exception as e: - logging.error(f"Fehler beim Abrufen der Geräte: {str(e)}") - return jsonify({'success': False, 'message': 'Fehler beim Abrufen der Geräte'}), 500 - -# @app.route("/api/license//register-device", methods=["POST"]) -# def register_device(license_id): - # """Registriere ein neues Gerät für eine Lizenz""" - # try: - # data = request.get_json() - # hardware_id = data.get('hardware_id') - # device_name = data.get('device_name', '') - # operating_system = data.get('operating_system', '') - - # if not hardware_id: - # return jsonify({'success': False, 'message': 'Hardware-ID fehlt'}), 400 - - # conn = get_connection() - # cur = conn.cursor() - - # Prüfe ob Lizenz existiert und aktiv ist - # cur.execute(""" - # SELECT device_limit, is_active, valid_until - # FROM licenses - # WHERE id = %s - # """, (license_id,)) - # license_data = cur.fetchone() - - # if not license_data: - # return jsonify({'success': False, 'message': 'Lizenz nicht gefunden'}), 404 - - # device_limit, is_active, valid_until = license_data - - # Prüfe ob Lizenz aktiv und gültig ist - # if not is_active: - # return jsonify({'success': False, 'message': 'Lizenz ist deaktiviert'}), 403 - - # if valid_until < datetime.now(ZoneInfo("Europe/Berlin")).date(): - # return jsonify({'success': False, 'message': 'Lizenz ist abgelaufen'}), 403 - - # Prüfe ob Gerät bereits registriert ist - # cur.execute(""" - # SELECT id, is_active FROM device_registrations - # WHERE license_id = %s AND hardware_id = %s - # """, (license_id, hardware_id)) - # existing_device = cur.fetchone() - - # if existing_device: - # device_id, is_device_active = existing_device - # if is_device_active: - # Gerät ist bereits aktiv, update last_seen - # cur.execute(""" - # UPDATE device_registrations - # SET last_seen = CURRENT_TIMESTAMP, - # ip_address = %s, - # user_agent = %s - # WHERE id = %s - # """, (get_client_ip(), request.headers.get('User-Agent', ''), device_id)) - # conn.commit() - # return jsonify({'success': True, 'message': 'Gerät bereits registriert', 'device_id': device_id}) - # else: - # Gerät war deaktiviert, prüfe ob wir es reaktivieren können - # cur.execute(""" - # SELECT COUNT(*) FROM device_registrations - # WHERE license_id = %s AND is_active = TRUE - # """, (license_id,)) - # active_count = cur.fetchone()[0] - - # if active_count >= device_limit: - # return jsonify({'success': False, 'message': f'Gerätelimit erreicht ({device_limit} Geräte)'}), 403 - - # Reaktiviere das Gerät - # cur.execute(""" - # UPDATE device_registrations - # SET is_active = TRUE, - # last_seen = CURRENT_TIMESTAMP, - # deactivated_at = NULL, - # deactivated_by = NULL, - # ip_address = %s, - # user_agent = %s - # WHERE id = %s - # """, (get_client_ip(), request.headers.get('User-Agent', ''), device_id)) - # conn.commit() - # return jsonify({'success': True, 'message': 'Gerät reaktiviert', 'device_id': device_id}) - - # Neues Gerät - prüfe Gerätelimit - # cur.execute(""" - # SELECT COUNT(*) FROM device_registrations - # WHERE license_id = %s AND is_active = TRUE - # """, (license_id,)) - # active_count = cur.fetchone()[0] - - # if active_count >= device_limit: - # return jsonify({'success': False, 'message': f'Gerätelimit erreicht ({device_limit} Geräte)'}), 403 - - # Registriere neues Gerät - # cur.execute(""" - # INSERT INTO device_registrations - # (license_id, hardware_id, device_name, operating_system, ip_address, user_agent) - # VALUES (%s, %s, %s, %s, %s, %s) - # RETURNING id - # """, (license_id, hardware_id, device_name, operating_system, - # get_client_ip(), request.headers.get('User-Agent', ''))) - # device_id = cur.fetchone()[0] - - # conn.commit() - - # Audit Log - # log_audit('DEVICE_REGISTER', 'device', device_id, - # new_values={'license_id': license_id, 'hardware_id': hardware_id}) - - # cur.close() - # conn.close() - - # return jsonify({'success': True, 'message': 'Gerät erfolgreich registriert', 'device_id': device_id}) - - # except Exception as e: - # logging.error(f"Fehler bei Geräte-Registrierung: {str(e)}") - # return jsonify({'success': False, 'message': 'Fehler bei der Registrierung'}), 500 - -# @app.route("/api/license//deactivate-device/", methods=["POST"]) -# @login_required -def deactivate_device(license_id, device_id): - """Deaktiviere ein registriertes Gerät""" - try: - conn = get_connection() - cur = conn.cursor() - - # Prüfe ob das Gerät zu dieser Lizenz gehört - cur.execute(""" - SELECT id FROM device_registrations - WHERE id = %s AND license_id = %s AND is_active = TRUE - """, (device_id, license_id)) - - if not cur.fetchone(): - return jsonify({'success': False, 'message': 'Gerät nicht gefunden oder bereits deaktiviert'}), 404 - - # Deaktiviere das Gerät - cur.execute(""" - UPDATE device_registrations - SET is_active = FALSE, - deactivated_at = CURRENT_TIMESTAMP, - deactivated_by = %s - WHERE id = %s - """, (session['username'], device_id)) - - conn.commit() - - # Audit Log - log_audit('DEVICE_DEACTIVATE', 'device', device_id, - old_values={'is_active': True}, - new_values={'is_active': False}) - - cur.close() - conn.close() - - return jsonify({'success': True, 'message': 'Gerät erfolgreich deaktiviert'}) - - except Exception as e: - logging.error(f"Fehler beim Deaktivieren des Geräts: {str(e)}") - return jsonify({'success': False, 'message': 'Fehler beim Deaktivieren'}), 500 - -# @app.route("/api/licenses/bulk-delete", methods=["POST"]) -# @login_required -def bulk_delete_licenses(): - """Delete multiple licenses at once""" - try: - data = request.get_json() - license_ids = data.get('ids', []) - - if not license_ids: - return jsonify({'success': False, 'message': 'Keine Lizenzen ausgewählt'}), 400 - - conn = get_connection() - cur = conn.cursor() - - # Get license info for audit log (nur Live-Daten) - cur.execute(""" - SELECT license_key - FROM licenses - WHERE id = ANY(%s) AND is_test = FALSE - """, (license_ids,)) - license_keys = [row[0] for row in cur.fetchall()] - - # Delete all selected licenses (nur Live-Daten) - cur.execute(""" - DELETE FROM licenses - WHERE id = ANY(%s) AND is_test = FALSE - """, (license_ids,)) - - affected_rows = cur.rowcount - conn.commit() - - # Log the bulk action - log_audit('BULK_DELETE', 'licenses', None, - old_values={'license_keys': license_keys, 'count': affected_rows}, - additional_info=f"{affected_rows} Lizenzen gelöscht") - - cur.close() - conn.close() - - return jsonify({'success': True, 'message': f'{affected_rows} Lizenzen gelöscht'}) - except Exception as e: - return jsonify({'success': False, 'message': str(e)}), 500 - -# ===================== RESOURCE POOL MANAGEMENT ===================== - -# @app.route('/resources') -# @login_required -def resources(): - """Resource Pool Hauptübersicht""" - conn = get_connection() - cur = conn.cursor() - - # Prüfe ob Testdaten angezeigt werden sollen (gleiche Logik wie bei Kunden) - show_test = request.args.get('show_test', 'false').lower() == 'true' - - # Statistiken abrufen - cur.execute(""" - SELECT - resource_type, - COUNT(*) FILTER (WHERE status = 'available') as available, - COUNT(*) FILTER (WHERE status = 'allocated') as allocated, - COUNT(*) FILTER (WHERE status = 'quarantine') as quarantine, - COUNT(*) as total - FROM resource_pools - WHERE is_test = %s - GROUP BY resource_type - """, (show_test,)) - - stats = {} - for row in cur.fetchall(): - stats[row[0]] = { - 'available': row[1], - 'allocated': row[2], - 'quarantine': row[3], - 'total': row[4], - 'available_percent': round((row[1] / row[4] * 100) if row[4] > 0 else 0, 1) - } - - # Letzte Aktivitäten (gefiltert nach Test/Live) - cur.execute(""" - SELECT - rh.action, - rh.action_by, - rh.action_at, - rp.resource_type, - rp.resource_value, - rh.details - FROM resource_history rh - JOIN resource_pools rp ON rh.resource_id = rp.id - WHERE rp.is_test = %s - ORDER BY rh.action_at DESC - LIMIT 10 - """, (show_test,)) - recent_activities = cur.fetchall() - - # Ressourcen-Liste mit Pagination - page = request.args.get('page', 1, type=int) - per_page = 50 - offset = (page - 1) * per_page - - resource_type = request.args.get('type', '') - status_filter = request.args.get('status', '') - search = request.args.get('search', '') - - # Sortierung - sort_by = request.args.get('sort', 'id') - sort_order = request.args.get('order', 'desc') - - # Base Query - query = """ - SELECT - rp.id, - rp.resource_type, - rp.resource_value, - rp.status, - rp.allocated_to_license, - l.license_key, - c.name as customer_name, - rp.status_changed_at, - rp.quarantine_reason, - rp.quarantine_until, - c.id as customer_id - FROM resource_pools rp - LEFT JOIN licenses l ON rp.allocated_to_license = l.id - LEFT JOIN customers c ON l.customer_id = c.id - WHERE rp.is_test = %s - """ - params = [show_test] - - if resource_type: - query += " AND rp.resource_type = %s" - params.append(resource_type) - - if status_filter: - query += " AND rp.status = %s" - params.append(status_filter) - - if search: - query += " AND rp.resource_value ILIKE %s" - params.append(f'%{search}%') - - # Count total - count_query = f"SELECT COUNT(*) FROM ({query}) as cnt" - cur.execute(count_query, params) - total = cur.fetchone()[0] - total_pages = (total + per_page - 1) // per_page - - # Get paginated results with dynamic sorting - sort_column_map = { - 'id': 'rp.id', - 'type': 'rp.resource_type', - 'resource': 'rp.resource_value', - 'status': 'rp.status', - 'assigned': 'c.name', - 'changed': 'rp.status_changed_at' - } - - sort_column = sort_column_map.get(sort_by, 'rp.id') - sort_direction = 'ASC' if sort_order == 'asc' else 'DESC' - - query += f" ORDER BY {sort_column} {sort_direction} LIMIT %s OFFSET %s" - params.extend([per_page, offset]) - - cur.execute(query, params) - resources = cur.fetchall() - - cur.close() - conn.close() - - return render_template('resources.html', - stats=stats, - resources=resources, - recent_activities=recent_activities, - page=page, - total_pages=total_pages, - total=total, - resource_type=resource_type, - status_filter=status_filter, - search=search, - show_test=show_test, - sort_by=sort_by, - sort_order=sort_order, - datetime=datetime, - timedelta=timedelta) - -# @app.route('/resources/add', methods=['GET', 'POST']) -# @login_required -def add_resources(): - """Ressourcen zum Pool hinzufügen""" - # Hole show_test Parameter für die Anzeige - show_test = request.args.get('show_test', 'false').lower() == 'true' - - if request.method == 'POST': - resource_type = request.form.get('resource_type') - resources_text = request.form.get('resources_text', '') - is_test = request.form.get('is_test') == 'on' # Checkbox für Testdaten - - # Parse resources (one per line) - resources = [r.strip() for r in resources_text.split('\n') if r.strip()] - - if not resources: - flash('Keine Ressourcen angegeben', 'error') - return redirect(url_for('add_resources', show_test=show_test)) - - conn = get_connection() - cur = conn.cursor() - - added = 0 - duplicates = 0 - - for resource_value in resources: - try: - cur.execute(""" - INSERT INTO resource_pools (resource_type, resource_value, status_changed_by, is_test) - VALUES (%s, %s, %s, %s) - ON CONFLICT (resource_type, resource_value) DO NOTHING - """, (resource_type, resource_value, session['username'], is_test)) - - if cur.rowcount > 0: - added += 1 - # Get the inserted ID - cur.execute("SELECT id FROM resource_pools WHERE resource_type = %s AND resource_value = %s", - (resource_type, resource_value)) - resource_id = cur.fetchone()[0] - - # Log in history - cur.execute(""" - INSERT INTO resource_history (resource_id, action, action_by, ip_address) - VALUES (%s, 'created', %s, %s) - """, (resource_id, session['username'], get_client_ip())) - else: - duplicates += 1 - - except Exception as e: - app.logger.error(f"Error adding resource {resource_value}: {e}") - - conn.commit() - cur.close() - conn.close() - - log_audit('CREATE', 'resource_pool', None, - new_values={'type': resource_type, 'added': added, 'duplicates': duplicates, 'is_test': is_test}, - additional_info=f"{added} {'Test-' if is_test else ''}Ressourcen hinzugefügt, {duplicates} Duplikate übersprungen") - - flash(f'{added} {"Test-" if is_test else ""}Ressourcen hinzugefügt, {duplicates} Duplikate übersprungen', 'success') - return redirect(url_for('resources', show_test=show_test)) - - return render_template('add_resources.html', show_test=show_test) - -# @app.route('/resources/quarantine/', methods=['POST']) -# @login_required -def quarantine_resource(resource_id): - """Ressource in Quarantäne setzen""" - reason = request.form.get('reason', 'review') - until_date = request.form.get('until_date') - notes = request.form.get('notes', '') - - conn = get_connection() - cur = conn.cursor() - - # Get current resource info - cur.execute("SELECT resource_type, resource_value, status FROM resource_pools WHERE id = %s", (resource_id,)) - resource = cur.fetchone() - - if not resource: - flash('Ressource nicht gefunden', 'error') - return redirect(url_for('resources')) - - old_status = resource[2] - - # Update resource - cur.execute(""" - UPDATE resource_pools - SET status = 'quarantine', - quarantine_reason = %s, - quarantine_until = %s, - notes = %s, - status_changed_at = CURRENT_TIMESTAMP, - status_changed_by = %s - WHERE id = %s - """, (reason, until_date if until_date else None, notes, session['username'], resource_id)) - - # Log in history - cur.execute(""" - INSERT INTO resource_history (resource_id, action, action_by, ip_address, details) - VALUES (%s, 'quarantined', %s, %s, %s) - """, (resource_id, session['username'], get_client_ip(), - Json({'reason': reason, 'until': until_date, 'notes': notes, 'old_status': old_status}))) - - conn.commit() - cur.close() - conn.close() - - log_audit('UPDATE', 'resource', resource_id, - old_values={'status': old_status}, - new_values={'status': 'quarantine', 'reason': reason}, - additional_info=f"Ressource {resource[0]}: {resource[1]} in Quarantäne") - - flash('Ressource in Quarantäne gesetzt', 'success') - - # Redirect mit allen aktuellen Filtern - return redirect(url_for('resources', - show_test=request.args.get('show_test', request.form.get('show_test', 'false')), - type=request.args.get('type', request.form.get('type', '')), - status=request.args.get('status', request.form.get('status', '')), - search=request.args.get('search', request.form.get('search', '')))) - -# @app.route('/resources/release', methods=['POST']) -# @login_required -def release_resources(): - """Ressourcen aus Quarantäne freigeben""" - resource_ids = request.form.getlist('resource_ids') - - if not resource_ids: - flash('Keine Ressourcen ausgewählt', 'error') - return redirect(url_for('resources')) - - conn = get_connection() - cur = conn.cursor() - - released = 0 - for resource_id in resource_ids: - cur.execute(""" - UPDATE resource_pools - SET status = 'available', - quarantine_reason = NULL, - quarantine_until = NULL, - allocated_to_license = NULL, - status_changed_at = CURRENT_TIMESTAMP, - status_changed_by = %s - WHERE id = %s AND status = 'quarantine' - """, (session['username'], resource_id)) - - if cur.rowcount > 0: - released += 1 - # Log in history - cur.execute(""" - INSERT INTO resource_history (resource_id, action, action_by, ip_address) - VALUES (%s, 'released', %s, %s) - """, (resource_id, session['username'], get_client_ip())) - - conn.commit() - cur.close() - conn.close() - - log_audit('UPDATE', 'resource_pool', None, - new_values={'released': released}, - additional_info=f"{released} Ressourcen aus Quarantäne freigegeben") - - flash(f'{released} Ressourcen freigegeben', 'success') - - # Redirect mit allen aktuellen Filtern - return redirect(url_for('resources', - show_test=request.args.get('show_test', request.form.get('show_test', 'false')), - type=request.args.get('type', request.form.get('type', '')), - status=request.args.get('status', request.form.get('status', '')), - search=request.args.get('search', request.form.get('search', '')))) - -# @app.route('/api/resources/allocate', methods=['POST']) -# @login_required -def allocate_resources_api(): - """API für Ressourcen-Zuweisung bei Lizenzerstellung""" - data = request.json - license_id = data.get('license_id') - domain_count = data.get('domain_count', 1) - ipv4_count = data.get('ipv4_count', 1) - phone_count = data.get('phone_count', 1) - - conn = get_connection() - cur = conn.cursor() - - try: - allocated = {'domains': [], 'ipv4s': [], 'phones': []} - - # Allocate domains - if domain_count > 0: - cur.execute(""" - SELECT id, resource_value FROM resource_pools - WHERE resource_type = 'domain' AND status = 'available' - LIMIT %s FOR UPDATE - """, (domain_count,)) - domains = cur.fetchall() - - if len(domains) < domain_count: - raise ValueError(f"Nicht genügend Domains verfügbar (benötigt: {domain_count}, verfügbar: {len(domains)})") - - for domain_id, domain_value in domains: - # Update resource status - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', - allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, - status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], domain_id)) - - # Create assignment - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, domain_id, session['username'])) - - # Log history - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (domain_id, license_id, session['username'], get_client_ip())) - - allocated['domains'].append(domain_value) - - # Allocate IPv4s (similar logic) - if ipv4_count > 0: - cur.execute(""" - SELECT id, resource_value FROM resource_pools - WHERE resource_type = 'ipv4' AND status = 'available' - LIMIT %s FOR UPDATE - """, (ipv4_count,)) - ipv4s = cur.fetchall() - - if len(ipv4s) < ipv4_count: - raise ValueError(f"Nicht genügend IPv4-Adressen verfügbar") - - for ipv4_id, ipv4_value in ipv4s: - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', - allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, - status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], ipv4_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, ipv4_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (ipv4_id, license_id, session['username'], get_client_ip())) - - allocated['ipv4s'].append(ipv4_value) - - # Allocate phones (similar logic) - if phone_count > 0: - cur.execute(""" - SELECT id, resource_value FROM resource_pools - WHERE resource_type = 'phone' AND status = 'available' - LIMIT %s FOR UPDATE - """, (phone_count,)) - phones = cur.fetchall() - - if len(phones) < phone_count: - raise ValueError(f"Nicht genügend Telefonnummern verfügbar") - - for phone_id, phone_value in phones: - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', - allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, - status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], phone_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, phone_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (phone_id, license_id, session['username'], get_client_ip())) - - allocated['phones'].append(phone_value) - - # Update license resource counts - cur.execute(""" - UPDATE licenses - SET domain_count = %s, - ipv4_count = %s, - phone_count = %s - WHERE id = %s - """, (domain_count, ipv4_count, phone_count, license_id)) - - conn.commit() - cur.close() - conn.close() - - return jsonify({ - 'success': True, - 'allocated': allocated - }) - - except Exception as e: - conn.rollback() - cur.close() - conn.close() - return jsonify({ - 'success': False, - 'error': str(e) - }), 400 - -# @app.route('/api/resources/check-availability', methods=['GET']) -# @login_required -def check_resource_availability(): - """Prüft verfügbare Ressourcen""" - resource_type = request.args.get('type', '') - count = request.args.get('count', 10, type=int) - show_test = request.args.get('show_test', 'false').lower() == 'true' - - conn = get_connection() - cur = conn.cursor() - - if resource_type: - # Spezifische Ressourcen für einen Typ - cur.execute(""" - SELECT id, resource_value - FROM resource_pools - WHERE status = 'available' - AND resource_type = %s - AND is_test = %s - ORDER BY resource_value - LIMIT %s - """, (resource_type, show_test, count)) - - resources = [] - for row in cur.fetchall(): - resources.append({ - 'id': row[0], - 'value': row[1] - }) - - cur.close() - conn.close() - - return jsonify({ - 'available': resources, - 'type': resource_type, - 'count': len(resources) - }) - else: - # Zusammenfassung aller Typen - cur.execute(""" - SELECT - resource_type, - COUNT(*) as available - FROM resource_pools - WHERE status = 'available' - AND is_test = %s - GROUP BY resource_type - """, (show_test,)) - - availability = {} - for row in cur.fetchall(): - availability[row[0]] = row[1] - - cur.close() - conn.close() - - return jsonify(availability) - -# @app.route('/api/global-search', methods=['GET']) -# @login_required -def global_search(): - """Global search API endpoint for searching customers and licenses""" - query = request.args.get('q', '').strip() - - if not query or len(query) < 2: - return jsonify({'customers': [], 'licenses': []}) - - conn = get_connection() - cur = conn.cursor() - - # Search pattern with wildcards - search_pattern = f'%{query}%' - - # Search customers - cur.execute(""" - SELECT id, name, email, company_name - FROM customers - WHERE (LOWER(name) LIKE LOWER(%s) - OR LOWER(email) LIKE LOWER(%s) - OR LOWER(company_name) LIKE LOWER(%s)) - AND is_test = FALSE - ORDER BY name - LIMIT 5 - """, (search_pattern, search_pattern, search_pattern)) - - customers = [] - for row in cur.fetchall(): - customers.append({ - 'id': row[0], - 'name': row[1], - 'email': row[2], - 'company_name': row[3] - }) - - # Search licenses - cur.execute(""" - SELECT l.id, l.license_key, c.name as customer_name - FROM licenses l - JOIN customers c ON l.customer_id = c.id - WHERE LOWER(l.license_key) LIKE LOWER(%s) - AND l.is_test = FALSE - ORDER BY l.created_at DESC - LIMIT 5 - """, (search_pattern,)) - - licenses = [] - for row in cur.fetchall(): - licenses.append({ - 'id': row[0], - 'license_key': row[1], - 'customer_name': row[2] - }) - - cur.close() - conn.close() - - return jsonify({ - 'customers': customers, - 'licenses': licenses - }) - -# @app.route('/resources/history/') -# @login_required -def resource_history(resource_id): - """Zeigt die komplette Historie einer Ressource""" - conn = get_connection() - cur = conn.cursor() - - # Get complete resource info using named columns - cur.execute(""" - SELECT id, resource_type, resource_value, status, allocated_to_license, - status_changed_at, status_changed_by, quarantine_reason, - quarantine_until, created_at, notes - FROM resource_pools - WHERE id = %s - """, (resource_id,)) - row = cur.fetchone() - - if not row: - flash('Ressource nicht gefunden', 'error') - return redirect(url_for('resources')) - - # Create resource object with named attributes - resource = { - 'id': row[0], - 'resource_type': row[1], - 'resource_value': row[2], - 'status': row[3], - 'allocated_to_license': row[4], - 'status_changed_at': row[5], - 'status_changed_by': row[6], - 'quarantine_reason': row[7], - 'quarantine_until': row[8], - 'created_at': row[9], - 'notes': row[10] - } - - # Get license info if allocated - license_info = None - if resource['allocated_to_license']: - cur.execute("SELECT license_key FROM licenses WHERE id = %s", - (resource['allocated_to_license'],)) - lic = cur.fetchone() - if lic: - license_info = {'license_key': lic[0]} - - # Get history with named columns - cur.execute(""" - SELECT - rh.action, - rh.action_by, - rh.action_at, - rh.details, - rh.license_id, - rh.ip_address - FROM resource_history rh - WHERE rh.resource_id = %s - ORDER BY rh.action_at DESC - """, (resource_id,)) - - history = [] - for row in cur.fetchall(): - history.append({ - 'action': row[0], - 'action_by': row[1], - 'action_at': row[2], - 'details': row[3], - 'license_id': row[4], - 'ip_address': row[5] - }) - - cur.close() - conn.close() - - # Convert to object-like for template - class ResourceObj: - def __init__(self, data): - for key, value in data.items(): - setattr(self, key, value) - - resource_obj = ResourceObj(resource) - history_objs = [ResourceObj(h) for h in history] - - return render_template('resource_history.html', - resource=resource_obj, - license_info=license_info, - history=history_objs) - -# @app.route('/resources/metrics') -# @login_required -def resources_metrics(): - """Dashboard für Resource Metrics und Reports""" - conn = get_connection() - cur = conn.cursor() - - # Overall stats with fallback values - cur.execute(""" - SELECT - COUNT(DISTINCT resource_id) as total_resources, - COALESCE(AVG(performance_score), 0) as avg_performance, - COALESCE(SUM(cost), 0) as total_cost, - COALESCE(SUM(revenue), 0) as total_revenue, - COALESCE(SUM(issues_count), 0) as total_issues - FROM resource_metrics - WHERE metric_date >= CURRENT_DATE - INTERVAL '30 days' - """) - row = cur.fetchone() - - # Calculate ROI - roi = 0 - if row[2] > 0: # if total_cost > 0 - roi = row[3] / row[2] # revenue / cost - - stats = { - 'total_resources': row[0] or 0, - 'avg_performance': row[1] or 0, - 'total_cost': row[2] or 0, - 'total_revenue': row[3] or 0, - 'total_issues': row[4] or 0, - 'roi': roi - } - - # Performance by type - cur.execute(""" - SELECT - rp.resource_type, - COALESCE(AVG(rm.performance_score), 0) as avg_score, - COUNT(DISTINCT rp.id) as resource_count - FROM resource_pools rp - LEFT JOIN resource_metrics rm ON rp.id = rm.resource_id - AND rm.metric_date >= CURRENT_DATE - INTERVAL '30 days' - GROUP BY rp.resource_type - ORDER BY rp.resource_type - """) - performance_by_type = cur.fetchall() - - # Utilization data - cur.execute(""" - SELECT - resource_type, - COUNT(*) FILTER (WHERE status = 'allocated') as allocated, - COUNT(*) as total, - ROUND(COUNT(*) FILTER (WHERE status = 'allocated') * 100.0 / COUNT(*), 1) as allocated_percent - FROM resource_pools - GROUP BY resource_type - """) - utilization_rows = cur.fetchall() - utilization_data = [ - { - 'type': row[0].upper(), - 'allocated': row[1], - 'total': row[2], - 'allocated_percent': row[3] - } - for row in utilization_rows - ] - - # Top performing resources - cur.execute(""" - SELECT - rp.id, - rp.resource_type, - rp.resource_value, - COALESCE(AVG(rm.performance_score), 0) as avg_score, - COALESCE(SUM(rm.revenue), 0) as total_revenue, - COALESCE(SUM(rm.cost), 1) as total_cost, - CASE - WHEN COALESCE(SUM(rm.cost), 0) = 0 THEN 0 - ELSE COALESCE(SUM(rm.revenue), 0) / COALESCE(SUM(rm.cost), 1) - END as roi - FROM resource_pools rp - LEFT JOIN resource_metrics rm ON rp.id = rm.resource_id - AND rm.metric_date >= CURRENT_DATE - INTERVAL '30 days' - WHERE rp.status != 'quarantine' - GROUP BY rp.id, rp.resource_type, rp.resource_value - HAVING AVG(rm.performance_score) IS NOT NULL - ORDER BY avg_score DESC - LIMIT 10 - """) - top_rows = cur.fetchall() - top_performers = [ - { - 'id': row[0], - 'resource_type': row[1], - 'resource_value': row[2], - 'avg_score': row[3], - 'roi': row[6] - } - for row in top_rows - ] - - # Resources with issues - cur.execute(""" - SELECT - rp.id, - rp.resource_type, - rp.resource_value, - rp.status, - COALESCE(SUM(rm.issues_count), 0) as total_issues - FROM resource_pools rp - LEFT JOIN resource_metrics rm ON rp.id = rm.resource_id - AND rm.metric_date >= CURRENT_DATE - INTERVAL '30 days' - WHERE rm.issues_count > 0 OR rp.status = 'quarantine' - GROUP BY rp.id, rp.resource_type, rp.resource_value, rp.status - HAVING SUM(rm.issues_count) > 0 - ORDER BY total_issues DESC - LIMIT 10 - """) - problem_rows = cur.fetchall() - problem_resources = [ - { - 'id': row[0], - 'resource_type': row[1], - 'resource_value': row[2], - 'status': row[3], - 'total_issues': row[4] - } - for row in problem_rows - ] - - # Daily metrics for trend chart (last 30 days) - cur.execute(""" - SELECT - metric_date, - COALESCE(AVG(performance_score), 0) as avg_performance, - COALESCE(SUM(issues_count), 0) as total_issues - FROM resource_metrics - WHERE metric_date >= CURRENT_DATE - INTERVAL '30 days' - GROUP BY metric_date - ORDER BY metric_date - """) - daily_rows = cur.fetchall() - daily_metrics = [ - { - 'date': row[0].strftime('%d.%m'), - 'performance': float(row[1]), - 'issues': int(row[2]) - } - for row in daily_rows - ] - - cur.close() - conn.close() - - return render_template('resource_metrics.html', - stats=stats, - performance_by_type=performance_by_type, - utilization_data=utilization_data, - top_performers=top_performers, - problem_resources=problem_resources, - daily_metrics=daily_metrics) - -# @app.route('/resources/report', methods=['GET']) -# @login_required -def resources_report(): - """Generiert Ressourcen-Reports oder zeigt Report-Formular""" - # Prüfe ob Download angefordert wurde - if request.args.get('download') == 'true': - report_type = request.args.get('type', 'usage') - format_type = request.args.get('format', 'excel') - date_from = request.args.get('from', (datetime.now(ZoneInfo("Europe/Berlin")) - timedelta(days=30)).strftime('%Y-%m-%d')) - date_to = request.args.get('to', datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y-%m-%d')) - - conn = get_connection() - cur = conn.cursor() - - if report_type == 'usage': - # Auslastungsreport - query = """ - SELECT - rp.resource_type, - rp.resource_value, - rp.status, - COUNT(DISTINCT rh.license_id) as unique_licenses, - COUNT(rh.id) as total_allocations, - MIN(rh.action_at) as first_used, - MAX(rh.action_at) as last_used - FROM resource_pools rp - LEFT JOIN resource_history rh ON rp.id = rh.resource_id - AND rh.action = 'allocated' - AND rh.action_at BETWEEN %s AND %s - GROUP BY rp.id, rp.resource_type, rp.resource_value, rp.status - ORDER BY rp.resource_type, total_allocations DESC - """ - cur.execute(query, (date_from, date_to)) - columns = ['Typ', 'Ressource', 'Status', 'Unique Lizenzen', 'Gesamt Zuweisungen', 'Erste Nutzung', 'Letzte Nutzung'] - - elif report_type == 'performance': - # Performance-Report - query = """ - SELECT - rp.resource_type, - rp.resource_value, - AVG(rm.performance_score) as avg_performance, - SUM(rm.usage_count) as total_usage, - SUM(rm.revenue) as total_revenue, - SUM(rm.cost) as total_cost, - SUM(rm.revenue - rm.cost) as profit, - SUM(rm.issues_count) as total_issues - FROM resource_pools rp - JOIN resource_metrics rm ON rp.id = rm.resource_id - WHERE rm.metric_date BETWEEN %s AND %s - GROUP BY rp.id, rp.resource_type, rp.resource_value - ORDER BY profit DESC - """ - cur.execute(query, (date_from, date_to)) - columns = ['Typ', 'Ressource', 'Durchschn. Performance', 'Gesamt Nutzung', 'Umsatz', 'Kosten', 'Gewinn', 'Issues'] - - elif report_type == 'compliance': - # Compliance-Report - query = """ - SELECT - rh.action_at, - rh.action, - rh.action_by, - rp.resource_type, - rp.resource_value, - l.license_key, - c.name as customer_name, - rh.ip_address - FROM resource_history rh - JOIN resource_pools rp ON rh.resource_id = rp.id - LEFT JOIN licenses l ON rh.license_id = l.id - LEFT JOIN customers c ON l.customer_id = c.id - WHERE rh.action_at BETWEEN %s AND %s - ORDER BY rh.action_at DESC - """ - cur.execute(query, (date_from, date_to)) - columns = ['Zeit', 'Aktion', 'Von', 'Typ', 'Ressource', 'Lizenz', 'Kunde', 'IP-Adresse'] - - else: # inventory report - # Inventar-Report - query = """ - SELECT - resource_type, - COUNT(*) FILTER (WHERE status = 'available') as available, - COUNT(*) FILTER (WHERE status = 'allocated') as allocated, - COUNT(*) FILTER (WHERE status = 'quarantine') as quarantine, - COUNT(*) as total - FROM resource_pools - GROUP BY resource_type - ORDER BY resource_type - """ - cur.execute(query) - columns = ['Typ', 'Verfügbar', 'Zugeteilt', 'Quarantäne', 'Gesamt'] - - # Convert to DataFrame - data = cur.fetchall() - df = pd.DataFrame(data, columns=columns) - - cur.close() - conn.close() - - # Generate file - timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') - filename = f"resource_report_{report_type}_{timestamp}" - - if format_type == 'excel': - output = io.BytesIO() - with pd.ExcelWriter(output, engine='openpyxl') as writer: - df.to_excel(writer, sheet_name='Report', index=False) - - # Auto-adjust columns width - worksheet = writer.sheets['Report'] - for column in worksheet.columns: - max_length = 0 - column = [cell for cell in column] - for cell in column: - try: - if len(str(cell.value)) > max_length: - max_length = len(str(cell.value)) - except: - pass - adjusted_width = (max_length + 2) - worksheet.column_dimensions[column[0].column_letter].width = adjusted_width - - output.seek(0) - - log_audit('EXPORT', 'resource_report', None, - new_values={'type': report_type, 'format': 'excel', 'rows': len(df)}, - additional_info=f"Resource Report {report_type} exportiert") - - return send_file(output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=f'{filename}.xlsx') - - else: # CSV - output = io.StringIO() - df.to_csv(output, index=False, sep=';', encoding='utf-8-sig') - output.seek(0) - - log_audit('EXPORT', 'resource_report', None, - new_values={'type': report_type, 'format': 'csv', 'rows': len(df)}, - additional_info=f"Resource Report {report_type} exportiert") - - return send_file(io.BytesIO(output.getvalue().encode('utf-8-sig')), - mimetype='text/csv', - as_attachment=True, - download_name=f'{filename}.csv') - - # Wenn kein Download, zeige Report-Formular - return render_template('resource_report.html', - datetime=datetime, - timedelta=timedelta, - username=session.get('username')) - -if __name__ == "__main__": - app.run(host="0.0.0.0", port=5000) diff --git a/v2_adminpanel/app.py.old b/v2_adminpanel/app.py.old deleted file mode 100644 index 3849500..0000000 --- a/v2_adminpanel/app.py.old +++ /dev/null @@ -1,5021 +0,0 @@ -import os -import time -import json -import logging -import requests -from io import BytesIO -from datetime import datetime, timedelta -from zoneinfo import ZoneInfo -from pathlib import Path - -from flask import Flask, render_template, request, redirect, session, url_for, send_file, jsonify, flash -from flask_session import Session -from werkzeug.middleware.proxy_fix import ProxyFix -from apscheduler.schedulers.background import BackgroundScheduler -import pandas as pd -from psycopg2.extras import Json - -# Import our new modules -import config -from db import get_connection, get_db_connection, get_db_cursor, execute_query -from auth.decorators import login_required -from auth.password import hash_password, verify_password -from auth.two_factor import ( - generate_totp_secret, generate_qr_code, verify_totp, - generate_backup_codes, hash_backup_code, verify_backup_code -) -from auth.rate_limiting import ( - get_client_ip, check_ip_blocked, record_failed_attempt, - reset_login_attempts, get_login_attempts -) -from utils.audit import log_audit -from utils.license import generate_license_key, validate_license_key -from utils.backup import create_backup, restore_backup, get_or_create_encryption_key -from utils.export import ( - create_excel_export, format_datetime_for_export, - prepare_license_export_data, prepare_customer_export_data, - prepare_session_export_data, prepare_audit_export_data -) - -app = Flask(__name__) -# Load configuration from config module -app.config['SECRET_KEY'] = config.SECRET_KEY -app.config['SESSION_TYPE'] = config.SESSION_TYPE -app.config['JSON_AS_ASCII'] = config.JSON_AS_ASCII -app.config['JSONIFY_MIMETYPE'] = config.JSONIFY_MIMETYPE -app.config['PERMANENT_SESSION_LIFETIME'] = config.PERMANENT_SESSION_LIFETIME -app.config['SESSION_COOKIE_HTTPONLY'] = config.SESSION_COOKIE_HTTPONLY -app.config['SESSION_COOKIE_SECURE'] = config.SESSION_COOKIE_SECURE -app.config['SESSION_COOKIE_SAMESITE'] = config.SESSION_COOKIE_SAMESITE -app.config['SESSION_COOKIE_NAME'] = config.SESSION_COOKIE_NAME -app.config['SESSION_REFRESH_EACH_REQUEST'] = config.SESSION_REFRESH_EACH_REQUEST -Session(app) - -# ProxyFix für korrekte IP-Adressen hinter Nginx -app.wsgi_app = ProxyFix( - app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1 -) - -# Configuration is now loaded from config module - -# Scheduler für automatische Backups -scheduler = BackgroundScheduler() -scheduler.start() - -# Logging konfigurieren -logging.basicConfig(level=logging.INFO) - - -# Login decorator -def login_required(f): - @wraps(f) - def decorated_function(*args, **kwargs): - if 'logged_in' not in session: - return redirect(url_for('login')) - - # Prüfe ob Session abgelaufen ist - if 'last_activity' in session: - last_activity = datetime.fromisoformat(session['last_activity']) - time_since_activity = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None) - last_activity - - # Debug-Logging - app.logger.info(f"Session check for {session.get('username', 'unknown')}: " - f"Last activity: {last_activity}, " - f"Time since: {time_since_activity.total_seconds()} seconds") - - if time_since_activity > timedelta(minutes=5): - # Session abgelaufen - Logout - username = session.get('username', 'unbekannt') - app.logger.info(f"Session timeout for user {username} - auto logout") - # Audit-Log für automatischen Logout (vor session.clear()!) - try: - log_audit('AUTO_LOGOUT', 'session', additional_info={'reason': 'Session timeout (5 minutes)', 'username': username}) - except: - pass - session.clear() - flash('Ihre Sitzung ist abgelaufen. Bitte melden Sie sich erneut an.', 'warning') - return redirect(url_for('login')) - - # Aktivität NICHT automatisch aktualisieren - # Nur bei expliziten Benutzeraktionen (wird vom Heartbeat gemacht) - return f(*args, **kwargs) - return decorated_function - -# DB-Verbindung mit UTF-8 Encoding -def get_connection(): - conn = psycopg2.connect( - host=os.getenv("POSTGRES_HOST", "postgres"), - port=os.getenv("POSTGRES_PORT", "5432"), - dbname=os.getenv("POSTGRES_DB"), - user=os.getenv("POSTGRES_USER"), - password=os.getenv("POSTGRES_PASSWORD"), - options='-c client_encoding=UTF8' - ) - conn.set_client_encoding('UTF8') - return conn - -# User Authentication Helper Functions -def hash_password(password): - """Hash a password using bcrypt""" - return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8') - -def verify_password(password, hashed): - """Verify a password against its hash""" - return bcrypt.checkpw(password.encode('utf-8'), hashed.encode('utf-8')) - -def get_user_by_username(username): - """Get user from database by username""" - conn = get_connection() - cur = conn.cursor() - try: - cur.execute(""" - SELECT id, username, password_hash, email, totp_secret, totp_enabled, - backup_codes, last_password_change, failed_2fa_attempts - FROM users WHERE username = %s - """, (username,)) - user = cur.fetchone() - if user: - return { - 'id': user[0], - 'username': user[1], - 'password_hash': user[2], - 'email': user[3], - 'totp_secret': user[4], - 'totp_enabled': user[5], - 'backup_codes': user[6], - 'last_password_change': user[7], - 'failed_2fa_attempts': user[8] - } - return None - finally: - cur.close() - conn.close() - -def generate_totp_secret(): - """Generate a new TOTP secret""" - return pyotp.random_base32() - -def generate_qr_code(username, totp_secret): - """Generate QR code for TOTP setup""" - totp_uri = pyotp.totp.TOTP(totp_secret).provisioning_uri( - name=username, - issuer_name='V2 Admin Panel' - ) - - qr = qrcode.QRCode(version=1, box_size=10, border=5) - qr.add_data(totp_uri) - qr.make(fit=True) - - img = qr.make_image(fill_color="black", back_color="white") - buf = BytesIO() - img.save(buf, format='PNG') - buf.seek(0) - - return base64.b64encode(buf.getvalue()).decode() - -def verify_totp(totp_secret, token): - """Verify a TOTP token""" - totp = pyotp.TOTP(totp_secret) - return totp.verify(token, valid_window=1) - -def generate_backup_codes(count=8): - """Generate backup codes for 2FA recovery""" - codes = [] - for _ in range(count): - code = ''.join(random.choices(string.ascii_uppercase + string.digits, k=8)) - codes.append(code) - return codes - -def hash_backup_code(code): - """Hash a backup code for storage""" - return hashlib.sha256(code.encode()).hexdigest() - -def verify_backup_code(code, hashed_codes): - """Verify a backup code against stored hashes""" - code_hash = hashlib.sha256(code.encode()).hexdigest() - return code_hash in hashed_codes - -# Audit-Log-Funktion -def log_audit(action, entity_type, entity_id=None, old_values=None, new_values=None, additional_info=None): - """Protokolliert Änderungen im Audit-Log""" - conn = get_connection() - cur = conn.cursor() - - try: - username = session.get('username', 'system') - ip_address = get_client_ip() if request else None - user_agent = request.headers.get('User-Agent') if request else None - - # Debug logging - app.logger.info(f"Audit log - IP address captured: {ip_address}, Action: {action}, User: {username}") - - # Konvertiere Dictionaries zu JSONB - old_json = Json(old_values) if old_values else None - new_json = Json(new_values) if new_values else None - - cur.execute(""" - INSERT INTO audit_log - (username, action, entity_type, entity_id, old_values, new_values, - ip_address, user_agent, additional_info) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) - """, (username, action, entity_type, entity_id, old_json, new_json, - ip_address, user_agent, additional_info)) - - conn.commit() - except Exception as e: - print(f"Audit log error: {e}") - conn.rollback() - finally: - cur.close() - conn.close() - -# Verschlüsselungs-Funktionen -def get_or_create_encryption_key(): - """Holt oder erstellt einen Verschlüsselungsschlüssel""" - key_file = BACKUP_DIR / ".backup_key" - - # Versuche Key aus Umgebungsvariable zu lesen - env_key = os.getenv("BACKUP_ENCRYPTION_KEY") - if env_key: - try: - # Validiere den Key - Fernet(env_key.encode()) - return env_key.encode() - except: - pass - - # Wenn kein gültiger Key in ENV, prüfe Datei - if key_file.exists(): - return key_file.read_bytes() - - # Erstelle neuen Key - key = Fernet.generate_key() - key_file.write_bytes(key) - logging.info("Neuer Backup-Verschlüsselungsschlüssel erstellt") - return key - -# Backup-Funktionen -def create_backup(backup_type="manual", created_by=None): - """Erstellt ein verschlüsseltes Backup der Datenbank""" - start_time = time.time() - timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime("%Y%m%d_%H%M%S") - filename = f"backup_v2docker_{timestamp}_encrypted.sql.gz.enc" - filepath = BACKUP_DIR / filename - - conn = get_connection() - cur = conn.cursor() - - # Backup-Eintrag erstellen - cur.execute(""" - INSERT INTO backup_history - (filename, filepath, backup_type, status, created_by, is_encrypted) - VALUES (%s, %s, %s, %s, %s, %s) - RETURNING id - """, (filename, str(filepath), backup_type, 'in_progress', - created_by or 'system', True)) - backup_id = cur.fetchone()[0] - conn.commit() - - try: - # PostgreSQL Dump erstellen - dump_command = [ - 'pg_dump', - '-h', os.getenv("POSTGRES_HOST", "postgres"), - '-p', os.getenv("POSTGRES_PORT", "5432"), - '-U', os.getenv("POSTGRES_USER"), - '-d', os.getenv("POSTGRES_DB"), - '--no-password', - '--verbose' - ] - - # PGPASSWORD setzen - env = os.environ.copy() - env['PGPASSWORD'] = os.getenv("POSTGRES_PASSWORD") - - # Dump ausführen - result = subprocess.run(dump_command, capture_output=True, text=True, env=env) - - if result.returncode != 0: - raise Exception(f"pg_dump failed: {result.stderr}") - - dump_data = result.stdout.encode('utf-8') - - # Komprimieren - compressed_data = gzip.compress(dump_data) - - # Verschlüsseln - key = get_or_create_encryption_key() - f = Fernet(key) - encrypted_data = f.encrypt(compressed_data) - - # Speichern - filepath.write_bytes(encrypted_data) - - # Statistiken sammeln - cur.execute("SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'public'") - tables_count = cur.fetchone()[0] - - cur.execute(""" - SELECT SUM(n_live_tup) - FROM pg_stat_user_tables - """) - records_count = cur.fetchone()[0] or 0 - - duration = time.time() - start_time - filesize = filepath.stat().st_size - - # Backup-Eintrag aktualisieren - cur.execute(""" - UPDATE backup_history - SET status = %s, filesize = %s, tables_count = %s, - records_count = %s, duration_seconds = %s - WHERE id = %s - """, ('success', filesize, tables_count, records_count, duration, backup_id)) - - conn.commit() - - # Audit-Log - log_audit('BACKUP', 'database', backup_id, - additional_info=f"Backup erstellt: {filename} ({filesize} bytes)") - - # E-Mail-Benachrichtigung (wenn konfiguriert) - send_backup_notification(True, filename, filesize, duration) - - logging.info(f"Backup erfolgreich erstellt: {filename}") - return True, filename - - except Exception as e: - # Fehler protokollieren - cur.execute(""" - UPDATE backup_history - SET status = %s, error_message = %s, duration_seconds = %s - WHERE id = %s - """, ('failed', str(e), time.time() - start_time, backup_id)) - conn.commit() - - logging.error(f"Backup fehlgeschlagen: {e}") - send_backup_notification(False, filename, error=str(e)) - - return False, str(e) - - finally: - cur.close() - conn.close() - -def restore_backup(backup_id, encryption_key=None): - """Stellt ein Backup wieder her""" - conn = get_connection() - cur = conn.cursor() - - try: - # Backup-Info abrufen - cur.execute(""" - SELECT filename, filepath, is_encrypted - FROM backup_history - WHERE id = %s - """, (backup_id,)) - backup_info = cur.fetchone() - - if not backup_info: - raise Exception("Backup nicht gefunden") - - filename, filepath, is_encrypted = backup_info - filepath = Path(filepath) - - if not filepath.exists(): - raise Exception("Backup-Datei nicht gefunden") - - # Datei lesen - encrypted_data = filepath.read_bytes() - - # Entschlüsseln - if is_encrypted: - key = encryption_key.encode() if encryption_key else get_or_create_encryption_key() - try: - f = Fernet(key) - compressed_data = f.decrypt(encrypted_data) - except: - raise Exception("Entschlüsselung fehlgeschlagen. Falsches Passwort?") - else: - compressed_data = encrypted_data - - # Dekomprimieren - dump_data = gzip.decompress(compressed_data) - sql_commands = dump_data.decode('utf-8') - - # Bestehende Verbindungen schließen - cur.close() - conn.close() - - # Datenbank wiederherstellen - restore_command = [ - 'psql', - '-h', os.getenv("POSTGRES_HOST", "postgres"), - '-p', os.getenv("POSTGRES_PORT", "5432"), - '-U', os.getenv("POSTGRES_USER"), - '-d', os.getenv("POSTGRES_DB"), - '--no-password' - ] - - env = os.environ.copy() - env['PGPASSWORD'] = os.getenv("POSTGRES_PASSWORD") - - result = subprocess.run(restore_command, input=sql_commands, - capture_output=True, text=True, env=env) - - if result.returncode != 0: - raise Exception(f"Wiederherstellung fehlgeschlagen: {result.stderr}") - - # Audit-Log (neue Verbindung) - log_audit('RESTORE', 'database', backup_id, - additional_info=f"Backup wiederhergestellt: {filename}") - - return True, "Backup erfolgreich wiederhergestellt" - - except Exception as e: - logging.error(f"Wiederherstellung fehlgeschlagen: {e}") - return False, str(e) - -def send_backup_notification(success, filename, filesize=None, duration=None, error=None): - """Sendet E-Mail-Benachrichtigung (wenn konfiguriert)""" - if not os.getenv("EMAIL_ENABLED", "false").lower() == "true": - return - - # E-Mail-Funktion vorbereitet aber deaktiviert - # TODO: Implementieren wenn E-Mail-Server konfiguriert ist - logging.info(f"E-Mail-Benachrichtigung vorbereitet: Backup {'erfolgreich' if success else 'fehlgeschlagen'}") - -# Scheduled Backup Job -def scheduled_backup(): - """Führt ein geplantes Backup aus""" - logging.info("Starte geplantes Backup...") - create_backup(backup_type="scheduled", created_by="scheduler") - -# Scheduler konfigurieren - täglich um 3:00 Uhr -scheduler.add_job( - scheduled_backup, - 'cron', - hour=3, - minute=0, - id='daily_backup', - replace_existing=True -) - -# Rate-Limiting Funktionen -def get_client_ip(): - """Ermittelt die echte IP-Adresse des Clients""" - # Debug logging - app.logger.info(f"Headers - X-Real-IP: {request.headers.get('X-Real-IP')}, X-Forwarded-For: {request.headers.get('X-Forwarded-For')}, Remote-Addr: {request.remote_addr}") - - # Try X-Real-IP first (set by nginx) - if request.headers.get('X-Real-IP'): - return request.headers.get('X-Real-IP') - # Then X-Forwarded-For - elif request.headers.get('X-Forwarded-For'): - # X-Forwarded-For can contain multiple IPs, take the first one - return request.headers.get('X-Forwarded-For').split(',')[0].strip() - # Fallback to remote_addr - else: - return request.remote_addr - -def check_ip_blocked(ip_address): - """Prüft ob eine IP-Adresse gesperrt ist""" - conn = get_connection() - cur = conn.cursor() - - cur.execute(""" - SELECT blocked_until FROM login_attempts - WHERE ip_address = %s AND blocked_until IS NOT NULL - """, (ip_address,)) - - result = cur.fetchone() - cur.close() - conn.close() - - if result and result[0]: - if result[0] > datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None): - return True, result[0] - return False, None - -def record_failed_attempt(ip_address, username): - """Zeichnet einen fehlgeschlagenen Login-Versuch auf""" - conn = get_connection() - cur = conn.cursor() - - # Random Fehlermeldung - error_message = random.choice(FAIL_MESSAGES) - - try: - # Prüfen ob IP bereits existiert - cur.execute(""" - SELECT attempt_count FROM login_attempts - WHERE ip_address = %s - """, (ip_address,)) - - result = cur.fetchone() - - if result: - # Update bestehenden Eintrag - new_count = result[0] + 1 - blocked_until = None - - if new_count >= MAX_LOGIN_ATTEMPTS: - blocked_until = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None) + timedelta(hours=BLOCK_DURATION_HOURS) - # E-Mail-Benachrichtigung (wenn aktiviert) - if os.getenv("EMAIL_ENABLED", "false").lower() == "true": - send_security_alert_email(ip_address, username, new_count) - - cur.execute(""" - UPDATE login_attempts - SET attempt_count = %s, - last_attempt = CURRENT_TIMESTAMP, - blocked_until = %s, - last_username_tried = %s, - last_error_message = %s - WHERE ip_address = %s - """, (new_count, blocked_until, username, error_message, ip_address)) - else: - # Neuen Eintrag erstellen - cur.execute(""" - INSERT INTO login_attempts - (ip_address, attempt_count, last_username_tried, last_error_message) - VALUES (%s, 1, %s, %s) - """, (ip_address, username, error_message)) - - conn.commit() - - # Audit-Log - log_audit('LOGIN_FAILED', 'user', - additional_info=f"IP: {ip_address}, User: {username}, Message: {error_message}") - - except Exception as e: - print(f"Rate limiting error: {e}") - conn.rollback() - finally: - cur.close() - conn.close() - - return error_message - -def reset_login_attempts(ip_address): - """Setzt die Login-Versuche für eine IP zurück""" - conn = get_connection() - cur = conn.cursor() - - try: - cur.execute(""" - DELETE FROM login_attempts - WHERE ip_address = %s - """, (ip_address,)) - conn.commit() - except Exception as e: - print(f"Reset attempts error: {e}") - conn.rollback() - finally: - cur.close() - conn.close() - -def get_login_attempts(ip_address): - """Gibt die Anzahl der Login-Versuche für eine IP zurück""" - conn = get_connection() - cur = conn.cursor() - - cur.execute(""" - SELECT attempt_count FROM login_attempts - WHERE ip_address = %s - """, (ip_address,)) - - result = cur.fetchone() - cur.close() - conn.close() - - return result[0] if result else 0 - -def send_security_alert_email(ip_address, username, attempt_count): - """Sendet eine Sicherheitswarnung per E-Mail""" - subject = f"⚠️ SICHERHEITSWARNUNG: {attempt_count} fehlgeschlagene Login-Versuche" - body = f""" - WARNUNG: Mehrere fehlgeschlagene Login-Versuche erkannt! - - IP-Adresse: {ip_address} - Versuchter Benutzername: {username} - Anzahl Versuche: {attempt_count} - Zeit: {datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y-%m-%d %H:%M:%S')} - - Die IP-Adresse wurde für 24 Stunden gesperrt. - - Dies ist eine automatische Nachricht vom v2-Docker Admin Panel. - """ - - # TODO: E-Mail-Versand implementieren wenn SMTP konfiguriert - logging.warning(f"Sicherheitswarnung: {attempt_count} fehlgeschlagene Versuche von IP {ip_address}") - print(f"E-Mail würde gesendet: {subject}") - -def verify_recaptcha(response): - """Verifiziert die reCAPTCHA v2 Response mit Google""" - secret_key = os.getenv('RECAPTCHA_SECRET_KEY') - - # Wenn kein Secret Key konfiguriert ist, CAPTCHA als bestanden werten (für PoC) - if not secret_key: - logging.warning("RECAPTCHA_SECRET_KEY nicht konfiguriert - CAPTCHA wird übersprungen") - return True - - # Verifizierung bei Google - try: - verify_url = 'https://www.google.com/recaptcha/api/siteverify' - data = { - 'secret': secret_key, - 'response': response - } - - # Timeout für Request setzen - r = requests.post(verify_url, data=data, timeout=5) - result = r.json() - - # Log für Debugging - if not result.get('success'): - logging.warning(f"reCAPTCHA Validierung fehlgeschlagen: {result.get('error-codes', [])}") - - return result.get('success', False) - - except requests.exceptions.RequestException as e: - logging.error(f"reCAPTCHA Verifizierung fehlgeschlagen: {str(e)}") - # Bei Netzwerkfehlern CAPTCHA als bestanden werten - return True - except Exception as e: - logging.error(f"Unerwarteter Fehler bei reCAPTCHA: {str(e)}") - return False - -def generate_license_key(license_type='full'): - """ - Generiert einen Lizenzschlüssel im Format: AF-F-YYYYMM-XXXX-YYYY-ZZZZ - - AF = Account Factory (Produktkennung) - F/T = F für Fullversion, T für Testversion - YYYY = Jahr - MM = Monat - XXXX-YYYY-ZZZZ = Zufällige alphanumerische Zeichen - """ - # Erlaubte Zeichen (ohne verwirrende wie 0/O, 1/I/l) - chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' - - # Datum-Teil - now = datetime.now(ZoneInfo("Europe/Berlin")) - date_part = now.strftime('%Y%m') - type_char = 'F' if license_type == 'full' else 'T' - - # Zufällige Teile generieren (3 Blöcke à 4 Zeichen) - parts = [] - for _ in range(3): - part = ''.join(secrets.choice(chars) for _ in range(4)) - parts.append(part) - - # Key zusammensetzen - key = f"AF-{type_char}-{date_part}-{parts[0]}-{parts[1]}-{parts[2]}" - - return key - -def validate_license_key(key): - """ - Validiert das License Key Format - Erwartet: AF-F-YYYYMM-XXXX-YYYY-ZZZZ oder AF-T-YYYYMM-XXXX-YYYY-ZZZZ - """ - if not key: - return False - - # Pattern für das neue Format - # AF- (fest) + F oder T + - + 6 Ziffern (YYYYMM) + - + 4 Zeichen + - + 4 Zeichen + - + 4 Zeichen - pattern = r'^AF-[FT]-\d{6}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$' - - # Großbuchstaben für Vergleich - return bool(re.match(pattern, key.upper())) - -@app.route("/login", methods=["GET", "POST"]) -def login(): - # Timing-Attack Schutz - Start Zeit merken - start_time = time.time() - - # IP-Adresse ermitteln - ip_address = get_client_ip() - - # Prüfen ob IP gesperrt ist - is_blocked, blocked_until = check_ip_blocked(ip_address) - if is_blocked: - time_remaining = (blocked_until - datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None)).total_seconds() / 3600 - error_msg = f"IP GESPERRT! Noch {time_remaining:.1f} Stunden warten." - return render_template("login.html", error=error_msg, error_type="blocked") - - # Anzahl bisheriger Versuche - attempt_count = get_login_attempts(ip_address) - - if request.method == "POST": - username = request.form.get("username") - password = request.form.get("password") - captcha_response = request.form.get("g-recaptcha-response") - - # CAPTCHA-Prüfung nur wenn Keys konfiguriert sind - recaptcha_site_key = os.getenv('RECAPTCHA_SITE_KEY') - if attempt_count >= CAPTCHA_AFTER_ATTEMPTS and recaptcha_site_key: - if not captcha_response: - # Timing-Attack Schutz - elapsed = time.time() - start_time - if elapsed < 1.0: - time.sleep(1.0 - elapsed) - return render_template("login.html", - error="CAPTCHA ERFORDERLICH!", - show_captcha=True, - error_type="captcha", - attempts_left=max(0, MAX_LOGIN_ATTEMPTS - attempt_count), - recaptcha_site_key=recaptcha_site_key) - - # CAPTCHA validieren - if not verify_recaptcha(captcha_response): - # Timing-Attack Schutz - elapsed = time.time() - start_time - if elapsed < 1.0: - time.sleep(1.0 - elapsed) - return render_template("login.html", - error="CAPTCHA UNGÜLTIG! Bitte erneut versuchen.", - show_captcha=True, - error_type="captcha", - attempts_left=max(0, MAX_LOGIN_ATTEMPTS - attempt_count), - recaptcha_site_key=recaptcha_site_key) - - # Check user in database first, fallback to env vars - user = get_user_by_username(username) - login_success = False - needs_2fa = False - - if user: - # Database user authentication - if verify_password(password, user['password_hash']): - login_success = True - needs_2fa = user['totp_enabled'] - else: - # Fallback to environment variables for backward compatibility - admin1_user = os.getenv("ADMIN1_USERNAME") - admin1_pass = os.getenv("ADMIN1_PASSWORD") - admin2_user = os.getenv("ADMIN2_USERNAME") - admin2_pass = os.getenv("ADMIN2_PASSWORD") - - if ((username == admin1_user and password == admin1_pass) or - (username == admin2_user and password == admin2_pass)): - login_success = True - - # Timing-Attack Schutz - Mindestens 1 Sekunde warten - elapsed = time.time() - start_time - if elapsed < 1.0: - time.sleep(1.0 - elapsed) - - if login_success: - # Erfolgreicher Login - if needs_2fa: - # Store temporary session for 2FA verification - session['temp_username'] = username - session['temp_user_id'] = user['id'] - session['awaiting_2fa'] = True - return redirect(url_for('verify_2fa')) - else: - # Complete login without 2FA - session.permanent = True # Aktiviert das Timeout - session['logged_in'] = True - session['username'] = username - session['user_id'] = user['id'] if user else None - session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() - reset_login_attempts(ip_address) - log_audit('LOGIN_SUCCESS', 'user', - additional_info=f"Erfolgreiche Anmeldung von IP: {ip_address}") - return redirect(url_for('dashboard')) - else: - # Fehlgeschlagener Login - error_message = record_failed_attempt(ip_address, username) - new_attempt_count = get_login_attempts(ip_address) - - # Prüfen ob jetzt gesperrt - is_now_blocked, _ = check_ip_blocked(ip_address) - if is_now_blocked: - log_audit('LOGIN_BLOCKED', 'security', - additional_info=f"IP {ip_address} wurde nach {MAX_LOGIN_ATTEMPTS} Versuchen gesperrt") - - return render_template("login.html", - error=error_message, - show_captcha=(new_attempt_count >= CAPTCHA_AFTER_ATTEMPTS and os.getenv('RECAPTCHA_SITE_KEY')), - error_type="failed", - attempts_left=max(0, MAX_LOGIN_ATTEMPTS - new_attempt_count), - recaptcha_site_key=os.getenv('RECAPTCHA_SITE_KEY')) - - # GET Request - return render_template("login.html", - show_captcha=(attempt_count >= CAPTCHA_AFTER_ATTEMPTS and os.getenv('RECAPTCHA_SITE_KEY')), - attempts_left=max(0, MAX_LOGIN_ATTEMPTS - attempt_count), - recaptcha_site_key=os.getenv('RECAPTCHA_SITE_KEY')) - -@app.route("/logout") -def logout(): - username = session.get('username', 'unknown') - log_audit('LOGOUT', 'user', additional_info=f"Abmeldung") - session.pop('logged_in', None) - session.pop('username', None) - session.pop('user_id', None) - session.pop('temp_username', None) - session.pop('temp_user_id', None) - session.pop('awaiting_2fa', None) - return redirect(url_for('login')) - -@app.route("/verify-2fa", methods=["GET", "POST"]) -def verify_2fa(): - if not session.get('awaiting_2fa'): - return redirect(url_for('login')) - - if request.method == "POST": - token = request.form.get('token', '').replace(' ', '') - username = session.get('temp_username') - user_id = session.get('temp_user_id') - - if not username or not user_id: - flash('Session expired. Please login again.', 'error') - return redirect(url_for('login')) - - user = get_user_by_username(username) - if not user: - flash('User not found.', 'error') - return redirect(url_for('login')) - - # Check if it's a backup code - if len(token) == 8 and token.isupper(): - # Try backup code - backup_codes = json.loads(user['backup_codes']) if user['backup_codes'] else [] - if verify_backup_code(token, backup_codes): - # Remove used backup code - code_hash = hash_backup_code(token) - backup_codes.remove(code_hash) - - conn = get_connection() - cur = conn.cursor() - cur.execute("UPDATE users SET backup_codes = %s WHERE id = %s", - (json.dumps(backup_codes), user_id)) - conn.commit() - cur.close() - conn.close() - - # Complete login - session.permanent = True - session['logged_in'] = True - session['username'] = username - session['user_id'] = user_id - session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() - session.pop('temp_username', None) - session.pop('temp_user_id', None) - session.pop('awaiting_2fa', None) - - flash('Login successful using backup code. Please generate new backup codes.', 'warning') - log_audit('LOGIN_2FA_BACKUP', 'user', additional_info=f"2FA login with backup code") - return redirect(url_for('dashboard')) - else: - # Try TOTP token - if verify_totp(user['totp_secret'], token): - # Complete login - session.permanent = True - session['logged_in'] = True - session['username'] = username - session['user_id'] = user_id - session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() - session.pop('temp_username', None) - session.pop('temp_user_id', None) - session.pop('awaiting_2fa', None) - - log_audit('LOGIN_2FA_SUCCESS', 'user', additional_info=f"2FA login successful") - return redirect(url_for('dashboard')) - - # Failed verification - conn = get_connection() - cur = conn.cursor() - cur.execute("UPDATE users SET failed_2fa_attempts = failed_2fa_attempts + 1, last_failed_2fa = %s WHERE id = %s", - (datetime.now(), user_id)) - conn.commit() - cur.close() - conn.close() - - flash('Invalid authentication code. Please try again.', 'error') - log_audit('LOGIN_2FA_FAILED', 'user', additional_info=f"Failed 2FA attempt") - - return render_template('verify_2fa.html') - -@app.route("/profile") -@login_required -def profile(): - user = get_user_by_username(session['username']) - if not user: - # For environment-based users, redirect with message - flash('Bitte führen Sie das Migrations-Script aus, um Passwort-Änderung und 2FA zu aktivieren.', 'info') - return redirect(url_for('dashboard')) - return render_template('profile.html', user=user) - -@app.route("/profile/change-password", methods=["POST"]) -@login_required -def change_password(): - current_password = request.form.get('current_password') - new_password = request.form.get('new_password') - confirm_password = request.form.get('confirm_password') - - user = get_user_by_username(session['username']) - - # Verify current password - if not verify_password(current_password, user['password_hash']): - flash('Current password is incorrect.', 'error') - return redirect(url_for('profile')) - - # Check new password - if new_password != confirm_password: - flash('New passwords do not match.', 'error') - return redirect(url_for('profile')) - - if len(new_password) < 8: - flash('Password must be at least 8 characters long.', 'error') - return redirect(url_for('profile')) - - # Update password - new_hash = hash_password(new_password) - conn = get_connection() - cur = conn.cursor() - cur.execute("UPDATE users SET password_hash = %s, last_password_change = %s WHERE id = %s", - (new_hash, datetime.now(), user['id'])) - conn.commit() - cur.close() - conn.close() - - log_audit('PASSWORD_CHANGE', 'user', entity_id=user['id'], - additional_info="Password changed successfully") - flash('Password changed successfully.', 'success') - return redirect(url_for('profile')) - -@app.route("/profile/setup-2fa") -@login_required -def setup_2fa(): - user = get_user_by_username(session['username']) - - if user['totp_enabled']: - flash('2FA is already enabled for your account.', 'info') - return redirect(url_for('profile')) - - # Generate new TOTP secret - totp_secret = generate_totp_secret() - session['temp_totp_secret'] = totp_secret - - # Generate QR code - qr_code = generate_qr_code(user['username'], totp_secret) - - return render_template('setup_2fa.html', - totp_secret=totp_secret, - qr_code=qr_code) - -@app.route("/profile/enable-2fa", methods=["POST"]) -@login_required -def enable_2fa(): - token = request.form.get('token', '').replace(' ', '') - totp_secret = session.get('temp_totp_secret') - - if not totp_secret: - flash('2FA setup session expired. Please try again.', 'error') - return redirect(url_for('setup_2fa')) - - # Verify the token - if not verify_totp(totp_secret, token): - flash('Invalid authentication code. Please try again.', 'error') - return redirect(url_for('setup_2fa')) - - # Generate backup codes - backup_codes = generate_backup_codes() - hashed_codes = [hash_backup_code(code) for code in backup_codes] - - # Enable 2FA - conn = get_connection() - cur = conn.cursor() - cur.execute(""" - UPDATE users - SET totp_secret = %s, totp_enabled = TRUE, backup_codes = %s - WHERE username = %s - """, (totp_secret, json.dumps(hashed_codes), session['username'])) - conn.commit() - cur.close() - conn.close() - - session.pop('temp_totp_secret', None) - - log_audit('2FA_ENABLED', 'user', additional_info="2FA enabled successfully") - - # Show backup codes - return render_template('backup_codes.html', backup_codes=backup_codes) - -@app.route("/profile/disable-2fa", methods=["POST"]) -@login_required -def disable_2fa(): - password = request.form.get('password') - user = get_user_by_username(session['username']) - - # Verify password - if not verify_password(password, user['password_hash']): - flash('Incorrect password.', 'error') - return redirect(url_for('profile')) - - # Disable 2FA - conn = get_connection() - cur = conn.cursor() - cur.execute(""" - UPDATE users - SET totp_enabled = FALSE, totp_secret = NULL, backup_codes = NULL - WHERE username = %s - """, (session['username'],)) - conn.commit() - cur.close() - conn.close() - - log_audit('2FA_DISABLED', 'user', additional_info="2FA disabled") - flash('2FA has been disabled for your account.', 'success') - return redirect(url_for('profile')) - -@app.route("/heartbeat", methods=['POST']) -@login_required -def heartbeat(): - """Endpoint für Session Keep-Alive - aktualisiert last_activity""" - # Aktualisiere last_activity nur wenn explizit angefordert - session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() - # Force session save - session.modified = True - - return jsonify({ - 'status': 'ok', - 'last_activity': session['last_activity'], - 'username': session.get('username') - }) - -@app.route("/api/generate-license-key", methods=['POST']) -@login_required -def api_generate_key(): - """API Endpoint zur Generierung eines neuen Lizenzschlüssels""" - try: - # Lizenztyp aus Request holen (default: full) - data = request.get_json() or {} - license_type = data.get('type', 'full') - - # Key generieren - key = generate_license_key(license_type) - - # Prüfen ob Key bereits existiert (sehr unwahrscheinlich aber sicher ist sicher) - conn = get_connection() - cur = conn.cursor() - - # Wiederhole bis eindeutiger Key gefunden - attempts = 0 - while attempts < 10: # Max 10 Versuche - cur.execute("SELECT 1 FROM licenses WHERE license_key = %s", (key,)) - if not cur.fetchone(): - break # Key ist eindeutig - key = generate_license_key(license_type) - attempts += 1 - - cur.close() - conn.close() - - # Log für Audit - log_audit('GENERATE_KEY', 'license', - additional_info={'type': license_type, 'key': key}) - - return jsonify({ - 'success': True, - 'key': key, - 'type': license_type - }) - - except Exception as e: - logging.error(f"Fehler bei Key-Generierung: {str(e)}") - return jsonify({ - 'success': False, - 'error': 'Fehler bei der Key-Generierung' - }), 500 - -@app.route("/api/customers", methods=['GET']) -@login_required -def api_customers(): - """API Endpoint für die Kundensuche mit Select2""" - try: - # Suchparameter - search = request.args.get('q', '').strip() - page = request.args.get('page', 1, type=int) - per_page = 20 - customer_id = request.args.get('id', type=int) - - conn = get_connection() - cur = conn.cursor() - - # Einzelnen Kunden per ID abrufen - if customer_id: - cur.execute(""" - SELECT c.id, c.name, c.email, - COUNT(l.id) as license_count - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - WHERE c.id = %s - GROUP BY c.id, c.name, c.email - """, (customer_id,)) - - customer = cur.fetchone() - results = [] - if customer: - results.append({ - 'id': customer[0], - 'text': f"{customer[1]} ({customer[2]})", - 'name': customer[1], - 'email': customer[2], - 'license_count': customer[3] - }) - - cur.close() - conn.close() - - return jsonify({ - 'results': results, - 'pagination': {'more': False} - }) - - # SQL Query mit optionaler Suche - elif search: - cur.execute(""" - SELECT c.id, c.name, c.email, - COUNT(l.id) as license_count - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - WHERE LOWER(c.name) LIKE LOWER(%s) - OR LOWER(c.email) LIKE LOWER(%s) - GROUP BY c.id, c.name, c.email - ORDER BY c.name - LIMIT %s OFFSET %s - """, (f'%{search}%', f'%{search}%', per_page, (page - 1) * per_page)) - else: - cur.execute(""" - SELECT c.id, c.name, c.email, - COUNT(l.id) as license_count - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - GROUP BY c.id, c.name, c.email - ORDER BY c.name - LIMIT %s OFFSET %s - """, (per_page, (page - 1) * per_page)) - - customers = cur.fetchall() - - # Format für Select2 - results = [] - for customer in customers: - results.append({ - 'id': customer[0], - 'text': f"{customer[1]} - {customer[2]} ({customer[3]} Lizenzen)", - 'name': customer[1], - 'email': customer[2], - 'license_count': customer[3] - }) - - # Gesamtanzahl für Pagination - if search: - cur.execute(""" - SELECT COUNT(*) FROM customers - WHERE LOWER(name) LIKE LOWER(%s) - OR LOWER(email) LIKE LOWER(%s) - """, (f'%{search}%', f'%{search}%')) - else: - cur.execute("SELECT COUNT(*) FROM customers") - - total_count = cur.fetchone()[0] - - cur.close() - conn.close() - - # Select2 Response Format - return jsonify({ - 'results': results, - 'pagination': { - 'more': (page * per_page) < total_count - } - }) - - except Exception as e: - logging.error(f"Fehler bei Kundensuche: {str(e)}") - return jsonify({ - 'results': [], - 'error': 'Fehler bei der Kundensuche' - }), 500 - -@app.route("/") -@login_required -def dashboard(): - conn = get_connection() - cur = conn.cursor() - - # Statistiken abrufen - # Gesamtanzahl Kunden (ohne Testdaten) - cur.execute("SELECT COUNT(*) FROM customers WHERE is_test = FALSE") - total_customers = cur.fetchone()[0] - - # Gesamtanzahl Lizenzen (ohne Testdaten) - cur.execute("SELECT COUNT(*) FROM licenses WHERE is_test = FALSE") - total_licenses = cur.fetchone()[0] - - # Aktive Lizenzen (nicht abgelaufen und is_active = true, ohne Testdaten) - cur.execute(""" - SELECT COUNT(*) FROM licenses - WHERE valid_until >= CURRENT_DATE AND is_active = TRUE AND is_test = FALSE - """) - active_licenses = cur.fetchone()[0] - - # Aktive Sessions - cur.execute("SELECT COUNT(*) FROM sessions WHERE is_active = TRUE") - active_sessions_count = cur.fetchone()[0] - - # Abgelaufene Lizenzen (ohne Testdaten) - cur.execute(""" - SELECT COUNT(*) FROM licenses - WHERE valid_until < CURRENT_DATE AND is_test = FALSE - """) - expired_licenses = cur.fetchone()[0] - - # Deaktivierte Lizenzen (ohne Testdaten) - cur.execute(""" - SELECT COUNT(*) FROM licenses - WHERE is_active = FALSE AND is_test = FALSE - """) - inactive_licenses = cur.fetchone()[0] - - # Lizenzen die in den nächsten 30 Tagen ablaufen (ohne Testdaten) - cur.execute(""" - SELECT COUNT(*) FROM licenses - WHERE valid_until >= CURRENT_DATE - AND valid_until < CURRENT_DATE + INTERVAL '30 days' - AND is_active = TRUE - AND is_test = FALSE - """) - expiring_soon = cur.fetchone()[0] - - # Testlizenzen vs Vollversionen (ohne Testdaten) - cur.execute(""" - SELECT license_type, COUNT(*) - FROM licenses - WHERE is_test = FALSE - GROUP BY license_type - """) - license_types = dict(cur.fetchall()) - - # Anzahl Testdaten - cur.execute("SELECT COUNT(*) FROM licenses WHERE is_test = TRUE") - test_data_count = cur.fetchone()[0] - - # Anzahl Test-Kunden - cur.execute("SELECT COUNT(*) FROM customers WHERE is_test = TRUE") - test_customers_count = cur.fetchone()[0] - - # Anzahl Test-Ressourcen - cur.execute("SELECT COUNT(*) FROM resource_pools WHERE is_test = TRUE") - test_resources_count = cur.fetchone()[0] - - # Letzte 5 erstellten Lizenzen (ohne Testdaten) - cur.execute(""" - SELECT l.id, l.license_key, c.name, l.valid_until, - CASE - WHEN l.is_active = FALSE THEN 'deaktiviert' - WHEN l.valid_until < CURRENT_DATE THEN 'abgelaufen' - WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'läuft bald ab' - ELSE 'aktiv' - END as status - FROM licenses l - JOIN customers c ON l.customer_id = c.id - WHERE l.is_test = FALSE - ORDER BY l.id DESC - LIMIT 5 - """) - recent_licenses = cur.fetchall() - - # Bald ablaufende Lizenzen (nächste 30 Tage, ohne Testdaten) - cur.execute(""" - SELECT l.id, l.license_key, c.name, l.valid_until, - l.valid_until - CURRENT_DATE as days_left - FROM licenses l - JOIN customers c ON l.customer_id = c.id - WHERE l.valid_until >= CURRENT_DATE - AND l.valid_until < CURRENT_DATE + INTERVAL '30 days' - AND l.is_active = TRUE - AND l.is_test = FALSE - ORDER BY l.valid_until - LIMIT 10 - """) - expiring_licenses = cur.fetchall() - - # Letztes Backup - cur.execute(""" - SELECT created_at, filesize, duration_seconds, backup_type, status - FROM backup_history - ORDER BY created_at DESC - LIMIT 1 - """) - last_backup_info = cur.fetchone() - - # Sicherheitsstatistiken - # Gesperrte IPs - cur.execute(""" - SELECT COUNT(*) FROM login_attempts - WHERE blocked_until IS NOT NULL AND blocked_until > CURRENT_TIMESTAMP - """) - blocked_ips_count = cur.fetchone()[0] - - # Fehlversuche heute - cur.execute(""" - SELECT COALESCE(SUM(attempt_count), 0) FROM login_attempts - WHERE last_attempt::date = CURRENT_DATE - """) - failed_attempts_today = cur.fetchone()[0] - - # Letzte 5 Sicherheitsereignisse - cur.execute(""" - SELECT - la.ip_address, - la.attempt_count, - la.last_attempt, - la.blocked_until, - la.last_username_tried, - la.last_error_message - FROM login_attempts la - ORDER BY la.last_attempt DESC - LIMIT 5 - """) - recent_security_events = [] - for event in cur.fetchall(): - recent_security_events.append({ - 'ip_address': event[0], - 'attempt_count': event[1], - 'last_attempt': event[2].strftime('%d.%m %H:%M'), - 'blocked_until': event[3].strftime('%d.%m %H:%M') if event[3] and event[3] > datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None) else None, - 'username_tried': event[4], - 'error_message': event[5] - }) - - # Sicherheitslevel berechnen - if blocked_ips_count > 5 or failed_attempts_today > 50: - security_level = 'danger' - security_level_text = 'KRITISCH' - elif blocked_ips_count > 2 or failed_attempts_today > 20: - security_level = 'warning' - security_level_text = 'ERHÖHT' - else: - security_level = 'success' - security_level_text = 'NORMAL' - - # Resource Pool Statistiken (nur Live-Daten, keine Testdaten) - cur.execute(""" - SELECT - resource_type, - COUNT(*) FILTER (WHERE status = 'available') as available, - COUNT(*) FILTER (WHERE status = 'allocated') as allocated, - COUNT(*) FILTER (WHERE status = 'quarantine') as quarantine, - COUNT(*) as total - FROM resource_pools - WHERE is_test = FALSE - GROUP BY resource_type - """) - - resource_stats = {} - resource_warning = None - - for row in cur.fetchall(): - available_percent = round((row[1] / row[4] * 100) if row[4] > 0 else 0, 1) - resource_stats[row[0]] = { - 'available': row[1], - 'allocated': row[2], - 'quarantine': row[3], - 'total': row[4], - 'available_percent': available_percent, - 'warning_level': 'danger' if row[1] < 50 else 'warning' if row[1] < 100 else 'success' - } - - # Warnung bei niedrigem Bestand - if row[1] < 50: - if not resource_warning: - resource_warning = f"Niedriger Bestand bei {row[0].upper()}: nur noch {row[1]} verfügbar!" - else: - resource_warning += f" | {row[0].upper()}: {row[1]}" - - cur.close() - conn.close() - - stats = { - 'total_customers': total_customers, - 'total_licenses': total_licenses, - 'active_licenses': active_licenses, - 'expired_licenses': expired_licenses, - 'inactive_licenses': inactive_licenses, - 'expiring_soon': expiring_soon, - 'full_licenses': license_types.get('full', 0), - 'test_licenses': license_types.get('test', 0), - 'test_data_count': test_data_count, - 'test_customers_count': test_customers_count, - 'test_resources_count': test_resources_count, - 'recent_licenses': recent_licenses, - 'expiring_licenses': expiring_licenses, - 'active_sessions': active_sessions_count, - 'last_backup': last_backup_info, - # Sicherheitsstatistiken - 'blocked_ips_count': blocked_ips_count, - 'failed_attempts_today': failed_attempts_today, - 'recent_security_events': recent_security_events, - 'security_level': security_level, - 'security_level_text': security_level_text, - 'resource_stats': resource_stats - } - - return render_template("dashboard.html", - stats=stats, - resource_stats=resource_stats, - resource_warning=resource_warning, - username=session.get('username')) - -@app.route("/create", methods=["GET", "POST"]) -@login_required -def create_license(): - if request.method == "POST": - customer_id = request.form.get("customer_id") - license_key = request.form["license_key"].upper() # Immer Großbuchstaben - license_type = request.form["license_type"] - valid_from = request.form["valid_from"] - is_test = request.form.get("is_test") == "on" # Checkbox value - - # Berechne valid_until basierend auf Laufzeit - duration = int(request.form.get("duration", 1)) - duration_type = request.form.get("duration_type", "years") - - from datetime import datetime, timedelta - from dateutil.relativedelta import relativedelta - - start_date = datetime.strptime(valid_from, "%Y-%m-%d") - - if duration_type == "days": - end_date = start_date + timedelta(days=duration) - elif duration_type == "months": - end_date = start_date + relativedelta(months=duration) - else: # years - end_date = start_date + relativedelta(years=duration) - - # Ein Tag abziehen, da der Starttag mitgezählt wird - end_date = end_date - timedelta(days=1) - valid_until = end_date.strftime("%Y-%m-%d") - - # Validiere License Key Format - if not validate_license_key(license_key): - flash('Ungültiges License Key Format! Erwartet: AF-YYYYMMFT-XXXX-YYYY-ZZZZ', 'error') - return redirect(url_for('create_license')) - - # Resource counts - domain_count = int(request.form.get("domain_count", 1)) - ipv4_count = int(request.form.get("ipv4_count", 1)) - phone_count = int(request.form.get("phone_count", 1)) - device_limit = int(request.form.get("device_limit", 3)) - - conn = get_connection() - cur = conn.cursor() - - try: - # Prüfe ob neuer Kunde oder bestehender - if customer_id == "new": - # Neuer Kunde - name = request.form.get("customer_name") - email = request.form.get("email") - - if not name: - flash('Kundenname ist erforderlich!', 'error') - return redirect(url_for('create_license')) - - # Prüfe ob E-Mail bereits existiert - if email: - cur.execute("SELECT id, name FROM customers WHERE LOWER(email) = LOWER(%s)", (email,)) - existing = cur.fetchone() - if existing: - flash(f'E-Mail bereits vergeben für Kunde: {existing[1]}', 'error') - return redirect(url_for('create_license')) - - # Kunde einfügen (erbt Test-Status von Lizenz) - cur.execute(""" - INSERT INTO customers (name, email, is_test, created_at) - VALUES (%s, %s, %s, NOW()) - RETURNING id - """, (name, email, is_test)) - customer_id = cur.fetchone()[0] - customer_info = {'name': name, 'email': email, 'is_test': is_test} - - # Audit-Log für neuen Kunden - log_audit('CREATE', 'customer', customer_id, - new_values={'name': name, 'email': email, 'is_test': is_test}) - else: - # Bestehender Kunde - hole Infos für Audit-Log - cur.execute("SELECT name, email, is_test FROM customers WHERE id = %s", (customer_id,)) - customer_data = cur.fetchone() - if not customer_data: - flash('Kunde nicht gefunden!', 'error') - return redirect(url_for('create_license')) - customer_info = {'name': customer_data[0], 'email': customer_data[1]} - - # Wenn Kunde Test-Kunde ist, Lizenz auch als Test markieren - if customer_data[2]: # is_test des Kunden - is_test = True - - # Lizenz hinzufügen - cur.execute(""" - INSERT INTO licenses (license_key, customer_id, license_type, valid_from, valid_until, is_active, - domain_count, ipv4_count, phone_count, device_limit, is_test) - VALUES (%s, %s, %s, %s, %s, TRUE, %s, %s, %s, %s, %s) - RETURNING id - """, (license_key, customer_id, license_type, valid_from, valid_until, - domain_count, ipv4_count, phone_count, device_limit, is_test)) - license_id = cur.fetchone()[0] - - # Ressourcen zuweisen - try: - # Prüfe Verfügbarkeit - cur.execute(""" - SELECT - (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s) as domains, - (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s) as ipv4s, - (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s) as phones - """, (is_test, is_test, is_test)) - available = cur.fetchone() - - if available[0] < domain_count: - raise ValueError(f"Nicht genügend Domains verfügbar (benötigt: {domain_count}, verfügbar: {available[0]})") - if available[1] < ipv4_count: - raise ValueError(f"Nicht genügend IPv4-Adressen verfügbar (benötigt: {ipv4_count}, verfügbar: {available[1]})") - if available[2] < phone_count: - raise ValueError(f"Nicht genügend Telefonnummern verfügbar (benötigt: {phone_count}, verfügbar: {available[2]})") - - # Domains zuweisen - if domain_count > 0: - cur.execute(""" - SELECT id FROM resource_pools - WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s - LIMIT %s FOR UPDATE - """, (is_test, domain_count)) - for (resource_id,) in cur.fetchall(): - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], resource_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, resource_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (resource_id, license_id, session['username'], get_client_ip())) - - # IPv4s zuweisen - if ipv4_count > 0: - cur.execute(""" - SELECT id FROM resource_pools - WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s - LIMIT %s FOR UPDATE - """, (is_test, ipv4_count)) - for (resource_id,) in cur.fetchall(): - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], resource_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, resource_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (resource_id, license_id, session['username'], get_client_ip())) - - # Telefonnummern zuweisen - if phone_count > 0: - cur.execute(""" - SELECT id FROM resource_pools - WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s - LIMIT %s FOR UPDATE - """, (is_test, phone_count)) - for (resource_id,) in cur.fetchall(): - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], resource_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, resource_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (resource_id, license_id, session['username'], get_client_ip())) - - except ValueError as e: - conn.rollback() - flash(str(e), 'error') - return redirect(url_for('create_license')) - - conn.commit() - - # Audit-Log - log_audit('CREATE', 'license', license_id, - new_values={ - 'license_key': license_key, - 'customer_name': customer_info['name'], - 'customer_email': customer_info['email'], - 'license_type': license_type, - 'valid_from': valid_from, - 'valid_until': valid_until, - 'device_limit': device_limit, - 'is_test': is_test - }) - - flash(f'Lizenz {license_key} erfolgreich erstellt!', 'success') - - except Exception as e: - conn.rollback() - logging.error(f"Fehler beim Erstellen der Lizenz: {str(e)}") - flash('Fehler beim Erstellen der Lizenz!', 'error') - finally: - cur.close() - conn.close() - - # Preserve show_test parameter if present - redirect_url = "/create" - if request.args.get('show_test') == 'true': - redirect_url += "?show_test=true" - return redirect(redirect_url) - - # Unterstützung für vorausgewählten Kunden - preselected_customer_id = request.args.get('customer_id', type=int) - return render_template("index.html", username=session.get('username'), preselected_customer_id=preselected_customer_id) - -@app.route("/batch", methods=["GET", "POST"]) -@login_required -def batch_licenses(): - """Batch-Generierung mehrerer Lizenzen für einen Kunden""" - if request.method == "POST": - # Formulardaten - customer_id = request.form.get("customer_id") - license_type = request.form["license_type"] - quantity = int(request.form["quantity"]) - valid_from = request.form["valid_from"] - is_test = request.form.get("is_test") == "on" # Checkbox value - - # Berechne valid_until basierend auf Laufzeit - duration = int(request.form.get("duration", 1)) - duration_type = request.form.get("duration_type", "years") - - from datetime import datetime, timedelta - from dateutil.relativedelta import relativedelta - - start_date = datetime.strptime(valid_from, "%Y-%m-%d") - - if duration_type == "days": - end_date = start_date + timedelta(days=duration) - elif duration_type == "months": - end_date = start_date + relativedelta(months=duration) - else: # years - end_date = start_date + relativedelta(years=duration) - - # Ein Tag abziehen, da der Starttag mitgezählt wird - end_date = end_date - timedelta(days=1) - valid_until = end_date.strftime("%Y-%m-%d") - - # Resource counts - domain_count = int(request.form.get("domain_count", 1)) - ipv4_count = int(request.form.get("ipv4_count", 1)) - phone_count = int(request.form.get("phone_count", 1)) - device_limit = int(request.form.get("device_limit", 3)) - - # Sicherheitslimit - if quantity < 1 or quantity > 100: - flash('Anzahl muss zwischen 1 und 100 liegen!', 'error') - return redirect(url_for('batch_licenses')) - - conn = get_connection() - cur = conn.cursor() - - try: - # Prüfe ob neuer Kunde oder bestehender - if customer_id == "new": - # Neuer Kunde - name = request.form.get("customer_name") - email = request.form.get("email") - - if not name: - flash('Kundenname ist erforderlich!', 'error') - return redirect(url_for('batch_licenses')) - - # Prüfe ob E-Mail bereits existiert - if email: - cur.execute("SELECT id, name FROM customers WHERE LOWER(email) = LOWER(%s)", (email,)) - existing = cur.fetchone() - if existing: - flash(f'E-Mail bereits vergeben für Kunde: {existing[1]}', 'error') - return redirect(url_for('batch_licenses')) - - # Kunde einfügen (erbt Test-Status von Lizenz) - cur.execute(""" - INSERT INTO customers (name, email, is_test, created_at) - VALUES (%s, %s, %s, NOW()) - RETURNING id - """, (name, email, is_test)) - customer_id = cur.fetchone()[0] - - # Audit-Log für neuen Kunden - log_audit('CREATE', 'customer', customer_id, - new_values={'name': name, 'email': email, 'is_test': is_test}) - else: - # Bestehender Kunde - hole Infos - cur.execute("SELECT name, email, is_test FROM customers WHERE id = %s", (customer_id,)) - customer_data = cur.fetchone() - if not customer_data: - flash('Kunde nicht gefunden!', 'error') - return redirect(url_for('batch_licenses')) - name = customer_data[0] - email = customer_data[1] - - # Wenn Kunde Test-Kunde ist, Lizenzen auch als Test markieren - if customer_data[2]: # is_test des Kunden - is_test = True - - # Prüfe Ressourcen-Verfügbarkeit für gesamten Batch - total_domains_needed = domain_count * quantity - total_ipv4s_needed = ipv4_count * quantity - total_phones_needed = phone_count * quantity - - cur.execute(""" - SELECT - (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s) as domains, - (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s) as ipv4s, - (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s) as phones - """, (is_test, is_test, is_test)) - available = cur.fetchone() - - if available[0] < total_domains_needed: - flash(f"Nicht genügend Domains verfügbar (benötigt: {total_domains_needed}, verfügbar: {available[0]})", 'error') - return redirect(url_for('batch_licenses')) - if available[1] < total_ipv4s_needed: - flash(f"Nicht genügend IPv4-Adressen verfügbar (benötigt: {total_ipv4s_needed}, verfügbar: {available[1]})", 'error') - return redirect(url_for('batch_licenses')) - if available[2] < total_phones_needed: - flash(f"Nicht genügend Telefonnummern verfügbar (benötigt: {total_phones_needed}, verfügbar: {available[2]})", 'error') - return redirect(url_for('batch_licenses')) - - # Lizenzen generieren und speichern - generated_licenses = [] - for i in range(quantity): - # Eindeutigen Key generieren - attempts = 0 - while attempts < 10: - license_key = generate_license_key(license_type) - cur.execute("SELECT 1 FROM licenses WHERE license_key = %s", (license_key,)) - if not cur.fetchone(): - break - attempts += 1 - - # Lizenz einfügen - cur.execute(""" - INSERT INTO licenses (license_key, customer_id, license_type, is_test, - valid_from, valid_until, is_active, - domain_count, ipv4_count, phone_count, device_limit) - VALUES (%s, %s, %s, %s, %s, %s, true, %s, %s, %s, %s) - RETURNING id - """, (license_key, customer_id, license_type, is_test, valid_from, valid_until, - domain_count, ipv4_count, phone_count, device_limit)) - license_id = cur.fetchone()[0] - - # Ressourcen für diese Lizenz zuweisen - # Domains - if domain_count > 0: - cur.execute(""" - SELECT id FROM resource_pools - WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s - LIMIT %s FOR UPDATE - """, (is_test, domain_count)) - for (resource_id,) in cur.fetchall(): - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], resource_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, resource_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (resource_id, license_id, session['username'], get_client_ip())) - - # IPv4s - if ipv4_count > 0: - cur.execute(""" - SELECT id FROM resource_pools - WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s - LIMIT %s FOR UPDATE - """, (is_test, ipv4_count)) - for (resource_id,) in cur.fetchall(): - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], resource_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, resource_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (resource_id, license_id, session['username'], get_client_ip())) - - # Telefonnummern - if phone_count > 0: - cur.execute(""" - SELECT id FROM resource_pools - WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s - LIMIT %s FOR UPDATE - """, (is_test, phone_count)) - for (resource_id,) in cur.fetchall(): - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], resource_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, resource_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (resource_id, license_id, session['username'], get_client_ip())) - - generated_licenses.append({ - 'id': license_id, - 'key': license_key, - 'type': license_type - }) - - conn.commit() - - # Audit-Log - log_audit('CREATE_BATCH', 'license', - new_values={'customer': name, 'quantity': quantity, 'type': license_type}, - additional_info=f"Batch-Generierung von {quantity} Lizenzen") - - # Session für Export speichern - session['batch_export'] = { - 'customer': name, - 'email': email, - 'licenses': generated_licenses, - 'valid_from': valid_from, - 'valid_until': valid_until, - 'timestamp': datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() - } - - flash(f'{quantity} Lizenzen erfolgreich generiert!', 'success') - return render_template("batch_result.html", - customer=name, - email=email, - licenses=generated_licenses, - valid_from=valid_from, - valid_until=valid_until) - - except Exception as e: - conn.rollback() - logging.error(f"Fehler bei Batch-Generierung: {str(e)}") - flash('Fehler bei der Batch-Generierung!', 'error') - return redirect(url_for('batch_licenses')) - finally: - cur.close() - conn.close() - - # GET Request - return render_template("batch_form.html") - -@app.route("/batch/export") -@login_required -def export_batch(): - """Exportiert die zuletzt generierten Batch-Lizenzen""" - batch_data = session.get('batch_export') - if not batch_data: - flash('Keine Batch-Daten zum Exportieren vorhanden!', 'error') - return redirect(url_for('batch_licenses')) - - # CSV generieren - output = io.StringIO() - output.write('\ufeff') # UTF-8 BOM für Excel - - # Header - output.write(f"Kunde: {batch_data['customer']}\n") - output.write(f"E-Mail: {batch_data['email']}\n") - output.write(f"Generiert am: {datetime.fromisoformat(batch_data['timestamp']).strftime('%d.%m.%Y %H:%M')}\n") - output.write(f"Gültig von: {batch_data['valid_from']} bis {batch_data['valid_until']}\n") - output.write("\n") - output.write("Nr;Lizenzschlüssel;Typ\n") - - # Lizenzen - for i, license in enumerate(batch_data['licenses'], 1): - typ_text = "Vollversion" if license['type'] == 'full' else "Testversion" - output.write(f"{i};{license['key']};{typ_text}\n") - - output.seek(0) - - # Audit-Log - log_audit('EXPORT', 'batch_licenses', - additional_info=f"Export von {len(batch_data['licenses'])} Batch-Lizenzen") - - return send_file( - io.BytesIO(output.getvalue().encode('utf-8-sig')), - mimetype='text/csv', - as_attachment=True, - download_name=f"batch_licenses_{batch_data['customer'].replace(' ', '_')}_{datetime.now(ZoneInfo('Europe/Berlin')).strftime('%Y%m%d_%H%M%S')}.csv" - ) - -@app.route("/licenses") -@login_required -def licenses(): - # Redirect zur kombinierten Ansicht - return redirect("/customers-licenses") - -@app.route("/license/edit/", methods=["GET", "POST"]) -@login_required -def edit_license(license_id): - conn = get_connection() - cur = conn.cursor() - - if request.method == "POST": - # Alte Werte für Audit-Log abrufen - cur.execute(""" - SELECT license_key, license_type, valid_from, valid_until, is_active, is_test, device_limit - FROM licenses WHERE id = %s - """, (license_id,)) - old_license = cur.fetchone() - - # Update license - license_key = request.form["license_key"] - license_type = request.form["license_type"] - valid_from = request.form["valid_from"] - valid_until = request.form["valid_until"] - is_active = request.form.get("is_active") == "on" - is_test = request.form.get("is_test") == "on" - device_limit = int(request.form.get("device_limit", 3)) - - cur.execute(""" - UPDATE licenses - SET license_key = %s, license_type = %s, valid_from = %s, - valid_until = %s, is_active = %s, is_test = %s, device_limit = %s - WHERE id = %s - """, (license_key, license_type, valid_from, valid_until, is_active, is_test, device_limit, license_id)) - - conn.commit() - - # Audit-Log - log_audit('UPDATE', 'license', license_id, - old_values={ - 'license_key': old_license[0], - 'license_type': old_license[1], - 'valid_from': str(old_license[2]), - 'valid_until': str(old_license[3]), - 'is_active': old_license[4], - 'is_test': old_license[5], - 'device_limit': old_license[6] - }, - new_values={ - 'license_key': license_key, - 'license_type': license_type, - 'valid_from': valid_from, - 'valid_until': valid_until, - 'is_active': is_active, - 'is_test': is_test, - 'device_limit': device_limit - }) - - cur.close() - conn.close() - - # Redirect zurück zu customers-licenses mit beibehaltenen Parametern - redirect_url = "/customers-licenses" - - # Behalte show_test Parameter bei (aus Form oder GET-Parameter) - show_test = request.form.get('show_test') or request.args.get('show_test') - if show_test == 'true': - redirect_url += "?show_test=true" - - # Behalte customer_id bei wenn vorhanden - if request.referrer and 'customer_id=' in request.referrer: - import re - match = re.search(r'customer_id=(\d+)', request.referrer) - if match: - connector = "&" if "?" in redirect_url else "?" - redirect_url += f"{connector}customer_id={match.group(1)}" - - return redirect(redirect_url) - - # Get license data - cur.execute(""" - SELECT l.id, l.license_key, c.name, c.email, l.license_type, - l.valid_from, l.valid_until, l.is_active, c.id, l.is_test, l.device_limit - FROM licenses l - JOIN customers c ON l.customer_id = c.id - WHERE l.id = %s - """, (license_id,)) - - license = cur.fetchone() - cur.close() - conn.close() - - if not license: - return redirect("/licenses") - - return render_template("edit_license.html", license=license, username=session.get('username')) - -@app.route("/license/delete/", methods=["POST"]) -@login_required -def delete_license(license_id): - conn = get_connection() - cur = conn.cursor() - - # Lizenzdetails für Audit-Log abrufen - cur.execute(""" - SELECT l.license_key, c.name, l.license_type - FROM licenses l - JOIN customers c ON l.customer_id = c.id - WHERE l.id = %s - """, (license_id,)) - license_info = cur.fetchone() - - cur.execute("DELETE FROM licenses WHERE id = %s", (license_id,)) - - conn.commit() - - # Audit-Log - if license_info: - log_audit('DELETE', 'license', license_id, - old_values={ - 'license_key': license_info[0], - 'customer_name': license_info[1], - 'license_type': license_info[2] - }) - - cur.close() - conn.close() - - return redirect("/licenses") - -@app.route("/customers") -@login_required -def customers(): - # Redirect zur kombinierten Ansicht - return redirect("/customers-licenses") - -@app.route("/customer/edit/", methods=["GET", "POST"]) -@login_required -def edit_customer(customer_id): - conn = get_connection() - cur = conn.cursor() - - if request.method == "POST": - # Alte Werte für Audit-Log abrufen - cur.execute("SELECT name, email, is_test FROM customers WHERE id = %s", (customer_id,)) - old_customer = cur.fetchone() - - # Update customer - name = request.form["name"] - email = request.form["email"] - is_test = request.form.get("is_test") == "on" - - cur.execute(""" - UPDATE customers - SET name = %s, email = %s, is_test = %s - WHERE id = %s - """, (name, email, is_test, customer_id)) - - conn.commit() - - # Audit-Log - log_audit('UPDATE', 'customer', customer_id, - old_values={ - 'name': old_customer[0], - 'email': old_customer[1], - 'is_test': old_customer[2] - }, - new_values={ - 'name': name, - 'email': email, - 'is_test': is_test - }) - - cur.close() - conn.close() - - # Redirect zurück zu customers-licenses mit beibehaltenen Parametern - redirect_url = "/customers-licenses" - - # Behalte show_test Parameter bei (aus Form oder GET-Parameter) - show_test = request.form.get('show_test') or request.args.get('show_test') - if show_test == 'true': - redirect_url += "?show_test=true" - - # Behalte customer_id bei (immer der aktuelle Kunde) - connector = "&" if "?" in redirect_url else "?" - redirect_url += f"{connector}customer_id={customer_id}" - - return redirect(redirect_url) - - # Get customer data with licenses - cur.execute(""" - SELECT id, name, email, created_at, is_test FROM customers WHERE id = %s - """, (customer_id,)) - - customer = cur.fetchone() - if not customer: - cur.close() - conn.close() - return "Kunde nicht gefunden", 404 - - - # Get customer's licenses - cur.execute(""" - SELECT id, license_key, license_type, valid_from, valid_until, is_active - FROM licenses - WHERE customer_id = %s - ORDER BY valid_until DESC - """, (customer_id,)) - - licenses = cur.fetchall() - - cur.close() - conn.close() - - if not customer: - return redirect("/customers-licenses") - - return render_template("edit_customer.html", customer=customer, licenses=licenses, username=session.get('username')) - -@app.route("/customer/create", methods=["GET", "POST"]) -@login_required -def create_customer(): - """Erstellt einen neuen Kunden ohne Lizenz""" - if request.method == "POST": - name = request.form.get('name') - email = request.form.get('email') - is_test = request.form.get('is_test') == 'on' - - if not name or not email: - flash("Name und E-Mail sind Pflichtfelder!", "error") - return render_template("create_customer.html", username=session.get('username')) - - conn = get_connection() - cur = conn.cursor() - - try: - # Prüfen ob E-Mail bereits existiert - cur.execute("SELECT id, name FROM customers WHERE email = %s", (email,)) - existing = cur.fetchone() - if existing: - flash(f"Ein Kunde mit der E-Mail '{email}' existiert bereits: {existing[1]}", "error") - return render_template("create_customer.html", username=session.get('username')) - - # Kunde erstellen - cur.execute(""" - INSERT INTO customers (name, email, created_at, is_test) - VALUES (%s, %s, %s, %s) RETURNING id - """, (name, email, datetime.now(), is_test)) - - customer_id = cur.fetchone()[0] - conn.commit() - - # Audit-Log - log_audit('CREATE', 'customer', customer_id, - new_values={ - 'name': name, - 'email': email, - 'is_test': is_test - }) - - flash(f"Kunde '{name}' wurde erfolgreich angelegt!", "success") - return redirect(f"/customer/edit/{customer_id}") - - except Exception as e: - conn.rollback() - flash(f"Fehler beim Anlegen des Kunden: {str(e)}", "error") - return render_template("create_customer.html", username=session.get('username')) - finally: - cur.close() - conn.close() - - # GET Request - Formular anzeigen - return render_template("create_customer.html", username=session.get('username')) - -@app.route("/customer/delete/", methods=["POST"]) -@login_required -def delete_customer(customer_id): - conn = get_connection() - cur = conn.cursor() - - # Prüfen ob Kunde Lizenzen hat - cur.execute("SELECT COUNT(*) FROM licenses WHERE customer_id = %s", (customer_id,)) - license_count = cur.fetchone()[0] - - if license_count > 0: - # Kunde hat Lizenzen - nicht löschen - cur.close() - conn.close() - return redirect("/customers") - - # Kundendetails für Audit-Log abrufen - cur.execute("SELECT name, email FROM customers WHERE id = %s", (customer_id,)) - customer_info = cur.fetchone() - - # Kunde löschen wenn keine Lizenzen vorhanden - cur.execute("DELETE FROM customers WHERE id = %s", (customer_id,)) - - conn.commit() - - # Audit-Log - if customer_info: - log_audit('DELETE', 'customer', customer_id, - old_values={ - 'name': customer_info[0], - 'email': customer_info[1] - }) - - cur.close() - conn.close() - - return redirect("/customers") - -@app.route("/customers-licenses") -@login_required -def customers_licenses(): - """Kombinierte Ansicht für Kunden und deren Lizenzen""" - conn = get_connection() - cur = conn.cursor() - - # Hole alle Kunden mit Lizenzstatistiken (inkl. Testkunden wenn gewünscht) - show_test = request.args.get('show_test', 'false').lower() == 'true' - - query = """ - SELECT - c.id, - c.name, - c.email, - c.created_at, - COUNT(l.id) as total_licenses, - COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE THEN 1 END) as active_licenses, - COUNT(CASE WHEN l.valid_until < CURRENT_DATE THEN 1 END) as expired_licenses - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - """ - - if not show_test: - query += " WHERE c.is_test = FALSE" - - query += """ - GROUP BY c.id, c.name, c.email, c.created_at - ORDER BY c.name - """ - - cur.execute(query) - customers = cur.fetchall() - - # Hole ausgewählten Kunden nur wenn explizit in URL angegeben - selected_customer_id = request.args.get('customer_id', type=int) - licenses = [] - selected_customer = None - - if customers and selected_customer_id: - # Hole Daten des ausgewählten Kunden - for customer in customers: - if customer[0] == selected_customer_id: - selected_customer = customer - break - - # Hole Lizenzen des ausgewählten Kunden - if selected_customer: - cur.execute(""" - SELECT - l.id, - l.license_key, - l.license_type, - l.valid_from, - l.valid_until, - l.is_active, - CASE - WHEN l.is_active = FALSE THEN 'deaktiviert' - WHEN l.valid_until < CURRENT_DATE THEN 'abgelaufen' - WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'läuft bald ab' - ELSE 'aktiv' - END as status, - l.domain_count, - l.ipv4_count, - l.phone_count, - l.device_limit, - (SELECT COUNT(*) FROM device_registrations WHERE license_id = l.id AND is_active = TRUE) as active_devices, - -- Actual resource counts - (SELECT COUNT(*) FROM license_resources lr - JOIN resource_pools rp ON lr.resource_id = rp.id - WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'domain') as actual_domain_count, - (SELECT COUNT(*) FROM license_resources lr - JOIN resource_pools rp ON lr.resource_id = rp.id - WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'ipv4') as actual_ipv4_count, - (SELECT COUNT(*) FROM license_resources lr - JOIN resource_pools rp ON lr.resource_id = rp.id - WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'phone') as actual_phone_count - FROM licenses l - WHERE l.customer_id = %s - ORDER BY l.created_at DESC, l.id DESC - """, (selected_customer_id,)) - licenses = cur.fetchall() - - cur.close() - conn.close() - - return render_template("customers_licenses.html", - customers=customers, - selected_customer=selected_customer, - selected_customer_id=selected_customer_id, - licenses=licenses, - show_test=show_test) - -@app.route("/api/customer//licenses") -@login_required -def api_customer_licenses(customer_id): - """API-Endpoint für AJAX-Abruf der Lizenzen eines Kunden""" - conn = get_connection() - cur = conn.cursor() - - # Hole Lizenzen des Kunden - cur.execute(""" - SELECT - l.id, - l.license_key, - l.license_type, - l.valid_from, - l.valid_until, - l.is_active, - CASE - WHEN l.is_active = FALSE THEN 'deaktiviert' - WHEN l.valid_until < CURRENT_DATE THEN 'abgelaufen' - WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'läuft bald ab' - ELSE 'aktiv' - END as status, - l.domain_count, - l.ipv4_count, - l.phone_count, - l.device_limit, - (SELECT COUNT(*) FROM device_registrations WHERE license_id = l.id AND is_active = TRUE) as active_devices, - -- Actual resource counts - (SELECT COUNT(*) FROM license_resources lr - JOIN resource_pools rp ON lr.resource_id = rp.id - WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'domain') as actual_domain_count, - (SELECT COUNT(*) FROM license_resources lr - JOIN resource_pools rp ON lr.resource_id = rp.id - WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'ipv4') as actual_ipv4_count, - (SELECT COUNT(*) FROM license_resources lr - JOIN resource_pools rp ON lr.resource_id = rp.id - WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'phone') as actual_phone_count - FROM licenses l - WHERE l.customer_id = %s - ORDER BY l.created_at DESC, l.id DESC - """, (customer_id,)) - - licenses = [] - for row in cur.fetchall(): - license_id = row[0] - - # Hole die konkreten zugewiesenen Ressourcen für diese Lizenz - cur.execute(""" - SELECT rp.id, rp.resource_type, rp.resource_value, lr.assigned_at - FROM resource_pools rp - JOIN license_resources lr ON rp.id = lr.resource_id - WHERE lr.license_id = %s AND lr.is_active = true - ORDER BY rp.resource_type, rp.resource_value - """, (license_id,)) - - resources = { - 'domains': [], - 'ipv4s': [], - 'phones': [] - } - - for res_row in cur.fetchall(): - resource_info = { - 'id': res_row[0], - 'value': res_row[2], - 'assigned_at': res_row[3].strftime('%d.%m.%Y') if res_row[3] else '' - } - - if res_row[1] == 'domain': - resources['domains'].append(resource_info) - elif res_row[1] == 'ipv4': - resources['ipv4s'].append(resource_info) - elif res_row[1] == 'phone': - resources['phones'].append(resource_info) - - licenses.append({ - 'id': row[0], - 'license_key': row[1], - 'license_type': row[2], - 'valid_from': row[3].strftime('%d.%m.%Y') if row[3] else '', - 'valid_until': row[4].strftime('%d.%m.%Y') if row[4] else '', - 'is_active': row[5], - 'status': row[6], - 'domain_count': row[7], # limit - 'ipv4_count': row[8], # limit - 'phone_count': row[9], # limit - 'device_limit': row[10], - 'active_devices': row[11], - 'actual_domain_count': row[12], # actual count - 'actual_ipv4_count': row[13], # actual count - 'actual_phone_count': row[14], # actual count - 'resources': resources - }) - - cur.close() - conn.close() - - return jsonify({ - 'success': True, - 'licenses': licenses, - 'count': len(licenses) - }) - -@app.route("/api/customer//quick-stats") -@login_required -def api_customer_quick_stats(customer_id): - """API-Endpoint für Schnellstatistiken eines Kunden""" - conn = get_connection() - cur = conn.cursor() - - # Hole Kundenstatistiken - cur.execute(""" - SELECT - COUNT(l.id) as total_licenses, - COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE THEN 1 END) as active_licenses, - COUNT(CASE WHEN l.valid_until < CURRENT_DATE THEN 1 END) as expired_licenses, - COUNT(CASE WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' AND l.valid_until >= CURRENT_DATE THEN 1 END) as expiring_soon - FROM licenses l - WHERE l.customer_id = %s - """, (customer_id,)) - - stats = cur.fetchone() - - cur.close() - conn.close() - - return jsonify({ - 'success': True, - 'stats': { - 'total': stats[0], - 'active': stats[1], - 'expired': stats[2], - 'expiring_soon': stats[3] - } - }) - -@app.route("/api/license//quick-edit", methods=['POST']) -@login_required -def api_license_quick_edit(license_id): - """API-Endpoint für schnelle Lizenz-Bearbeitung""" - conn = get_connection() - cur = conn.cursor() - - try: - data = request.get_json() - - # Hole alte Werte für Audit-Log - cur.execute(""" - SELECT is_active, valid_until, license_type - FROM licenses WHERE id = %s - """, (license_id,)) - old_values = cur.fetchone() - - if not old_values: - return jsonify({'success': False, 'error': 'Lizenz nicht gefunden'}), 404 - - # Update-Felder vorbereiten - updates = [] - params = [] - new_values = {} - - if 'is_active' in data: - updates.append("is_active = %s") - params.append(data['is_active']) - new_values['is_active'] = data['is_active'] - - if 'valid_until' in data: - updates.append("valid_until = %s") - params.append(data['valid_until']) - new_values['valid_until'] = data['valid_until'] - - if 'license_type' in data: - updates.append("license_type = %s") - params.append(data['license_type']) - new_values['license_type'] = data['license_type'] - - if updates: - params.append(license_id) - cur.execute(f""" - UPDATE licenses - SET {', '.join(updates)} - WHERE id = %s - """, params) - - conn.commit() - - # Audit-Log - log_audit('UPDATE', 'license', license_id, - old_values={ - 'is_active': old_values[0], - 'valid_until': old_values[1].isoformat() if old_values[1] else None, - 'license_type': old_values[2] - }, - new_values=new_values) - - cur.close() - conn.close() - - return jsonify({'success': True}) - - except Exception as e: - conn.rollback() - cur.close() - conn.close() - return jsonify({'success': False, 'error': str(e)}), 500 - -@app.route("/api/license//resources") -@login_required -def api_license_resources(license_id): - """API-Endpoint für detaillierte Ressourcen-Informationen einer Lizenz""" - conn = get_connection() - cur = conn.cursor() - - try: - # Hole die konkreten zugewiesenen Ressourcen für diese Lizenz - cur.execute(""" - SELECT rp.id, rp.resource_type, rp.resource_value, lr.assigned_at - FROM resource_pools rp - JOIN license_resources lr ON rp.id = lr.resource_id - WHERE lr.license_id = %s AND lr.is_active = true - ORDER BY rp.resource_type, rp.resource_value - """, (license_id,)) - - resources = { - 'domains': [], - 'ipv4s': [], - 'phones': [] - } - - for row in cur.fetchall(): - resource_info = { - 'id': row[0], - 'value': row[2], - 'assigned_at': row[3].strftime('%d.%m.%Y') if row[3] else '' - } - - if row[1] == 'domain': - resources['domains'].append(resource_info) - elif row[1] == 'ipv4': - resources['ipv4s'].append(resource_info) - elif row[1] == 'phone': - resources['phones'].append(resource_info) - - cur.close() - conn.close() - - return jsonify({ - 'success': True, - 'resources': resources - }) - - except Exception as e: - cur.close() - conn.close() - return jsonify({'success': False, 'error': str(e)}), 500 - -@app.route("/sessions") -@login_required -def sessions(): - conn = get_connection() - cur = conn.cursor() - - # Sortierparameter - active_sort = request.args.get('active_sort', 'last_heartbeat') - active_order = request.args.get('active_order', 'desc') - ended_sort = request.args.get('ended_sort', 'ended_at') - ended_order = request.args.get('ended_order', 'desc') - - # Whitelist für erlaubte Sortierfelder - Aktive Sessions - active_sort_fields = { - 'customer': 'c.name', - 'license': 'l.license_key', - 'ip': 's.ip_address', - 'started': 's.started_at', - 'last_heartbeat': 's.last_heartbeat', - 'inactive': 'minutes_inactive' - } - - # Whitelist für erlaubte Sortierfelder - Beendete Sessions - ended_sort_fields = { - 'customer': 'c.name', - 'license': 'l.license_key', - 'ip': 's.ip_address', - 'started': 's.started_at', - 'ended_at': 's.ended_at', - 'duration': 'duration_minutes' - } - - # Validierung - if active_sort not in active_sort_fields: - active_sort = 'last_heartbeat' - if ended_sort not in ended_sort_fields: - ended_sort = 'ended_at' - if active_order not in ['asc', 'desc']: - active_order = 'desc' - if ended_order not in ['asc', 'desc']: - ended_order = 'desc' - - # Aktive Sessions abrufen - cur.execute(f""" - SELECT s.id, s.session_id, l.license_key, c.name, s.ip_address, - s.user_agent, s.started_at, s.last_heartbeat, - EXTRACT(EPOCH FROM (NOW() - s.last_heartbeat))/60 as minutes_inactive - FROM sessions s - JOIN licenses l ON s.license_id = l.id - JOIN customers c ON l.customer_id = c.id - WHERE s.is_active = TRUE - ORDER BY {active_sort_fields[active_sort]} {active_order.upper()} - """) - active_sessions = cur.fetchall() - - # Inaktive Sessions der letzten 24 Stunden - cur.execute(f""" - SELECT s.id, s.session_id, l.license_key, c.name, s.ip_address, - s.started_at, s.ended_at, - EXTRACT(EPOCH FROM (s.ended_at - s.started_at))/60 as duration_minutes - FROM sessions s - JOIN licenses l ON s.license_id = l.id - JOIN customers c ON l.customer_id = c.id - WHERE s.is_active = FALSE - AND s.ended_at > NOW() - INTERVAL '24 hours' - ORDER BY {ended_sort_fields[ended_sort]} {ended_order.upper()} - LIMIT 50 - """) - recent_sessions = cur.fetchall() - - cur.close() - conn.close() - - return render_template("sessions.html", - active_sessions=active_sessions, - recent_sessions=recent_sessions, - active_sort=active_sort, - active_order=active_order, - ended_sort=ended_sort, - ended_order=ended_order, - username=session.get('username')) - -@app.route("/session/end/", methods=["POST"]) -@login_required -def end_session(session_id): - conn = get_connection() - cur = conn.cursor() - - # Session beenden - cur.execute(""" - UPDATE sessions - SET is_active = FALSE, ended_at = NOW() - WHERE id = %s AND is_active = TRUE - """, (session_id,)) - - conn.commit() - cur.close() - conn.close() - - return redirect("/sessions") - -@app.route("/export/licenses") -@login_required -def export_licenses(): - conn = get_connection() - cur = conn.cursor() - - # Alle Lizenzen mit Kundeninformationen abrufen (ohne Testdaten, außer explizit gewünscht) - include_test = request.args.get('include_test', 'false').lower() == 'true' - customer_id = request.args.get('customer_id', type=int) - - query = """ - SELECT l.id, l.license_key, c.name as customer_name, c.email as customer_email, - l.license_type, l.valid_from, l.valid_until, l.is_active, l.is_test, - CASE - WHEN l.is_active = FALSE THEN 'Deaktiviert' - WHEN l.valid_until < CURRENT_DATE THEN 'Abgelaufen' - WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'Läuft bald ab' - ELSE 'Aktiv' - END as status - FROM licenses l - JOIN customers c ON l.customer_id = c.id - """ - - # Build WHERE clause - where_conditions = [] - params = [] - - if not include_test: - where_conditions.append("l.is_test = FALSE") - - if customer_id: - where_conditions.append("l.customer_id = %s") - params.append(customer_id) - - if where_conditions: - query += " WHERE " + " AND ".join(where_conditions) - - query += " ORDER BY l.id" - - cur.execute(query, params) - - # Spaltennamen - columns = ['ID', 'Lizenzschlüssel', 'Kunde', 'E-Mail', 'Typ', - 'Gültig von', 'Gültig bis', 'Aktiv', 'Testdaten', 'Status'] - - # Daten in DataFrame - data = cur.fetchall() - df = pd.DataFrame(data, columns=columns) - - # Datumsformatierung - df['Gültig von'] = pd.to_datetime(df['Gültig von']).dt.strftime('%d.%m.%Y') - df['Gültig bis'] = pd.to_datetime(df['Gültig bis']).dt.strftime('%d.%m.%Y') - - # Typ und Aktiv Status anpassen - df['Typ'] = df['Typ'].replace({'full': 'Vollversion', 'test': 'Testversion'}) - df['Aktiv'] = df['Aktiv'].replace({True: 'Ja', False: 'Nein'}) - df['Testdaten'] = df['Testdaten'].replace({True: 'Ja', False: 'Nein'}) - - cur.close() - conn.close() - - # Export Format - export_format = request.args.get('format', 'excel') - - # Audit-Log - log_audit('EXPORT', 'license', - additional_info=f"Export aller Lizenzen als {export_format.upper()}") - filename = f'lizenzen_export_{datetime.now(ZoneInfo("Europe/Berlin")).strftime("%Y%m%d_%H%M%S")}' - - if export_format == 'csv': - # CSV Export - output = io.StringIO() - df.to_csv(output, index=False, encoding='utf-8-sig', sep=';') - output.seek(0) - - return send_file( - io.BytesIO(output.getvalue().encode('utf-8-sig')), - mimetype='text/csv', - as_attachment=True, - download_name=f'{filename}.csv' - ) - else: - # Excel Export - output = io.BytesIO() - with pd.ExcelWriter(output, engine='openpyxl') as writer: - df.to_excel(writer, sheet_name='Lizenzen', index=False) - - # Formatierung - worksheet = writer.sheets['Lizenzen'] - for column in worksheet.columns: - max_length = 0 - column_letter = column[0].column_letter - for cell in column: - try: - if len(str(cell.value)) > max_length: - max_length = len(str(cell.value)) - except: - pass - adjusted_width = min(max_length + 2, 50) - worksheet.column_dimensions[column_letter].width = adjusted_width - - output.seek(0) - - return send_file( - output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=f'{filename}.xlsx' - ) - -@app.route("/export/audit") -@login_required -def export_audit(): - conn = get_connection() - cur = conn.cursor() - - # Holen der Filter-Parameter - filter_user = request.args.get('user', '') - filter_action = request.args.get('action', '') - filter_entity = request.args.get('entity', '') - export_format = request.args.get('format', 'excel') - - # SQL Query mit Filtern - query = """ - SELECT id, timestamp, username, action, entity_type, entity_id, - old_values, new_values, ip_address, user_agent, additional_info - FROM audit_log - WHERE 1=1 - """ - params = [] - - if filter_user: - query += " AND username ILIKE %s" - params.append(f'%{filter_user}%') - - if filter_action: - query += " AND action = %s" - params.append(filter_action) - - if filter_entity: - query += " AND entity_type = %s" - params.append(filter_entity) - - query += " ORDER BY timestamp DESC" - - cur.execute(query, params) - audit_logs = cur.fetchall() - cur.close() - conn.close() - - # Daten für Export vorbereiten - data = [] - for log in audit_logs: - action_text = { - 'CREATE': 'Erstellt', - 'UPDATE': 'Bearbeitet', - 'DELETE': 'Gelöscht', - 'LOGIN': 'Anmeldung', - 'LOGOUT': 'Abmeldung', - 'AUTO_LOGOUT': 'Auto-Logout', - 'EXPORT': 'Export', - 'GENERATE_KEY': 'Key generiert', - 'CREATE_BATCH': 'Batch erstellt', - 'BACKUP': 'Backup erstellt', - 'LOGIN_2FA_SUCCESS': '2FA-Anmeldung', - 'LOGIN_2FA_BACKUP': '2FA-Backup-Code', - 'LOGIN_2FA_FAILED': '2FA-Fehlgeschlagen', - 'LOGIN_BLOCKED': 'Login-Blockiert', - 'RESTORE': 'Wiederhergestellt', - 'PASSWORD_CHANGE': 'Passwort geändert', - '2FA_ENABLED': '2FA aktiviert', - '2FA_DISABLED': '2FA deaktiviert' - }.get(log[3], log[3]) - - data.append({ - 'ID': log[0], - 'Zeitstempel': log[1].strftime('%d.%m.%Y %H:%M:%S'), - 'Benutzer': log[2], - 'Aktion': action_text, - 'Entität': log[4], - 'Entität-ID': log[5] or '', - 'IP-Adresse': log[8] or '', - 'Zusatzinfo': log[10] or '' - }) - - # DataFrame erstellen - df = pd.DataFrame(data) - - # Timestamp für Dateiname - timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') - filename = f'audit_log_export_{timestamp}' - - # Audit Log für Export - log_audit('EXPORT', 'audit_log', - additional_info=f"{export_format.upper()} Export mit {len(data)} Einträgen") - - if export_format == 'csv': - # CSV Export - output = io.StringIO() - # UTF-8 BOM für Excel - output.write('\ufeff') - df.to_csv(output, index=False, sep=';', encoding='utf-8') - output.seek(0) - - return send_file( - io.BytesIO(output.getvalue().encode('utf-8')), - mimetype='text/csv;charset=utf-8', - as_attachment=True, - download_name=f'{filename}.csv' - ) - else: - # Excel Export - output = BytesIO() - with pd.ExcelWriter(output, engine='openpyxl') as writer: - df.to_excel(writer, index=False, sheet_name='Audit Log') - - # Spaltenbreiten anpassen - worksheet = writer.sheets['Audit Log'] - for idx, col in enumerate(df.columns): - max_length = max( - df[col].astype(str).map(len).max(), - len(col) - ) + 2 - worksheet.column_dimensions[get_column_letter(idx + 1)].width = min(max_length, 50) - - output.seek(0) - - return send_file( - output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=f'{filename}.xlsx' - ) - -@app.route("/export/customers") -@login_required -def export_customers(): - conn = get_connection() - cur = conn.cursor() - - # Check if test data should be included - include_test = request.args.get('include_test', 'false').lower() == 'true' - - # Build query based on test data filter - if include_test: - # Include all customers - query = """ - SELECT c.id, c.name, c.email, c.created_at, c.is_test, - COUNT(l.id) as total_licenses, - COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE THEN 1 END) as active_licenses, - COUNT(CASE WHEN l.valid_until < CURRENT_DATE THEN 1 END) as expired_licenses - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - GROUP BY c.id, c.name, c.email, c.created_at, c.is_test - ORDER BY c.id - """ - else: - # Exclude test customers and test licenses - query = """ - SELECT c.id, c.name, c.email, c.created_at, c.is_test, - COUNT(CASE WHEN l.is_test = FALSE THEN 1 END) as total_licenses, - COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE AND l.is_test = FALSE THEN 1 END) as active_licenses, - COUNT(CASE WHEN l.valid_until < CURRENT_DATE AND l.is_test = FALSE THEN 1 END) as expired_licenses - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - WHERE c.is_test = FALSE - GROUP BY c.id, c.name, c.email, c.created_at, c.is_test - ORDER BY c.id - """ - - cur.execute(query) - - # Spaltennamen - columns = ['ID', 'Name', 'E-Mail', 'Erstellt am', 'Testdaten', - 'Lizenzen gesamt', 'Aktive Lizenzen', 'Abgelaufene Lizenzen'] - - # Daten in DataFrame - data = cur.fetchall() - df = pd.DataFrame(data, columns=columns) - - # Datumsformatierung - df['Erstellt am'] = pd.to_datetime(df['Erstellt am']).dt.strftime('%d.%m.%Y %H:%M') - - # Testdaten formatting - df['Testdaten'] = df['Testdaten'].replace({True: 'Ja', False: 'Nein'}) - - cur.close() - conn.close() - - # Export Format - export_format = request.args.get('format', 'excel') - - # Audit-Log - log_audit('EXPORT', 'customer', - additional_info=f"Export aller Kunden als {export_format.upper()}") - filename = f'kunden_export_{datetime.now(ZoneInfo("Europe/Berlin")).strftime("%Y%m%d_%H%M%S")}' - - if export_format == 'csv': - # CSV Export - output = io.StringIO() - df.to_csv(output, index=False, encoding='utf-8-sig', sep=';') - output.seek(0) - - return send_file( - io.BytesIO(output.getvalue().encode('utf-8-sig')), - mimetype='text/csv', - as_attachment=True, - download_name=f'{filename}.csv' - ) - else: - # Excel Export - output = io.BytesIO() - with pd.ExcelWriter(output, engine='openpyxl') as writer: - df.to_excel(writer, sheet_name='Kunden', index=False) - - # Formatierung - worksheet = writer.sheets['Kunden'] - for column in worksheet.columns: - max_length = 0 - column_letter = column[0].column_letter - for cell in column: - try: - if len(str(cell.value)) > max_length: - max_length = len(str(cell.value)) - except: - pass - adjusted_width = min(max_length + 2, 50) - worksheet.column_dimensions[column_letter].width = adjusted_width - - output.seek(0) - - return send_file( - output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=f'{filename}.xlsx' - ) - -@app.route("/export/sessions") -@login_required -def export_sessions(): - conn = get_connection() - cur = conn.cursor() - - # Holen des Session-Typs (active oder ended) - session_type = request.args.get('type', 'active') - export_format = request.args.get('format', 'excel') - - # Daten je nach Typ abrufen - if session_type == 'active': - # Aktive Lizenz-Sessions - cur.execute(""" - SELECT s.id, l.license_key, c.name as customer_name, s.session_id, - s.started_at, s.last_heartbeat, - EXTRACT(EPOCH FROM (NOW() - s.started_at))::INT as duration_seconds, - s.ip_address, s.user_agent - FROM sessions s - JOIN licenses l ON s.license_id = l.id - JOIN customers c ON l.customer_id = c.id - WHERE s.is_active = true - ORDER BY s.last_heartbeat DESC - """) - sessions = cur.fetchall() - - # Daten für Export vorbereiten - data = [] - for sess in sessions: - duration = sess[6] - hours = duration // 3600 - minutes = (duration % 3600) // 60 - seconds = duration % 60 - - data.append({ - 'Session-ID': sess[0], - 'Lizenzschlüssel': sess[1], - 'Kunde': sess[2], - 'Session-ID (Tech)': sess[3], - 'Startzeit': sess[4].strftime('%d.%m.%Y %H:%M:%S'), - 'Letzte Aktivität': sess[5].strftime('%d.%m.%Y %H:%M:%S'), - 'Dauer': f"{hours}h {minutes}m {seconds}s", - 'IP-Adresse': sess[7], - 'Browser': sess[8] - }) - - sheet_name = 'Aktive Sessions' - filename_prefix = 'aktive_sessions' - else: - # Beendete Lizenz-Sessions - cur.execute(""" - SELECT s.id, l.license_key, c.name as customer_name, s.session_id, - s.started_at, s.ended_at, - EXTRACT(EPOCH FROM (s.ended_at - s.started_at))::INT as duration_seconds, - s.ip_address, s.user_agent - FROM sessions s - JOIN licenses l ON s.license_id = l.id - JOIN customers c ON l.customer_id = c.id - WHERE s.is_active = false AND s.ended_at IS NOT NULL - ORDER BY s.ended_at DESC - LIMIT 1000 - """) - sessions = cur.fetchall() - - # Daten für Export vorbereiten - data = [] - for sess in sessions: - duration = sess[6] if sess[6] else 0 - hours = duration // 3600 - minutes = (duration % 3600) // 60 - seconds = duration % 60 - - data.append({ - 'Session-ID': sess[0], - 'Lizenzschlüssel': sess[1], - 'Kunde': sess[2], - 'Session-ID (Tech)': sess[3], - 'Startzeit': sess[4].strftime('%d.%m.%Y %H:%M:%S'), - 'Endzeit': sess[5].strftime('%d.%m.%Y %H:%M:%S') if sess[5] else '', - 'Dauer': f"{hours}h {minutes}m {seconds}s", - 'IP-Adresse': sess[7], - 'Browser': sess[8] - }) - - sheet_name = 'Beendete Sessions' - filename_prefix = 'beendete_sessions' - - cur.close() - conn.close() - - # DataFrame erstellen - df = pd.DataFrame(data) - - # Timestamp für Dateiname - timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') - filename = f'{filename_prefix}_export_{timestamp}' - - # Audit Log für Export - log_audit('EXPORT', 'sessions', - additional_info=f"{export_format.upper()} Export von {session_type} Sessions mit {len(data)} Einträgen") - - if export_format == 'csv': - # CSV Export - output = io.StringIO() - # UTF-8 BOM für Excel - output.write('\ufeff') - df.to_csv(output, index=False, sep=';', encoding='utf-8') - output.seek(0) - - return send_file( - io.BytesIO(output.getvalue().encode('utf-8')), - mimetype='text/csv;charset=utf-8', - as_attachment=True, - download_name=f'{filename}.csv' - ) - else: - # Excel Export - output = BytesIO() - with pd.ExcelWriter(output, engine='openpyxl') as writer: - df.to_excel(writer, index=False, sheet_name=sheet_name) - - # Spaltenbreiten anpassen - worksheet = writer.sheets[sheet_name] - for idx, col in enumerate(df.columns): - max_length = max( - df[col].astype(str).map(len).max(), - len(col) - ) + 2 - worksheet.column_dimensions[get_column_letter(idx + 1)].width = min(max_length, 50) - - output.seek(0) - - return send_file( - output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=f'{filename}.xlsx' - ) - -@app.route("/export/resources") -@login_required -def export_resources(): - conn = get_connection() - cur = conn.cursor() - - # Holen der Filter-Parameter - filter_type = request.args.get('type', '') - filter_status = request.args.get('status', '') - search_query = request.args.get('search', '') - show_test = request.args.get('show_test', 'false').lower() == 'true' - export_format = request.args.get('format', 'excel') - - # SQL Query mit Filtern - query = """ - SELECT r.id, r.resource_type, r.resource_value, r.status, r.allocated_to_license, - r.created_at, r.status_changed_at, - l.license_key, c.name as customer_name, c.email as customer_email, - l.license_type - FROM resource_pools r - LEFT JOIN licenses l ON r.allocated_to_license = l.id - LEFT JOIN customers c ON l.customer_id = c.id - WHERE 1=1 - """ - params = [] - - # Filter für Testdaten - if not show_test: - query += " AND (r.is_test = false OR r.is_test IS NULL)" - - # Filter für Ressourcentyp - if filter_type: - query += " AND r.resource_type = %s" - params.append(filter_type) - - # Filter für Status - if filter_status: - query += " AND r.status = %s" - params.append(filter_status) - - # Suchfilter - if search_query: - query += " AND (r.resource_value ILIKE %s OR l.license_key ILIKE %s OR c.name ILIKE %s)" - params.extend([f'%{search_query}%', f'%{search_query}%', f'%{search_query}%']) - - query += " ORDER BY r.id DESC" - - cur.execute(query, params) - resources = cur.fetchall() - cur.close() - conn.close() - - # Daten für Export vorbereiten - data = [] - for res in resources: - status_text = { - 'available': 'Verfügbar', - 'allocated': 'Zugewiesen', - 'quarantine': 'Quarantäne' - }.get(res[3], res[3]) - - type_text = { - 'domain': 'Domain', - 'ipv4': 'IPv4', - 'phone': 'Telefon' - }.get(res[1], res[1]) - - data.append({ - 'ID': res[0], - 'Typ': type_text, - 'Ressource': res[2], - 'Status': status_text, - 'Lizenzschlüssel': res[7] or '', - 'Kunde': res[8] or '', - 'Kunden-Email': res[9] or '', - 'Lizenztyp': res[10] or '', - 'Erstellt am': res[5].strftime('%d.%m.%Y %H:%M:%S') if res[5] else '', - 'Zugewiesen am': res[6].strftime('%d.%m.%Y %H:%M:%S') if res[6] else '' - }) - - # DataFrame erstellen - df = pd.DataFrame(data) - - # Timestamp für Dateiname - timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') - filename = f'resources_export_{timestamp}' - - # Audit Log für Export - log_audit('EXPORT', 'resources', - additional_info=f"{export_format.upper()} Export mit {len(data)} Ressourcen") - - if export_format == 'csv': - # CSV Export - output = io.StringIO() - # UTF-8 BOM für Excel - output.write('\ufeff') - df.to_csv(output, index=False, sep=';', encoding='utf-8') - output.seek(0) - - return send_file( - io.BytesIO(output.getvalue().encode('utf-8')), - mimetype='text/csv;charset=utf-8', - as_attachment=True, - download_name=f'{filename}.csv' - ) - else: - # Excel Export - output = BytesIO() - with pd.ExcelWriter(output, engine='openpyxl') as writer: - df.to_excel(writer, index=False, sheet_name='Resources') - - # Spaltenbreiten anpassen - worksheet = writer.sheets['Resources'] - for idx, col in enumerate(df.columns): - max_length = max( - df[col].astype(str).map(len).max(), - len(col) - ) + 2 - worksheet.column_dimensions[get_column_letter(idx + 1)].width = min(max_length, 50) - - output.seek(0) - - return send_file( - output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=f'{filename}.xlsx' - ) - -@app.route("/audit") -@login_required -def audit_log(): - conn = get_connection() - cur = conn.cursor() - - # Parameter - filter_user = request.args.get('user', '').strip() - filter_action = request.args.get('action', '').strip() - filter_entity = request.args.get('entity', '').strip() - page = request.args.get('page', 1, type=int) - sort = request.args.get('sort', 'timestamp') - order = request.args.get('order', 'desc') - per_page = 50 - - # Whitelist für erlaubte Sortierfelder - allowed_sort_fields = { - 'timestamp': 'timestamp', - 'username': 'username', - 'action': 'action', - 'entity': 'entity_type', - 'ip': 'ip_address' - } - - # Validierung - if sort not in allowed_sort_fields: - sort = 'timestamp' - if order not in ['asc', 'desc']: - order = 'desc' - - sort_field = allowed_sort_fields[sort] - - # SQL Query mit optionalen Filtern - query = """ - SELECT id, timestamp, username, action, entity_type, entity_id, - old_values, new_values, ip_address, user_agent, additional_info - FROM audit_log - WHERE 1=1 - """ - - params = [] - - # Filter - if filter_user: - query += " AND LOWER(username) LIKE LOWER(%s)" - params.append(f'%{filter_user}%') - - if filter_action: - query += " AND action = %s" - params.append(filter_action) - - if filter_entity: - query += " AND entity_type = %s" - params.append(filter_entity) - - # Gesamtanzahl für Pagination - count_query = "SELECT COUNT(*) FROM (" + query + ") as count_table" - cur.execute(count_query, params) - total = cur.fetchone()[0] - - # Pagination - offset = (page - 1) * per_page - query += f" ORDER BY {sort_field} {order.upper()} LIMIT %s OFFSET %s" - params.extend([per_page, offset]) - - cur.execute(query, params) - logs = cur.fetchall() - - # JSON-Werte parsen - parsed_logs = [] - for log in logs: - parsed_log = list(log) - # old_values und new_values sind bereits Dictionaries (JSONB) - # Keine Konvertierung nötig - parsed_logs.append(parsed_log) - - # Pagination Info - total_pages = (total + per_page - 1) // per_page - - cur.close() - conn.close() - - return render_template("audit_log.html", - logs=parsed_logs, - filter_user=filter_user, - filter_action=filter_action, - filter_entity=filter_entity, - page=page, - total_pages=total_pages, - total=total, - sort=sort, - order=order, - username=session.get('username')) - -@app.route("/backups") -@login_required -def backups(): - """Zeigt die Backup-Historie an""" - conn = get_connection() - cur = conn.cursor() - - # Letztes erfolgreiches Backup für Dashboard - cur.execute(""" - SELECT created_at, filesize, duration_seconds - FROM backup_history - WHERE status = 'success' - ORDER BY created_at DESC - LIMIT 1 - """) - last_backup = cur.fetchone() - - # Alle Backups abrufen - cur.execute(""" - SELECT id, filename, filesize, backup_type, status, error_message, - created_at, created_by, tables_count, records_count, - duration_seconds, is_encrypted - FROM backup_history - ORDER BY created_at DESC - """) - backups = cur.fetchall() - - cur.close() - conn.close() - - return render_template("backups.html", - backups=backups, - last_backup=last_backup, - username=session.get('username')) - -@app.route("/backup/create", methods=["POST"]) -@login_required -def create_backup_route(): - """Erstellt ein manuelles Backup""" - username = session.get('username') - success, result = create_backup(backup_type="manual", created_by=username) - - if success: - return jsonify({ - 'success': True, - 'message': f'Backup erfolgreich erstellt: {result}' - }) - else: - return jsonify({ - 'success': False, - 'message': f'Backup fehlgeschlagen: {result}' - }), 500 - -@app.route("/backup/restore/", methods=["POST"]) -@login_required -def restore_backup_route(backup_id): - """Stellt ein Backup wieder her""" - encryption_key = request.form.get('encryption_key') - - success, message = restore_backup(backup_id, encryption_key) - - if success: - return jsonify({ - 'success': True, - 'message': message - }) - else: - return jsonify({ - 'success': False, - 'message': message - }), 500 - -@app.route("/backup/download/") -@login_required -def download_backup(backup_id): - """Lädt eine Backup-Datei herunter""" - conn = get_connection() - cur = conn.cursor() - - cur.execute(""" - SELECT filename, filepath - FROM backup_history - WHERE id = %s - """, (backup_id,)) - backup_info = cur.fetchone() - - cur.close() - conn.close() - - if not backup_info: - return "Backup nicht gefunden", 404 - - filename, filepath = backup_info - filepath = Path(filepath) - - if not filepath.exists(): - return "Backup-Datei nicht gefunden", 404 - - # Audit-Log - log_audit('DOWNLOAD', 'backup', backup_id, - additional_info=f"Backup heruntergeladen: {filename}") - - return send_file(filepath, as_attachment=True, download_name=filename) - -@app.route("/backup/delete/", methods=["DELETE"]) -@login_required -def delete_backup(backup_id): - """Löscht ein Backup""" - conn = get_connection() - cur = conn.cursor() - - try: - # Backup-Informationen abrufen - cur.execute(""" - SELECT filename, filepath - FROM backup_history - WHERE id = %s - """, (backup_id,)) - backup_info = cur.fetchone() - - if not backup_info: - return jsonify({ - 'success': False, - 'message': 'Backup nicht gefunden' - }), 404 - - filename, filepath = backup_info - filepath = Path(filepath) - - # Datei löschen, wenn sie existiert - if filepath.exists(): - filepath.unlink() - - # Aus Datenbank löschen - cur.execute(""" - DELETE FROM backup_history - WHERE id = %s - """, (backup_id,)) - - conn.commit() - - # Audit-Log - log_audit('DELETE', 'backup', backup_id, - additional_info=f"Backup gelöscht: {filename}") - - return jsonify({ - 'success': True, - 'message': f'Backup "{filename}" wurde erfolgreich gelöscht' - }) - - except Exception as e: - conn.rollback() - return jsonify({ - 'success': False, - 'message': f'Fehler beim Löschen des Backups: {str(e)}' - }), 500 - finally: - cur.close() - conn.close() - -@app.route("/security/blocked-ips") -@login_required -def blocked_ips(): - """Zeigt alle gesperrten IPs an""" - conn = get_connection() - cur = conn.cursor() - - cur.execute(""" - SELECT - ip_address, - attempt_count, - first_attempt, - last_attempt, - blocked_until, - last_username_tried, - last_error_message - FROM login_attempts - WHERE blocked_until IS NOT NULL - ORDER BY blocked_until DESC - """) - - blocked_ips_list = [] - for ip in cur.fetchall(): - blocked_ips_list.append({ - 'ip_address': ip[0], - 'attempt_count': ip[1], - 'first_attempt': ip[2].strftime('%d.%m.%Y %H:%M'), - 'last_attempt': ip[3].strftime('%d.%m.%Y %H:%M'), - 'blocked_until': ip[4].strftime('%d.%m.%Y %H:%M'), - 'is_active': ip[4] > datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None), - 'last_username': ip[5], - 'last_error': ip[6] - }) - - cur.close() - conn.close() - - return render_template("blocked_ips.html", - blocked_ips=blocked_ips_list, - username=session.get('username')) - -@app.route("/security/unblock-ip", methods=["POST"]) -@login_required -def unblock_ip(): - """Entsperrt eine IP-Adresse""" - ip_address = request.form.get('ip_address') - - if ip_address: - conn = get_connection() - cur = conn.cursor() - - cur.execute(""" - UPDATE login_attempts - SET blocked_until = NULL - WHERE ip_address = %s - """, (ip_address,)) - - conn.commit() - cur.close() - conn.close() - - # Audit-Log - log_audit('UNBLOCK_IP', 'security', - additional_info=f"IP {ip_address} manuell entsperrt") - - return redirect(url_for('blocked_ips')) - -@app.route("/security/clear-attempts", methods=["POST"]) -@login_required -def clear_attempts(): - """Löscht alle Login-Versuche für eine IP""" - ip_address = request.form.get('ip_address') - - if ip_address: - reset_login_attempts(ip_address) - - # Audit-Log - log_audit('CLEAR_ATTEMPTS', 'security', - additional_info=f"Login-Versuche für IP {ip_address} zurückgesetzt") - - return redirect(url_for('blocked_ips')) - -# API Endpoints for License Management -@app.route("/api/license//toggle", methods=["POST"]) -@login_required -def toggle_license_api(license_id): - """Toggle license active status via API""" - try: - data = request.get_json() - is_active = data.get('is_active', False) - - conn = get_connection() - cur = conn.cursor() - - # Update license status - cur.execute(""" - UPDATE licenses - SET is_active = %s - WHERE id = %s - """, (is_active, license_id)) - - conn.commit() - - # Log the action - log_audit('UPDATE', 'license', license_id, - new_values={'is_active': is_active}, - additional_info=f"Lizenz {'aktiviert' if is_active else 'deaktiviert'} via Toggle") - - cur.close() - conn.close() - - return jsonify({'success': True, 'message': 'Status erfolgreich geändert'}) - except Exception as e: - return jsonify({'success': False, 'message': str(e)}), 500 - -@app.route("/api/licenses/bulk-activate", methods=["POST"]) -@login_required -def bulk_activate_licenses(): - """Activate multiple licenses at once""" - try: - data = request.get_json() - license_ids = data.get('ids', []) - - if not license_ids: - return jsonify({'success': False, 'message': 'Keine Lizenzen ausgewählt'}), 400 - - conn = get_connection() - cur = conn.cursor() - - # Update all selected licenses (nur Live-Daten) - cur.execute(""" - UPDATE licenses - SET is_active = TRUE - WHERE id = ANY(%s) AND is_test = FALSE - """, (license_ids,)) - - affected_rows = cur.rowcount - conn.commit() - - # Log the bulk action - log_audit('BULK_UPDATE', 'licenses', None, - new_values={'is_active': True, 'count': affected_rows}, - additional_info=f"{affected_rows} Lizenzen aktiviert") - - cur.close() - conn.close() - - return jsonify({'success': True, 'message': f'{affected_rows} Lizenzen aktiviert'}) - except Exception as e: - return jsonify({'success': False, 'message': str(e)}), 500 - -@app.route("/api/licenses/bulk-deactivate", methods=["POST"]) -@login_required -def bulk_deactivate_licenses(): - """Deactivate multiple licenses at once""" - try: - data = request.get_json() - license_ids = data.get('ids', []) - - if not license_ids: - return jsonify({'success': False, 'message': 'Keine Lizenzen ausgewählt'}), 400 - - conn = get_connection() - cur = conn.cursor() - - # Update all selected licenses (nur Live-Daten) - cur.execute(""" - UPDATE licenses - SET is_active = FALSE - WHERE id = ANY(%s) AND is_test = FALSE - """, (license_ids,)) - - affected_rows = cur.rowcount - conn.commit() - - # Log the bulk action - log_audit('BULK_UPDATE', 'licenses', None, - new_values={'is_active': False, 'count': affected_rows}, - additional_info=f"{affected_rows} Lizenzen deaktiviert") - - cur.close() - conn.close() - - return jsonify({'success': True, 'message': f'{affected_rows} Lizenzen deaktiviert'}) - except Exception as e: - return jsonify({'success': False, 'message': str(e)}), 500 - -@app.route("/api/license//devices") -@login_required -def get_license_devices(license_id): - """Hole alle registrierten Geräte einer Lizenz""" - try: - conn = get_connection() - cur = conn.cursor() - - # Prüfe ob Lizenz existiert und hole device_limit - cur.execute(""" - SELECT device_limit FROM licenses WHERE id = %s - """, (license_id,)) - license_data = cur.fetchone() - - if not license_data: - return jsonify({'success': False, 'message': 'Lizenz nicht gefunden'}), 404 - - device_limit = license_data[0] - - # Hole alle Geräte für diese Lizenz - cur.execute(""" - SELECT id, hardware_id, device_name, operating_system, - first_seen, last_seen, is_active, ip_address - FROM device_registrations - WHERE license_id = %s - ORDER BY is_active DESC, last_seen DESC - """, (license_id,)) - - devices = [] - for row in cur.fetchall(): - devices.append({ - 'id': row[0], - 'hardware_id': row[1], - 'device_name': row[2] or 'Unbekanntes Gerät', - 'operating_system': row[3] or 'Unbekannt', - 'first_seen': row[4].strftime('%d.%m.%Y %H:%M') if row[4] else '', - 'last_seen': row[5].strftime('%d.%m.%Y %H:%M') if row[5] else '', - 'is_active': row[6], - 'ip_address': row[7] or '-' - }) - - cur.close() - conn.close() - - return jsonify({ - 'success': True, - 'devices': devices, - 'device_limit': device_limit, - 'active_count': sum(1 for d in devices if d['is_active']) - }) - - except Exception as e: - logging.error(f"Fehler beim Abrufen der Geräte: {str(e)}") - return jsonify({'success': False, 'message': 'Fehler beim Abrufen der Geräte'}), 500 - -@app.route("/api/license//register-device", methods=["POST"]) -def register_device(license_id): - """Registriere ein neues Gerät für eine Lizenz""" - try: - data = request.get_json() - hardware_id = data.get('hardware_id') - device_name = data.get('device_name', '') - operating_system = data.get('operating_system', '') - - if not hardware_id: - return jsonify({'success': False, 'message': 'Hardware-ID fehlt'}), 400 - - conn = get_connection() - cur = conn.cursor() - - # Prüfe ob Lizenz existiert und aktiv ist - cur.execute(""" - SELECT device_limit, is_active, valid_until - FROM licenses - WHERE id = %s - """, (license_id,)) - license_data = cur.fetchone() - - if not license_data: - return jsonify({'success': False, 'message': 'Lizenz nicht gefunden'}), 404 - - device_limit, is_active, valid_until = license_data - - # Prüfe ob Lizenz aktiv und gültig ist - if not is_active: - return jsonify({'success': False, 'message': 'Lizenz ist deaktiviert'}), 403 - - if valid_until < datetime.now(ZoneInfo("Europe/Berlin")).date(): - return jsonify({'success': False, 'message': 'Lizenz ist abgelaufen'}), 403 - - # Prüfe ob Gerät bereits registriert ist - cur.execute(""" - SELECT id, is_active FROM device_registrations - WHERE license_id = %s AND hardware_id = %s - """, (license_id, hardware_id)) - existing_device = cur.fetchone() - - if existing_device: - device_id, is_device_active = existing_device - if is_device_active: - # Gerät ist bereits aktiv, update last_seen - cur.execute(""" - UPDATE device_registrations - SET last_seen = CURRENT_TIMESTAMP, - ip_address = %s, - user_agent = %s - WHERE id = %s - """, (get_client_ip(), request.headers.get('User-Agent', ''), device_id)) - conn.commit() - return jsonify({'success': True, 'message': 'Gerät bereits registriert', 'device_id': device_id}) - else: - # Gerät war deaktiviert, prüfe ob wir es reaktivieren können - cur.execute(""" - SELECT COUNT(*) FROM device_registrations - WHERE license_id = %s AND is_active = TRUE - """, (license_id,)) - active_count = cur.fetchone()[0] - - if active_count >= device_limit: - return jsonify({'success': False, 'message': f'Gerätelimit erreicht ({device_limit} Geräte)'}), 403 - - # Reaktiviere das Gerät - cur.execute(""" - UPDATE device_registrations - SET is_active = TRUE, - last_seen = CURRENT_TIMESTAMP, - deactivated_at = NULL, - deactivated_by = NULL, - ip_address = %s, - user_agent = %s - WHERE id = %s - """, (get_client_ip(), request.headers.get('User-Agent', ''), device_id)) - conn.commit() - return jsonify({'success': True, 'message': 'Gerät reaktiviert', 'device_id': device_id}) - - # Neues Gerät - prüfe Gerätelimit - cur.execute(""" - SELECT COUNT(*) FROM device_registrations - WHERE license_id = %s AND is_active = TRUE - """, (license_id,)) - active_count = cur.fetchone()[0] - - if active_count >= device_limit: - return jsonify({'success': False, 'message': f'Gerätelimit erreicht ({device_limit} Geräte)'}), 403 - - # Registriere neues Gerät - cur.execute(""" - INSERT INTO device_registrations - (license_id, hardware_id, device_name, operating_system, ip_address, user_agent) - VALUES (%s, %s, %s, %s, %s, %s) - RETURNING id - """, (license_id, hardware_id, device_name, operating_system, - get_client_ip(), request.headers.get('User-Agent', ''))) - device_id = cur.fetchone()[0] - - conn.commit() - - # Audit Log - log_audit('DEVICE_REGISTER', 'device', device_id, - new_values={'license_id': license_id, 'hardware_id': hardware_id}) - - cur.close() - conn.close() - - return jsonify({'success': True, 'message': 'Gerät erfolgreich registriert', 'device_id': device_id}) - - except Exception as e: - logging.error(f"Fehler bei Geräte-Registrierung: {str(e)}") - return jsonify({'success': False, 'message': 'Fehler bei der Registrierung'}), 500 - -@app.route("/api/license//deactivate-device/", methods=["POST"]) -@login_required -def deactivate_device(license_id, device_id): - """Deaktiviere ein registriertes Gerät""" - try: - conn = get_connection() - cur = conn.cursor() - - # Prüfe ob das Gerät zu dieser Lizenz gehört - cur.execute(""" - SELECT id FROM device_registrations - WHERE id = %s AND license_id = %s AND is_active = TRUE - """, (device_id, license_id)) - - if not cur.fetchone(): - return jsonify({'success': False, 'message': 'Gerät nicht gefunden oder bereits deaktiviert'}), 404 - - # Deaktiviere das Gerät - cur.execute(""" - UPDATE device_registrations - SET is_active = FALSE, - deactivated_at = CURRENT_TIMESTAMP, - deactivated_by = %s - WHERE id = %s - """, (session['username'], device_id)) - - conn.commit() - - # Audit Log - log_audit('DEVICE_DEACTIVATE', 'device', device_id, - old_values={'is_active': True}, - new_values={'is_active': False}) - - cur.close() - conn.close() - - return jsonify({'success': True, 'message': 'Gerät erfolgreich deaktiviert'}) - - except Exception as e: - logging.error(f"Fehler beim Deaktivieren des Geräts: {str(e)}") - return jsonify({'success': False, 'message': 'Fehler beim Deaktivieren'}), 500 - -@app.route("/api/licenses/bulk-delete", methods=["POST"]) -@login_required -def bulk_delete_licenses(): - """Delete multiple licenses at once""" - try: - data = request.get_json() - license_ids = data.get('ids', []) - - if not license_ids: - return jsonify({'success': False, 'message': 'Keine Lizenzen ausgewählt'}), 400 - - conn = get_connection() - cur = conn.cursor() - - # Get license info for audit log (nur Live-Daten) - cur.execute(""" - SELECT license_key - FROM licenses - WHERE id = ANY(%s) AND is_test = FALSE - """, (license_ids,)) - license_keys = [row[0] for row in cur.fetchall()] - - # Delete all selected licenses (nur Live-Daten) - cur.execute(""" - DELETE FROM licenses - WHERE id = ANY(%s) AND is_test = FALSE - """, (license_ids,)) - - affected_rows = cur.rowcount - conn.commit() - - # Log the bulk action - log_audit('BULK_DELETE', 'licenses', None, - old_values={'license_keys': license_keys, 'count': affected_rows}, - additional_info=f"{affected_rows} Lizenzen gelöscht") - - cur.close() - conn.close() - - return jsonify({'success': True, 'message': f'{affected_rows} Lizenzen gelöscht'}) - except Exception as e: - return jsonify({'success': False, 'message': str(e)}), 500 - -# ===================== RESOURCE POOL MANAGEMENT ===================== - -@app.route('/resources') -@login_required -def resources(): - """Resource Pool Hauptübersicht""" - conn = get_connection() - cur = conn.cursor() - - # Prüfe ob Testdaten angezeigt werden sollen (gleiche Logik wie bei Kunden) - show_test = request.args.get('show_test', 'false').lower() == 'true' - - # Statistiken abrufen - cur.execute(""" - SELECT - resource_type, - COUNT(*) FILTER (WHERE status = 'available') as available, - COUNT(*) FILTER (WHERE status = 'allocated') as allocated, - COUNT(*) FILTER (WHERE status = 'quarantine') as quarantine, - COUNT(*) as total - FROM resource_pools - WHERE is_test = %s - GROUP BY resource_type - """, (show_test,)) - - stats = {} - for row in cur.fetchall(): - stats[row[0]] = { - 'available': row[1], - 'allocated': row[2], - 'quarantine': row[3], - 'total': row[4], - 'available_percent': round((row[1] / row[4] * 100) if row[4] > 0 else 0, 1) - } - - # Letzte Aktivitäten (gefiltert nach Test/Live) - cur.execute(""" - SELECT - rh.action, - rh.action_by, - rh.action_at, - rp.resource_type, - rp.resource_value, - rh.details - FROM resource_history rh - JOIN resource_pools rp ON rh.resource_id = rp.id - WHERE rp.is_test = %s - ORDER BY rh.action_at DESC - LIMIT 10 - """, (show_test,)) - recent_activities = cur.fetchall() - - # Ressourcen-Liste mit Pagination - page = request.args.get('page', 1, type=int) - per_page = 50 - offset = (page - 1) * per_page - - resource_type = request.args.get('type', '') - status_filter = request.args.get('status', '') - search = request.args.get('search', '') - - # Sortierung - sort_by = request.args.get('sort', 'id') - sort_order = request.args.get('order', 'desc') - - # Base Query - query = """ - SELECT - rp.id, - rp.resource_type, - rp.resource_value, - rp.status, - rp.allocated_to_license, - l.license_key, - c.name as customer_name, - rp.status_changed_at, - rp.quarantine_reason, - rp.quarantine_until, - c.id as customer_id - FROM resource_pools rp - LEFT JOIN licenses l ON rp.allocated_to_license = l.id - LEFT JOIN customers c ON l.customer_id = c.id - WHERE rp.is_test = %s - """ - params = [show_test] - - if resource_type: - query += " AND rp.resource_type = %s" - params.append(resource_type) - - if status_filter: - query += " AND rp.status = %s" - params.append(status_filter) - - if search: - query += " AND rp.resource_value ILIKE %s" - params.append(f'%{search}%') - - # Count total - count_query = f"SELECT COUNT(*) FROM ({query}) as cnt" - cur.execute(count_query, params) - total = cur.fetchone()[0] - total_pages = (total + per_page - 1) // per_page - - # Get paginated results with dynamic sorting - sort_column_map = { - 'id': 'rp.id', - 'type': 'rp.resource_type', - 'resource': 'rp.resource_value', - 'status': 'rp.status', - 'assigned': 'c.name', - 'changed': 'rp.status_changed_at' - } - - sort_column = sort_column_map.get(sort_by, 'rp.id') - sort_direction = 'ASC' if sort_order == 'asc' else 'DESC' - - query += f" ORDER BY {sort_column} {sort_direction} LIMIT %s OFFSET %s" - params.extend([per_page, offset]) - - cur.execute(query, params) - resources = cur.fetchall() - - cur.close() - conn.close() - - return render_template('resources.html', - stats=stats, - resources=resources, - recent_activities=recent_activities, - page=page, - total_pages=total_pages, - total=total, - resource_type=resource_type, - status_filter=status_filter, - search=search, - show_test=show_test, - sort_by=sort_by, - sort_order=sort_order, - datetime=datetime, - timedelta=timedelta) - -@app.route('/resources/add', methods=['GET', 'POST']) -@login_required -def add_resources(): - """Ressourcen zum Pool hinzufügen""" - # Hole show_test Parameter für die Anzeige - show_test = request.args.get('show_test', 'false').lower() == 'true' - - if request.method == 'POST': - resource_type = request.form.get('resource_type') - resources_text = request.form.get('resources_text', '') - is_test = request.form.get('is_test') == 'on' # Checkbox für Testdaten - - # Parse resources (one per line) - resources = [r.strip() for r in resources_text.split('\n') if r.strip()] - - if not resources: - flash('Keine Ressourcen angegeben', 'error') - return redirect(url_for('add_resources', show_test=show_test)) - - conn = get_connection() - cur = conn.cursor() - - added = 0 - duplicates = 0 - - for resource_value in resources: - try: - cur.execute(""" - INSERT INTO resource_pools (resource_type, resource_value, status_changed_by, is_test) - VALUES (%s, %s, %s, %s) - ON CONFLICT (resource_type, resource_value) DO NOTHING - """, (resource_type, resource_value, session['username'], is_test)) - - if cur.rowcount > 0: - added += 1 - # Get the inserted ID - cur.execute("SELECT id FROM resource_pools WHERE resource_type = %s AND resource_value = %s", - (resource_type, resource_value)) - resource_id = cur.fetchone()[0] - - # Log in history - cur.execute(""" - INSERT INTO resource_history (resource_id, action, action_by, ip_address) - VALUES (%s, 'created', %s, %s) - """, (resource_id, session['username'], get_client_ip())) - else: - duplicates += 1 - - except Exception as e: - app.logger.error(f"Error adding resource {resource_value}: {e}") - - conn.commit() - cur.close() - conn.close() - - log_audit('CREATE', 'resource_pool', None, - new_values={'type': resource_type, 'added': added, 'duplicates': duplicates, 'is_test': is_test}, - additional_info=f"{added} {'Test-' if is_test else ''}Ressourcen hinzugefügt, {duplicates} Duplikate übersprungen") - - flash(f'{added} {"Test-" if is_test else ""}Ressourcen hinzugefügt, {duplicates} Duplikate übersprungen', 'success') - return redirect(url_for('resources', show_test=show_test)) - - return render_template('add_resources.html', show_test=show_test) - -@app.route('/resources/quarantine/', methods=['POST']) -@login_required -def quarantine_resource(resource_id): - """Ressource in Quarantäne setzen""" - reason = request.form.get('reason', 'review') - until_date = request.form.get('until_date') - notes = request.form.get('notes', '') - - conn = get_connection() - cur = conn.cursor() - - # Get current resource info - cur.execute("SELECT resource_type, resource_value, status FROM resource_pools WHERE id = %s", (resource_id,)) - resource = cur.fetchone() - - if not resource: - flash('Ressource nicht gefunden', 'error') - return redirect(url_for('resources')) - - old_status = resource[2] - - # Update resource - cur.execute(""" - UPDATE resource_pools - SET status = 'quarantine', - quarantine_reason = %s, - quarantine_until = %s, - notes = %s, - status_changed_at = CURRENT_TIMESTAMP, - status_changed_by = %s - WHERE id = %s - """, (reason, until_date if until_date else None, notes, session['username'], resource_id)) - - # Log in history - cur.execute(""" - INSERT INTO resource_history (resource_id, action, action_by, ip_address, details) - VALUES (%s, 'quarantined', %s, %s, %s) - """, (resource_id, session['username'], get_client_ip(), - Json({'reason': reason, 'until': until_date, 'notes': notes, 'old_status': old_status}))) - - conn.commit() - cur.close() - conn.close() - - log_audit('UPDATE', 'resource', resource_id, - old_values={'status': old_status}, - new_values={'status': 'quarantine', 'reason': reason}, - additional_info=f"Ressource {resource[0]}: {resource[1]} in Quarantäne") - - flash('Ressource in Quarantäne gesetzt', 'success') - - # Redirect mit allen aktuellen Filtern - return redirect(url_for('resources', - show_test=request.args.get('show_test', request.form.get('show_test', 'false')), - type=request.args.get('type', request.form.get('type', '')), - status=request.args.get('status', request.form.get('status', '')), - search=request.args.get('search', request.form.get('search', '')))) - -@app.route('/resources/release', methods=['POST']) -@login_required -def release_resources(): - """Ressourcen aus Quarantäne freigeben""" - resource_ids = request.form.getlist('resource_ids') - - if not resource_ids: - flash('Keine Ressourcen ausgewählt', 'error') - return redirect(url_for('resources')) - - conn = get_connection() - cur = conn.cursor() - - released = 0 - for resource_id in resource_ids: - cur.execute(""" - UPDATE resource_pools - SET status = 'available', - quarantine_reason = NULL, - quarantine_until = NULL, - allocated_to_license = NULL, - status_changed_at = CURRENT_TIMESTAMP, - status_changed_by = %s - WHERE id = %s AND status = 'quarantine' - """, (session['username'], resource_id)) - - if cur.rowcount > 0: - released += 1 - # Log in history - cur.execute(""" - INSERT INTO resource_history (resource_id, action, action_by, ip_address) - VALUES (%s, 'released', %s, %s) - """, (resource_id, session['username'], get_client_ip())) - - conn.commit() - cur.close() - conn.close() - - log_audit('UPDATE', 'resource_pool', None, - new_values={'released': released}, - additional_info=f"{released} Ressourcen aus Quarantäne freigegeben") - - flash(f'{released} Ressourcen freigegeben', 'success') - - # Redirect mit allen aktuellen Filtern - return redirect(url_for('resources', - show_test=request.args.get('show_test', request.form.get('show_test', 'false')), - type=request.args.get('type', request.form.get('type', '')), - status=request.args.get('status', request.form.get('status', '')), - search=request.args.get('search', request.form.get('search', '')))) - -@app.route('/api/resources/allocate', methods=['POST']) -@login_required -def allocate_resources_api(): - """API für Ressourcen-Zuweisung bei Lizenzerstellung""" - data = request.json - license_id = data.get('license_id') - domain_count = data.get('domain_count', 1) - ipv4_count = data.get('ipv4_count', 1) - phone_count = data.get('phone_count', 1) - - conn = get_connection() - cur = conn.cursor() - - try: - allocated = {'domains': [], 'ipv4s': [], 'phones': []} - - # Allocate domains - if domain_count > 0: - cur.execute(""" - SELECT id, resource_value FROM resource_pools - WHERE resource_type = 'domain' AND status = 'available' - LIMIT %s FOR UPDATE - """, (domain_count,)) - domains = cur.fetchall() - - if len(domains) < domain_count: - raise ValueError(f"Nicht genügend Domains verfügbar (benötigt: {domain_count}, verfügbar: {len(domains)})") - - for domain_id, domain_value in domains: - # Update resource status - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', - allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, - status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], domain_id)) - - # Create assignment - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, domain_id, session['username'])) - - # Log history - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (domain_id, license_id, session['username'], get_client_ip())) - - allocated['domains'].append(domain_value) - - # Allocate IPv4s (similar logic) - if ipv4_count > 0: - cur.execute(""" - SELECT id, resource_value FROM resource_pools - WHERE resource_type = 'ipv4' AND status = 'available' - LIMIT %s FOR UPDATE - """, (ipv4_count,)) - ipv4s = cur.fetchall() - - if len(ipv4s) < ipv4_count: - raise ValueError(f"Nicht genügend IPv4-Adressen verfügbar") - - for ipv4_id, ipv4_value in ipv4s: - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', - allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, - status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], ipv4_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, ipv4_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (ipv4_id, license_id, session['username'], get_client_ip())) - - allocated['ipv4s'].append(ipv4_value) - - # Allocate phones (similar logic) - if phone_count > 0: - cur.execute(""" - SELECT id, resource_value FROM resource_pools - WHERE resource_type = 'phone' AND status = 'available' - LIMIT %s FOR UPDATE - """, (phone_count,)) - phones = cur.fetchall() - - if len(phones) < phone_count: - raise ValueError(f"Nicht genügend Telefonnummern verfügbar") - - for phone_id, phone_value in phones: - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', - allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, - status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], phone_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, phone_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (phone_id, license_id, session['username'], get_client_ip())) - - allocated['phones'].append(phone_value) - - # Update license resource counts - cur.execute(""" - UPDATE licenses - SET domain_count = %s, - ipv4_count = %s, - phone_count = %s - WHERE id = %s - """, (domain_count, ipv4_count, phone_count, license_id)) - - conn.commit() - cur.close() - conn.close() - - return jsonify({ - 'success': True, - 'allocated': allocated - }) - - except Exception as e: - conn.rollback() - cur.close() - conn.close() - return jsonify({ - 'success': False, - 'error': str(e) - }), 400 - -@app.route('/api/resources/check-availability', methods=['GET']) -@login_required -def check_resource_availability(): - """Prüft verfügbare Ressourcen""" - resource_type = request.args.get('type', '') - count = request.args.get('count', 10, type=int) - show_test = request.args.get('show_test', 'false').lower() == 'true' - - conn = get_connection() - cur = conn.cursor() - - if resource_type: - # Spezifische Ressourcen für einen Typ - cur.execute(""" - SELECT id, resource_value - FROM resource_pools - WHERE status = 'available' - AND resource_type = %s - AND is_test = %s - ORDER BY resource_value - LIMIT %s - """, (resource_type, show_test, count)) - - resources = [] - for row in cur.fetchall(): - resources.append({ - 'id': row[0], - 'value': row[1] - }) - - cur.close() - conn.close() - - return jsonify({ - 'available': resources, - 'type': resource_type, - 'count': len(resources) - }) - else: - # Zusammenfassung aller Typen - cur.execute(""" - SELECT - resource_type, - COUNT(*) as available - FROM resource_pools - WHERE status = 'available' - AND is_test = %s - GROUP BY resource_type - """, (show_test,)) - - availability = {} - for row in cur.fetchall(): - availability[row[0]] = row[1] - - cur.close() - conn.close() - - return jsonify(availability) - -@app.route('/api/global-search', methods=['GET']) -@login_required -def global_search(): - """Global search API endpoint for searching customers and licenses""" - query = request.args.get('q', '').strip() - - if not query or len(query) < 2: - return jsonify({'customers': [], 'licenses': []}) - - conn = get_connection() - cur = conn.cursor() - - # Search pattern with wildcards - search_pattern = f'%{query}%' - - # Search customers - cur.execute(""" - SELECT id, name, email, company_name - FROM customers - WHERE (LOWER(name) LIKE LOWER(%s) - OR LOWER(email) LIKE LOWER(%s) - OR LOWER(company_name) LIKE LOWER(%s)) - AND is_test = FALSE - ORDER BY name - LIMIT 5 - """, (search_pattern, search_pattern, search_pattern)) - - customers = [] - for row in cur.fetchall(): - customers.append({ - 'id': row[0], - 'name': row[1], - 'email': row[2], - 'company_name': row[3] - }) - - # Search licenses - cur.execute(""" - SELECT l.id, l.license_key, c.name as customer_name - FROM licenses l - JOIN customers c ON l.customer_id = c.id - WHERE LOWER(l.license_key) LIKE LOWER(%s) - AND l.is_test = FALSE - ORDER BY l.created_at DESC - LIMIT 5 - """, (search_pattern,)) - - licenses = [] - for row in cur.fetchall(): - licenses.append({ - 'id': row[0], - 'license_key': row[1], - 'customer_name': row[2] - }) - - cur.close() - conn.close() - - return jsonify({ - 'customers': customers, - 'licenses': licenses - }) - -@app.route('/resources/history/') -@login_required -def resource_history(resource_id): - """Zeigt die komplette Historie einer Ressource""" - conn = get_connection() - cur = conn.cursor() - - # Get complete resource info using named columns - cur.execute(""" - SELECT id, resource_type, resource_value, status, allocated_to_license, - status_changed_at, status_changed_by, quarantine_reason, - quarantine_until, created_at, notes - FROM resource_pools - WHERE id = %s - """, (resource_id,)) - row = cur.fetchone() - - if not row: - flash('Ressource nicht gefunden', 'error') - return redirect(url_for('resources')) - - # Create resource object with named attributes - resource = { - 'id': row[0], - 'resource_type': row[1], - 'resource_value': row[2], - 'status': row[3], - 'allocated_to_license': row[4], - 'status_changed_at': row[5], - 'status_changed_by': row[6], - 'quarantine_reason': row[7], - 'quarantine_until': row[8], - 'created_at': row[9], - 'notes': row[10] - } - - # Get license info if allocated - license_info = None - if resource['allocated_to_license']: - cur.execute("SELECT license_key FROM licenses WHERE id = %s", - (resource['allocated_to_license'],)) - lic = cur.fetchone() - if lic: - license_info = {'license_key': lic[0]} - - # Get history with named columns - cur.execute(""" - SELECT - rh.action, - rh.action_by, - rh.action_at, - rh.details, - rh.license_id, - rh.ip_address - FROM resource_history rh - WHERE rh.resource_id = %s - ORDER BY rh.action_at DESC - """, (resource_id,)) - - history = [] - for row in cur.fetchall(): - history.append({ - 'action': row[0], - 'action_by': row[1], - 'action_at': row[2], - 'details': row[3], - 'license_id': row[4], - 'ip_address': row[5] - }) - - cur.close() - conn.close() - - # Convert to object-like for template - class ResourceObj: - def __init__(self, data): - for key, value in data.items(): - setattr(self, key, value) - - resource_obj = ResourceObj(resource) - history_objs = [ResourceObj(h) for h in history] - - return render_template('resource_history.html', - resource=resource_obj, - license_info=license_info, - history=history_objs) - -@app.route('/resources/metrics') -@login_required -def resources_metrics(): - """Dashboard für Resource Metrics und Reports""" - conn = get_connection() - cur = conn.cursor() - - # Overall stats with fallback values - cur.execute(""" - SELECT - COUNT(DISTINCT resource_id) as total_resources, - COALESCE(AVG(performance_score), 0) as avg_performance, - COALESCE(SUM(cost), 0) as total_cost, - COALESCE(SUM(revenue), 0) as total_revenue, - COALESCE(SUM(issues_count), 0) as total_issues - FROM resource_metrics - WHERE metric_date >= CURRENT_DATE - INTERVAL '30 days' - """) - row = cur.fetchone() - - # Calculate ROI - roi = 0 - if row[2] > 0: # if total_cost > 0 - roi = row[3] / row[2] # revenue / cost - - stats = { - 'total_resources': row[0] or 0, - 'avg_performance': row[1] or 0, - 'total_cost': row[2] or 0, - 'total_revenue': row[3] or 0, - 'total_issues': row[4] or 0, - 'roi': roi - } - - # Performance by type - cur.execute(""" - SELECT - rp.resource_type, - COALESCE(AVG(rm.performance_score), 0) as avg_score, - COUNT(DISTINCT rp.id) as resource_count - FROM resource_pools rp - LEFT JOIN resource_metrics rm ON rp.id = rm.resource_id - AND rm.metric_date >= CURRENT_DATE - INTERVAL '30 days' - GROUP BY rp.resource_type - ORDER BY rp.resource_type - """) - performance_by_type = cur.fetchall() - - # Utilization data - cur.execute(""" - SELECT - resource_type, - COUNT(*) FILTER (WHERE status = 'allocated') as allocated, - COUNT(*) as total, - ROUND(COUNT(*) FILTER (WHERE status = 'allocated') * 100.0 / COUNT(*), 1) as allocated_percent - FROM resource_pools - GROUP BY resource_type - """) - utilization_rows = cur.fetchall() - utilization_data = [ - { - 'type': row[0].upper(), - 'allocated': row[1], - 'total': row[2], - 'allocated_percent': row[3] - } - for row in utilization_rows - ] - - # Top performing resources - cur.execute(""" - SELECT - rp.id, - rp.resource_type, - rp.resource_value, - COALESCE(AVG(rm.performance_score), 0) as avg_score, - COALESCE(SUM(rm.revenue), 0) as total_revenue, - COALESCE(SUM(rm.cost), 1) as total_cost, - CASE - WHEN COALESCE(SUM(rm.cost), 0) = 0 THEN 0 - ELSE COALESCE(SUM(rm.revenue), 0) / COALESCE(SUM(rm.cost), 1) - END as roi - FROM resource_pools rp - LEFT JOIN resource_metrics rm ON rp.id = rm.resource_id - AND rm.metric_date >= CURRENT_DATE - INTERVAL '30 days' - WHERE rp.status != 'quarantine' - GROUP BY rp.id, rp.resource_type, rp.resource_value - HAVING AVG(rm.performance_score) IS NOT NULL - ORDER BY avg_score DESC - LIMIT 10 - """) - top_rows = cur.fetchall() - top_performers = [ - { - 'id': row[0], - 'resource_type': row[1], - 'resource_value': row[2], - 'avg_score': row[3], - 'roi': row[6] - } - for row in top_rows - ] - - # Resources with issues - cur.execute(""" - SELECT - rp.id, - rp.resource_type, - rp.resource_value, - rp.status, - COALESCE(SUM(rm.issues_count), 0) as total_issues - FROM resource_pools rp - LEFT JOIN resource_metrics rm ON rp.id = rm.resource_id - AND rm.metric_date >= CURRENT_DATE - INTERVAL '30 days' - WHERE rm.issues_count > 0 OR rp.status = 'quarantine' - GROUP BY rp.id, rp.resource_type, rp.resource_value, rp.status - HAVING SUM(rm.issues_count) > 0 - ORDER BY total_issues DESC - LIMIT 10 - """) - problem_rows = cur.fetchall() - problem_resources = [ - { - 'id': row[0], - 'resource_type': row[1], - 'resource_value': row[2], - 'status': row[3], - 'total_issues': row[4] - } - for row in problem_rows - ] - - # Daily metrics for trend chart (last 30 days) - cur.execute(""" - SELECT - metric_date, - COALESCE(AVG(performance_score), 0) as avg_performance, - COALESCE(SUM(issues_count), 0) as total_issues - FROM resource_metrics - WHERE metric_date >= CURRENT_DATE - INTERVAL '30 days' - GROUP BY metric_date - ORDER BY metric_date - """) - daily_rows = cur.fetchall() - daily_metrics = [ - { - 'date': row[0].strftime('%d.%m'), - 'performance': float(row[1]), - 'issues': int(row[2]) - } - for row in daily_rows - ] - - cur.close() - conn.close() - - return render_template('resource_metrics.html', - stats=stats, - performance_by_type=performance_by_type, - utilization_data=utilization_data, - top_performers=top_performers, - problem_resources=problem_resources, - daily_metrics=daily_metrics) - -@app.route('/resources/report', methods=['GET']) -@login_required -def resources_report(): - """Generiert Ressourcen-Reports oder zeigt Report-Formular""" - # Prüfe ob Download angefordert wurde - if request.args.get('download') == 'true': - report_type = request.args.get('type', 'usage') - format_type = request.args.get('format', 'excel') - date_from = request.args.get('from', (datetime.now(ZoneInfo("Europe/Berlin")) - timedelta(days=30)).strftime('%Y-%m-%d')) - date_to = request.args.get('to', datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y-%m-%d')) - - conn = get_connection() - cur = conn.cursor() - - if report_type == 'usage': - # Auslastungsreport - query = """ - SELECT - rp.resource_type, - rp.resource_value, - rp.status, - COUNT(DISTINCT rh.license_id) as unique_licenses, - COUNT(rh.id) as total_allocations, - MIN(rh.action_at) as first_used, - MAX(rh.action_at) as last_used - FROM resource_pools rp - LEFT JOIN resource_history rh ON rp.id = rh.resource_id - AND rh.action = 'allocated' - AND rh.action_at BETWEEN %s AND %s - GROUP BY rp.id, rp.resource_type, rp.resource_value, rp.status - ORDER BY rp.resource_type, total_allocations DESC - """ - cur.execute(query, (date_from, date_to)) - columns = ['Typ', 'Ressource', 'Status', 'Unique Lizenzen', 'Gesamt Zuweisungen', 'Erste Nutzung', 'Letzte Nutzung'] - - elif report_type == 'performance': - # Performance-Report - query = """ - SELECT - rp.resource_type, - rp.resource_value, - AVG(rm.performance_score) as avg_performance, - SUM(rm.usage_count) as total_usage, - SUM(rm.revenue) as total_revenue, - SUM(rm.cost) as total_cost, - SUM(rm.revenue - rm.cost) as profit, - SUM(rm.issues_count) as total_issues - FROM resource_pools rp - JOIN resource_metrics rm ON rp.id = rm.resource_id - WHERE rm.metric_date BETWEEN %s AND %s - GROUP BY rp.id, rp.resource_type, rp.resource_value - ORDER BY profit DESC - """ - cur.execute(query, (date_from, date_to)) - columns = ['Typ', 'Ressource', 'Durchschn. Performance', 'Gesamt Nutzung', 'Umsatz', 'Kosten', 'Gewinn', 'Issues'] - - elif report_type == 'compliance': - # Compliance-Report - query = """ - SELECT - rh.action_at, - rh.action, - rh.action_by, - rp.resource_type, - rp.resource_value, - l.license_key, - c.name as customer_name, - rh.ip_address - FROM resource_history rh - JOIN resource_pools rp ON rh.resource_id = rp.id - LEFT JOIN licenses l ON rh.license_id = l.id - LEFT JOIN customers c ON l.customer_id = c.id - WHERE rh.action_at BETWEEN %s AND %s - ORDER BY rh.action_at DESC - """ - cur.execute(query, (date_from, date_to)) - columns = ['Zeit', 'Aktion', 'Von', 'Typ', 'Ressource', 'Lizenz', 'Kunde', 'IP-Adresse'] - - else: # inventory report - # Inventar-Report - query = """ - SELECT - resource_type, - COUNT(*) FILTER (WHERE status = 'available') as available, - COUNT(*) FILTER (WHERE status = 'allocated') as allocated, - COUNT(*) FILTER (WHERE status = 'quarantine') as quarantine, - COUNT(*) as total - FROM resource_pools - GROUP BY resource_type - ORDER BY resource_type - """ - cur.execute(query) - columns = ['Typ', 'Verfügbar', 'Zugeteilt', 'Quarantäne', 'Gesamt'] - - # Convert to DataFrame - data = cur.fetchall() - df = pd.DataFrame(data, columns=columns) - - cur.close() - conn.close() - - # Generate file - timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') - filename = f"resource_report_{report_type}_{timestamp}" - - if format_type == 'excel': - output = io.BytesIO() - with pd.ExcelWriter(output, engine='openpyxl') as writer: - df.to_excel(writer, sheet_name='Report', index=False) - - # Auto-adjust columns width - worksheet = writer.sheets['Report'] - for column in worksheet.columns: - max_length = 0 - column = [cell for cell in column] - for cell in column: - try: - if len(str(cell.value)) > max_length: - max_length = len(str(cell.value)) - except: - pass - adjusted_width = (max_length + 2) - worksheet.column_dimensions[column[0].column_letter].width = adjusted_width - - output.seek(0) - - log_audit('EXPORT', 'resource_report', None, - new_values={'type': report_type, 'format': 'excel', 'rows': len(df)}, - additional_info=f"Resource Report {report_type} exportiert") - - return send_file(output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=f'{filename}.xlsx') - - else: # CSV - output = io.StringIO() - df.to_csv(output, index=False, sep=';', encoding='utf-8-sig') - output.seek(0) - - log_audit('EXPORT', 'resource_report', None, - new_values={'type': report_type, 'format': 'csv', 'rows': len(df)}, - additional_info=f"Resource Report {report_type} exportiert") - - return send_file(io.BytesIO(output.getvalue().encode('utf-8-sig')), - mimetype='text/csv', - as_attachment=True, - download_name=f'{filename}.csv') - - # Wenn kein Download, zeige Report-Formular - return render_template('resource_report.html', - datetime=datetime, - timedelta=timedelta, - username=session.get('username')) - -if __name__ == "__main__": - app.run(host="0.0.0.0", port=5000) diff --git a/v2_adminpanel/app_before_blueprint.py b/v2_adminpanel/app_before_blueprint.py deleted file mode 100644 index f4a9bf2..0000000 --- a/v2_adminpanel/app_before_blueprint.py +++ /dev/null @@ -1,4460 +0,0 @@ -import os -import sys -import time -import json -import logging -import requests -import re -import random -import base64 -from io import BytesIO -from datetime import datetime, timedelta -from zoneinfo import ZoneInfo -from pathlib import Path - -# Add current directory to Python path to ensure modules can be imported -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) - -from flask import Flask, render_template, request, redirect, session, url_for, send_file, jsonify, flash -from flask_session import Session -from werkzeug.middleware.proxy_fix import ProxyFix -from apscheduler.schedulers.background import BackgroundScheduler -import pandas as pd -from psycopg2.extras import Json - -# Import our new modules -import config -from db import get_connection, get_db_connection, get_db_cursor, execute_query -from auth.decorators import login_required -from auth.password import hash_password, verify_password -from auth.two_factor import ( - generate_totp_secret, generate_qr_code, verify_totp, - generate_backup_codes, hash_backup_code, verify_backup_code -) -from auth.rate_limiting import ( - check_ip_blocked, record_failed_attempt, - reset_login_attempts, get_login_attempts -) -from utils.network import get_client_ip -from utils.audit import log_audit -from utils.license import generate_license_key, validate_license_key -from utils.backup import create_backup, restore_backup, get_or_create_encryption_key -from utils.export import ( - create_excel_export, format_datetime_for_export, - prepare_license_export_data, prepare_customer_export_data, - prepare_session_export_data, prepare_audit_export_data -) -from models import get_user_by_username - -app = Flask(__name__) -# Load configuration from config module -app.config['SECRET_KEY'] = config.SECRET_KEY -app.config['SESSION_TYPE'] = config.SESSION_TYPE -app.config['JSON_AS_ASCII'] = config.JSON_AS_ASCII -app.config['JSONIFY_MIMETYPE'] = config.JSONIFY_MIMETYPE -app.config['PERMANENT_SESSION_LIFETIME'] = config.PERMANENT_SESSION_LIFETIME -app.config['SESSION_COOKIE_HTTPONLY'] = config.SESSION_COOKIE_HTTPONLY -app.config['SESSION_COOKIE_SECURE'] = config.SESSION_COOKIE_SECURE -app.config['SESSION_COOKIE_SAMESITE'] = config.SESSION_COOKIE_SAMESITE -app.config['SESSION_COOKIE_NAME'] = config.SESSION_COOKIE_NAME -app.config['SESSION_REFRESH_EACH_REQUEST'] = config.SESSION_REFRESH_EACH_REQUEST -Session(app) - -# ProxyFix für korrekte IP-Adressen hinter Nginx -app.wsgi_app = ProxyFix( - app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1 -) - -# Configuration is now loaded from config module - -# Scheduler für automatische Backups -scheduler = BackgroundScheduler() -scheduler.start() - -# Logging konfigurieren -logging.basicConfig(level=logging.INFO) - -# Import and register blueprints -from routes.auth_routes import auth_bp -from routes.admin_routes import admin_bp - -app.register_blueprint(auth_bp) -app.register_blueprint(admin_bp) - - -# Scheduled Backup Job -def scheduled_backup(): - """Führt ein geplantes Backup aus""" - logging.info("Starte geplantes Backup...") - create_backup(backup_type="scheduled", created_by="scheduler") - -# Scheduler konfigurieren - täglich um 3:00 Uhr -scheduler.add_job( - scheduled_backup, - 'cron', - hour=config.SCHEDULER_CONFIG['backup_hour'], - minute=config.SCHEDULER_CONFIG['backup_minute'], - id='daily_backup', - replace_existing=True -) - - -def verify_recaptcha(response): - """Verifiziert die reCAPTCHA v2 Response mit Google""" - secret_key = config.RECAPTCHA_SECRET_KEY - - # Wenn kein Secret Key konfiguriert ist, CAPTCHA als bestanden werten (für PoC) - if not secret_key: - logging.warning("RECAPTCHA_SECRET_KEY nicht konfiguriert - CAPTCHA wird übersprungen") - return True - - # Verifizierung bei Google - try: - verify_url = 'https://www.google.com/recaptcha/api/siteverify' - data = { - 'secret': secret_key, - 'response': response - } - - # Timeout für Request setzen - r = requests.post(verify_url, data=data, timeout=5) - result = r.json() - - # Log für Debugging - if not result.get('success'): - logging.warning(f"reCAPTCHA Validierung fehlgeschlagen: {result.get('error-codes', [])}") - - return result.get('success', False) - - except requests.exceptions.RequestException as e: - logging.error(f"reCAPTCHA Verifizierung fehlgeschlagen: {str(e)}") - # Bei Netzwerkfehlern CAPTCHA als bestanden werten - return True - except Exception as e: - logging.error(f"Unerwarteter Fehler bei reCAPTCHA: {str(e)}") - return False - - -@app.route("/login", methods=["GET", "POST"]) -def login(): - # Timing-Attack Schutz - Start Zeit merken - start_time = time.time() - - # IP-Adresse ermitteln - ip_address = get_client_ip() - - # Prüfen ob IP gesperrt ist - is_blocked, blocked_until = check_ip_blocked(ip_address) - if is_blocked: - time_remaining = (blocked_until - datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None)).total_seconds() / 3600 - error_msg = f"IP GESPERRT! Noch {time_remaining:.1f} Stunden warten." - return render_template("login.html", error=error_msg, error_type="blocked") - - # Anzahl bisheriger Versuche - attempt_count = get_login_attempts(ip_address) - - if request.method == "POST": - username = request.form.get("username") - password = request.form.get("password") - captcha_response = request.form.get("g-recaptcha-response") - - # CAPTCHA-Prüfung nur wenn Keys konfiguriert sind - recaptcha_site_key = config.RECAPTCHA_SITE_KEY - if attempt_count >= config.CAPTCHA_AFTER_ATTEMPTS and recaptcha_site_key: - if not captcha_response: - # Timing-Attack Schutz - elapsed = time.time() - start_time - if elapsed < 1.0: - time.sleep(1.0 - elapsed) - return render_template("login.html", - error="CAPTCHA ERFORDERLICH!", - show_captcha=True, - error_type="captcha", - attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - attempt_count), - recaptcha_site_key=recaptcha_site_key) - - # CAPTCHA validieren - if not verify_recaptcha(captcha_response): - # Timing-Attack Schutz - elapsed = time.time() - start_time - if elapsed < 1.0: - time.sleep(1.0 - elapsed) - return render_template("login.html", - error="CAPTCHA UNGÜLTIG! Bitte erneut versuchen.", - show_captcha=True, - error_type="captcha", - attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - attempt_count), - recaptcha_site_key=recaptcha_site_key) - - # Check user in database first, fallback to env vars - user = get_user_by_username(username) - login_success = False - needs_2fa = False - - if user: - # Database user authentication - if verify_password(password, user['password_hash']): - login_success = True - needs_2fa = user['totp_enabled'] - else: - # Fallback to environment variables for backward compatibility - if username in config.ADMIN_USERS and password == config.ADMIN_USERS[username]: - login_success = True - - # Timing-Attack Schutz - Mindestens 1 Sekunde warten - elapsed = time.time() - start_time - if elapsed < 1.0: - time.sleep(1.0 - elapsed) - - if login_success: - # Erfolgreicher Login - if needs_2fa: - # Store temporary session for 2FA verification - session['temp_username'] = username - session['temp_user_id'] = user['id'] - session['awaiting_2fa'] = True - return redirect(url_for('verify_2fa')) - else: - # Complete login without 2FA - session.permanent = True # Aktiviert das Timeout - session['logged_in'] = True - session['username'] = username - session['user_id'] = user['id'] if user else None - session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() - reset_login_attempts(ip_address) - log_audit('LOGIN_SUCCESS', 'user', - additional_info=f"Erfolgreiche Anmeldung von IP: {ip_address}") - return redirect(url_for('dashboard')) - else: - # Fehlgeschlagener Login - error_message = record_failed_attempt(ip_address, username) - new_attempt_count = get_login_attempts(ip_address) - - # Prüfen ob jetzt gesperrt - is_now_blocked, _ = check_ip_blocked(ip_address) - if is_now_blocked: - log_audit('LOGIN_BLOCKED', 'security', - additional_info=f"IP {ip_address} wurde nach {config.MAX_LOGIN_ATTEMPTS} Versuchen gesperrt") - - return render_template("login.html", - error=error_message, - show_captcha=(new_attempt_count >= config.CAPTCHA_AFTER_ATTEMPTS and config.RECAPTCHA_SITE_KEY), - error_type="failed", - attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - new_attempt_count), - recaptcha_site_key=config.RECAPTCHA_SITE_KEY) - - # GET Request - return render_template("login.html", - show_captcha=(attempt_count >= config.CAPTCHA_AFTER_ATTEMPTS and config.RECAPTCHA_SITE_KEY), - attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - attempt_count), - recaptcha_site_key=config.RECAPTCHA_SITE_KEY) - -@app.route("/logout") -def logout(): - username = session.get('username', 'unknown') - log_audit('LOGOUT', 'user', additional_info=f"Abmeldung") - session.pop('logged_in', None) - session.pop('username', None) - session.pop('user_id', None) - session.pop('temp_username', None) - session.pop('temp_user_id', None) - session.pop('awaiting_2fa', None) - return redirect(url_for('login')) - -@app.route("/verify-2fa", methods=["GET", "POST"]) -def verify_2fa(): - if not session.get('awaiting_2fa'): - return redirect(url_for('login')) - - if request.method == "POST": - token = request.form.get('token', '').replace(' ', '') - username = session.get('temp_username') - user_id = session.get('temp_user_id') - - if not username or not user_id: - flash('Session expired. Please login again.', 'error') - return redirect(url_for('login')) - - user = get_user_by_username(username) - if not user: - flash('User not found.', 'error') - return redirect(url_for('login')) - - # Check if it's a backup code - if len(token) == 8 and token.isupper(): - # Try backup code - backup_codes = json.loads(user['backup_codes']) if user['backup_codes'] else [] - if verify_backup_code(token, backup_codes): - # Remove used backup code - code_hash = hash_backup_code(token) - backup_codes.remove(code_hash) - - conn = get_connection() - cur = conn.cursor() - cur.execute("UPDATE users SET backup_codes = %s WHERE id = %s", - (json.dumps(backup_codes), user_id)) - conn.commit() - cur.close() - conn.close() - - # Complete login - session.permanent = True - session['logged_in'] = True - session['username'] = username - session['user_id'] = user_id - session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() - session.pop('temp_username', None) - session.pop('temp_user_id', None) - session.pop('awaiting_2fa', None) - - flash('Login successful using backup code. Please generate new backup codes.', 'warning') - log_audit('LOGIN_2FA_BACKUP', 'user', additional_info=f"2FA login with backup code") - return redirect(url_for('dashboard')) - else: - # Try TOTP token - if verify_totp(user['totp_secret'], token): - # Complete login - session.permanent = True - session['logged_in'] = True - session['username'] = username - session['user_id'] = user_id - session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() - session.pop('temp_username', None) - session.pop('temp_user_id', None) - session.pop('awaiting_2fa', None) - - log_audit('LOGIN_2FA_SUCCESS', 'user', additional_info=f"2FA login successful") - return redirect(url_for('dashboard')) - - # Failed verification - conn = get_connection() - cur = conn.cursor() - cur.execute("UPDATE users SET failed_2fa_attempts = failed_2fa_attempts + 1, last_failed_2fa = %s WHERE id = %s", - (datetime.now(), user_id)) - conn.commit() - cur.close() - conn.close() - - flash('Invalid authentication code. Please try again.', 'error') - log_audit('LOGIN_2FA_FAILED', 'user', additional_info=f"Failed 2FA attempt") - - return render_template('verify_2fa.html') - -@app.route("/profile") -@login_required -def profile(): - user = get_user_by_username(session['username']) - if not user: - # For environment-based users, redirect with message - flash('Bitte führen Sie das Migrations-Script aus, um Passwort-Änderung und 2FA zu aktivieren.', 'info') - return redirect(url_for('dashboard')) - return render_template('profile.html', user=user) - -@app.route("/profile/change-password", methods=["POST"]) -@login_required -def change_password(): - current_password = request.form.get('current_password') - new_password = request.form.get('new_password') - confirm_password = request.form.get('confirm_password') - - user = get_user_by_username(session['username']) - - # Verify current password - if not verify_password(current_password, user['password_hash']): - flash('Current password is incorrect.', 'error') - return redirect(url_for('profile')) - - # Check new password - if new_password != confirm_password: - flash('New passwords do not match.', 'error') - return redirect(url_for('profile')) - - if len(new_password) < 8: - flash('Password must be at least 8 characters long.', 'error') - return redirect(url_for('profile')) - - # Update password - new_hash = hash_password(new_password) - conn = get_connection() - cur = conn.cursor() - cur.execute("UPDATE users SET password_hash = %s, last_password_change = %s WHERE id = %s", - (new_hash, datetime.now(), user['id'])) - conn.commit() - cur.close() - conn.close() - - log_audit('PASSWORD_CHANGE', 'user', entity_id=user['id'], - additional_info="Password changed successfully") - flash('Password changed successfully.', 'success') - return redirect(url_for('profile')) - -@app.route("/profile/setup-2fa") -@login_required -def setup_2fa(): - user = get_user_by_username(session['username']) - - if user['totp_enabled']: - flash('2FA is already enabled for your account.', 'info') - return redirect(url_for('profile')) - - # Generate new TOTP secret - totp_secret = generate_totp_secret() - session['temp_totp_secret'] = totp_secret - - # Generate QR code - qr_code = generate_qr_code(user['username'], totp_secret) - - return render_template('setup_2fa.html', - totp_secret=totp_secret, - qr_code=qr_code) - -@app.route("/profile/enable-2fa", methods=["POST"]) -@login_required -def enable_2fa(): - token = request.form.get('token', '').replace(' ', '') - totp_secret = session.get('temp_totp_secret') - - if not totp_secret: - flash('2FA setup session expired. Please try again.', 'error') - return redirect(url_for('setup_2fa')) - - # Verify the token - if not verify_totp(totp_secret, token): - flash('Invalid authentication code. Please try again.', 'error') - return redirect(url_for('setup_2fa')) - - # Generate backup codes - backup_codes = generate_backup_codes() - hashed_codes = [hash_backup_code(code) for code in backup_codes] - - # Enable 2FA - conn = get_connection() - cur = conn.cursor() - cur.execute(""" - UPDATE users - SET totp_secret = %s, totp_enabled = TRUE, backup_codes = %s - WHERE username = %s - """, (totp_secret, json.dumps(hashed_codes), session['username'])) - conn.commit() - cur.close() - conn.close() - - session.pop('temp_totp_secret', None) - - log_audit('2FA_ENABLED', 'user', additional_info="2FA enabled successfully") - - # Show backup codes - return render_template('backup_codes.html', backup_codes=backup_codes) - -@app.route("/profile/disable-2fa", methods=["POST"]) -@login_required -def disable_2fa(): - password = request.form.get('password') - user = get_user_by_username(session['username']) - - # Verify password - if not verify_password(password, user['password_hash']): - flash('Incorrect password.', 'error') - return redirect(url_for('profile')) - - # Disable 2FA - conn = get_connection() - cur = conn.cursor() - cur.execute(""" - UPDATE users - SET totp_enabled = FALSE, totp_secret = NULL, backup_codes = NULL - WHERE username = %s - """, (session['username'],)) - conn.commit() - cur.close() - conn.close() - - log_audit('2FA_DISABLED', 'user', additional_info="2FA disabled") - flash('2FA has been disabled for your account.', 'success') - return redirect(url_for('profile')) - -@app.route("/heartbeat", methods=['POST']) -@login_required -def heartbeat(): - """Endpoint für Session Keep-Alive - aktualisiert last_activity""" - # Aktualisiere last_activity nur wenn explizit angefordert - session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() - # Force session save - session.modified = True - - return jsonify({ - 'status': 'ok', - 'last_activity': session['last_activity'], - 'username': session.get('username') - }) - -@app.route("/api/generate-license-key", methods=['POST']) -@login_required -def api_generate_key(): - """API Endpoint zur Generierung eines neuen Lizenzschlüssels""" - try: - # Lizenztyp aus Request holen (default: full) - data = request.get_json() or {} - license_type = data.get('type', 'full') - - # Key generieren - key = generate_license_key(license_type) - - # Prüfen ob Key bereits existiert (sehr unwahrscheinlich aber sicher ist sicher) - conn = get_connection() - cur = conn.cursor() - - # Wiederhole bis eindeutiger Key gefunden - attempts = 0 - while attempts < 10: # Max 10 Versuche - cur.execute("SELECT 1 FROM licenses WHERE license_key = %s", (key,)) - if not cur.fetchone(): - break # Key ist eindeutig - key = generate_license_key(license_type) - attempts += 1 - - cur.close() - conn.close() - - # Log für Audit - log_audit('GENERATE_KEY', 'license', - additional_info={'type': license_type, 'key': key}) - - return jsonify({ - 'success': True, - 'key': key, - 'type': license_type - }) - - except Exception as e: - logging.error(f"Fehler bei Key-Generierung: {str(e)}") - return jsonify({ - 'success': False, - 'error': 'Fehler bei der Key-Generierung' - }), 500 - -@app.route("/api/customers", methods=['GET']) -@login_required -def api_customers(): - """API Endpoint für die Kundensuche mit Select2""" - try: - # Suchparameter - search = request.args.get('q', '').strip() - page = request.args.get('page', 1, type=int) - per_page = 20 - customer_id = request.args.get('id', type=int) - - conn = get_connection() - cur = conn.cursor() - - # Einzelnen Kunden per ID abrufen - if customer_id: - cur.execute(""" - SELECT c.id, c.name, c.email, - COUNT(l.id) as license_count - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - WHERE c.id = %s - GROUP BY c.id, c.name, c.email - """, (customer_id,)) - - customer = cur.fetchone() - results = [] - if customer: - results.append({ - 'id': customer[0], - 'text': f"{customer[1]} ({customer[2]})", - 'name': customer[1], - 'email': customer[2], - 'license_count': customer[3] - }) - - cur.close() - conn.close() - - return jsonify({ - 'results': results, - 'pagination': {'more': False} - }) - - # SQL Query mit optionaler Suche - elif search: - cur.execute(""" - SELECT c.id, c.name, c.email, - COUNT(l.id) as license_count - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - WHERE LOWER(c.name) LIKE LOWER(%s) - OR LOWER(c.email) LIKE LOWER(%s) - GROUP BY c.id, c.name, c.email - ORDER BY c.name - LIMIT %s OFFSET %s - """, (f'%{search}%', f'%{search}%', per_page, (page - 1) * per_page)) - else: - cur.execute(""" - SELECT c.id, c.name, c.email, - COUNT(l.id) as license_count - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - GROUP BY c.id, c.name, c.email - ORDER BY c.name - LIMIT %s OFFSET %s - """, (per_page, (page - 1) * per_page)) - - customers = cur.fetchall() - - # Format für Select2 - results = [] - for customer in customers: - results.append({ - 'id': customer[0], - 'text': f"{customer[1]} - {customer[2]} ({customer[3]} Lizenzen)", - 'name': customer[1], - 'email': customer[2], - 'license_count': customer[3] - }) - - # Gesamtanzahl für Pagination - if search: - cur.execute(""" - SELECT COUNT(*) FROM customers - WHERE LOWER(name) LIKE LOWER(%s) - OR LOWER(email) LIKE LOWER(%s) - """, (f'%{search}%', f'%{search}%')) - else: - cur.execute("SELECT COUNT(*) FROM customers") - - total_count = cur.fetchone()[0] - - cur.close() - conn.close() - - # Select2 Response Format - return jsonify({ - 'results': results, - 'pagination': { - 'more': (page * per_page) < total_count - } - }) - - except Exception as e: - logging.error(f"Fehler bei Kundensuche: {str(e)}") - return jsonify({ - 'results': [], - 'error': 'Fehler bei der Kundensuche' - }), 500 - -@app.route("/") -@login_required -def dashboard(): - conn = get_connection() - cur = conn.cursor() - - # Statistiken abrufen - # Gesamtanzahl Kunden (ohne Testdaten) - cur.execute("SELECT COUNT(*) FROM customers WHERE is_test = FALSE") - total_customers = cur.fetchone()[0] - - # Gesamtanzahl Lizenzen (ohne Testdaten) - cur.execute("SELECT COUNT(*) FROM licenses WHERE is_test = FALSE") - total_licenses = cur.fetchone()[0] - - # Aktive Lizenzen (nicht abgelaufen und is_active = true, ohne Testdaten) - cur.execute(""" - SELECT COUNT(*) FROM licenses - WHERE valid_until >= CURRENT_DATE AND is_active = TRUE AND is_test = FALSE - """) - active_licenses = cur.fetchone()[0] - - # Aktive Sessions - cur.execute("SELECT COUNT(*) FROM sessions WHERE is_active = TRUE") - active_sessions_count = cur.fetchone()[0] - - # Abgelaufene Lizenzen (ohne Testdaten) - cur.execute(""" - SELECT COUNT(*) FROM licenses - WHERE valid_until < CURRENT_DATE AND is_test = FALSE - """) - expired_licenses = cur.fetchone()[0] - - # Deaktivierte Lizenzen (ohne Testdaten) - cur.execute(""" - SELECT COUNT(*) FROM licenses - WHERE is_active = FALSE AND is_test = FALSE - """) - inactive_licenses = cur.fetchone()[0] - - # Lizenzen die in den nächsten 30 Tagen ablaufen (ohne Testdaten) - cur.execute(""" - SELECT COUNT(*) FROM licenses - WHERE valid_until >= CURRENT_DATE - AND valid_until < CURRENT_DATE + INTERVAL '30 days' - AND is_active = TRUE - AND is_test = FALSE - """) - expiring_soon = cur.fetchone()[0] - - # Testlizenzen vs Vollversionen (ohne Testdaten) - cur.execute(""" - SELECT license_type, COUNT(*) - FROM licenses - WHERE is_test = FALSE - GROUP BY license_type - """) - license_types = dict(cur.fetchall()) - - # Anzahl Testdaten - cur.execute("SELECT COUNT(*) FROM licenses WHERE is_test = TRUE") - test_data_count = cur.fetchone()[0] - - # Anzahl Test-Kunden - cur.execute("SELECT COUNT(*) FROM customers WHERE is_test = TRUE") - test_customers_count = cur.fetchone()[0] - - # Anzahl Test-Ressourcen - cur.execute("SELECT COUNT(*) FROM resource_pools WHERE is_test = TRUE") - test_resources_count = cur.fetchone()[0] - - # Letzte 5 erstellten Lizenzen (ohne Testdaten) - cur.execute(""" - SELECT l.id, l.license_key, c.name, l.valid_until, - CASE - WHEN l.is_active = FALSE THEN 'deaktiviert' - WHEN l.valid_until < CURRENT_DATE THEN 'abgelaufen' - WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'läuft bald ab' - ELSE 'aktiv' - END as status - FROM licenses l - JOIN customers c ON l.customer_id = c.id - WHERE l.is_test = FALSE - ORDER BY l.id DESC - LIMIT 5 - """) - recent_licenses = cur.fetchall() - - # Bald ablaufende Lizenzen (nächste 30 Tage, ohne Testdaten) - cur.execute(""" - SELECT l.id, l.license_key, c.name, l.valid_until, - l.valid_until - CURRENT_DATE as days_left - FROM licenses l - JOIN customers c ON l.customer_id = c.id - WHERE l.valid_until >= CURRENT_DATE - AND l.valid_until < CURRENT_DATE + INTERVAL '30 days' - AND l.is_active = TRUE - AND l.is_test = FALSE - ORDER BY l.valid_until - LIMIT 10 - """) - expiring_licenses = cur.fetchall() - - # Letztes Backup - cur.execute(""" - SELECT created_at, filesize, duration_seconds, backup_type, status - FROM backup_history - ORDER BY created_at DESC - LIMIT 1 - """) - last_backup_info = cur.fetchone() - - # Sicherheitsstatistiken - # Gesperrte IPs - cur.execute(""" - SELECT COUNT(*) FROM login_attempts - WHERE blocked_until IS NOT NULL AND blocked_until > CURRENT_TIMESTAMP - """) - blocked_ips_count = cur.fetchone()[0] - - # Fehlversuche heute - cur.execute(""" - SELECT COALESCE(SUM(attempt_count), 0) FROM login_attempts - WHERE last_attempt::date = CURRENT_DATE - """) - failed_attempts_today = cur.fetchone()[0] - - # Letzte 5 Sicherheitsereignisse - cur.execute(""" - SELECT - la.ip_address, - la.attempt_count, - la.last_attempt, - la.blocked_until, - la.last_username_tried, - la.last_error_message - FROM login_attempts la - ORDER BY la.last_attempt DESC - LIMIT 5 - """) - recent_security_events = [] - for event in cur.fetchall(): - recent_security_events.append({ - 'ip_address': event[0], - 'attempt_count': event[1], - 'last_attempt': event[2].strftime('%d.%m %H:%M'), - 'blocked_until': event[3].strftime('%d.%m %H:%M') if event[3] and event[3] > datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None) else None, - 'username_tried': event[4], - 'error_message': event[5] - }) - - # Sicherheitslevel berechnen - if blocked_ips_count > 5 or failed_attempts_today > 50: - security_level = 'danger' - security_level_text = 'KRITISCH' - elif blocked_ips_count > 2 or failed_attempts_today > 20: - security_level = 'warning' - security_level_text = 'ERHÖHT' - else: - security_level = 'success' - security_level_text = 'NORMAL' - - # Resource Pool Statistiken (nur Live-Daten, keine Testdaten) - cur.execute(""" - SELECT - resource_type, - COUNT(*) FILTER (WHERE status = 'available') as available, - COUNT(*) FILTER (WHERE status = 'allocated') as allocated, - COUNT(*) FILTER (WHERE status = 'quarantine') as quarantine, - COUNT(*) as total - FROM resource_pools - WHERE is_test = FALSE - GROUP BY resource_type - """) - - resource_stats = {} - resource_warning = None - - for row in cur.fetchall(): - available_percent = round((row[1] / row[4] * 100) if row[4] > 0 else 0, 1) - resource_stats[row[0]] = { - 'available': row[1], - 'allocated': row[2], - 'quarantine': row[3], - 'total': row[4], - 'available_percent': available_percent, - 'warning_level': 'danger' if row[1] < 50 else 'warning' if row[1] < 100 else 'success' - } - - # Warnung bei niedrigem Bestand - if row[1] < 50: - if not resource_warning: - resource_warning = f"Niedriger Bestand bei {row[0].upper()}: nur noch {row[1]} verfügbar!" - else: - resource_warning += f" | {row[0].upper()}: {row[1]}" - - cur.close() - conn.close() - - stats = { - 'total_customers': total_customers, - 'total_licenses': total_licenses, - 'active_licenses': active_licenses, - 'expired_licenses': expired_licenses, - 'inactive_licenses': inactive_licenses, - 'expiring_soon': expiring_soon, - 'full_licenses': license_types.get('full', 0), - 'test_licenses': license_types.get('test', 0), - 'test_data_count': test_data_count, - 'test_customers_count': test_customers_count, - 'test_resources_count': test_resources_count, - 'recent_licenses': recent_licenses, - 'expiring_licenses': expiring_licenses, - 'active_sessions': active_sessions_count, - 'last_backup': last_backup_info, - # Sicherheitsstatistiken - 'blocked_ips_count': blocked_ips_count, - 'failed_attempts_today': failed_attempts_today, - 'recent_security_events': recent_security_events, - 'security_level': security_level, - 'security_level_text': security_level_text, - 'resource_stats': resource_stats - } - - return render_template("dashboard.html", - stats=stats, - resource_stats=resource_stats, - resource_warning=resource_warning, - username=session.get('username')) - -@app.route("/create", methods=["GET", "POST"]) -@login_required -def create_license(): - if request.method == "POST": - customer_id = request.form.get("customer_id") - license_key = request.form["license_key"].upper() # Immer Großbuchstaben - license_type = request.form["license_type"] - valid_from = request.form["valid_from"] - is_test = request.form.get("is_test") == "on" # Checkbox value - - # Berechne valid_until basierend auf Laufzeit - duration = int(request.form.get("duration", 1)) - duration_type = request.form.get("duration_type", "years") - - from datetime import datetime, timedelta - from dateutil.relativedelta import relativedelta - - start_date = datetime.strptime(valid_from, "%Y-%m-%d") - - if duration_type == "days": - end_date = start_date + timedelta(days=duration) - elif duration_type == "months": - end_date = start_date + relativedelta(months=duration) - else: # years - end_date = start_date + relativedelta(years=duration) - - # Ein Tag abziehen, da der Starttag mitgezählt wird - end_date = end_date - timedelta(days=1) - valid_until = end_date.strftime("%Y-%m-%d") - - # Validiere License Key Format - if not validate_license_key(license_key): - flash('Ungültiges License Key Format! Erwartet: AF-YYYYMMFT-XXXX-YYYY-ZZZZ', 'error') - return redirect(url_for('create_license')) - - # Resource counts - domain_count = int(request.form.get("domain_count", 1)) - ipv4_count = int(request.form.get("ipv4_count", 1)) - phone_count = int(request.form.get("phone_count", 1)) - device_limit = int(request.form.get("device_limit", 3)) - - conn = get_connection() - cur = conn.cursor() - - try: - # Prüfe ob neuer Kunde oder bestehender - if customer_id == "new": - # Neuer Kunde - name = request.form.get("customer_name") - email = request.form.get("email") - - if not name: - flash('Kundenname ist erforderlich!', 'error') - return redirect(url_for('create_license')) - - # Prüfe ob E-Mail bereits existiert - if email: - cur.execute("SELECT id, name FROM customers WHERE LOWER(email) = LOWER(%s)", (email,)) - existing = cur.fetchone() - if existing: - flash(f'E-Mail bereits vergeben für Kunde: {existing[1]}', 'error') - return redirect(url_for('create_license')) - - # Kunde einfügen (erbt Test-Status von Lizenz) - cur.execute(""" - INSERT INTO customers (name, email, is_test, created_at) - VALUES (%s, %s, %s, NOW()) - RETURNING id - """, (name, email, is_test)) - customer_id = cur.fetchone()[0] - customer_info = {'name': name, 'email': email, 'is_test': is_test} - - # Audit-Log für neuen Kunden - log_audit('CREATE', 'customer', customer_id, - new_values={'name': name, 'email': email, 'is_test': is_test}) - else: - # Bestehender Kunde - hole Infos für Audit-Log - cur.execute("SELECT name, email, is_test FROM customers WHERE id = %s", (customer_id,)) - customer_data = cur.fetchone() - if not customer_data: - flash('Kunde nicht gefunden!', 'error') - return redirect(url_for('create_license')) - customer_info = {'name': customer_data[0], 'email': customer_data[1]} - - # Wenn Kunde Test-Kunde ist, Lizenz auch als Test markieren - if customer_data[2]: # is_test des Kunden - is_test = True - - # Lizenz hinzufügen - cur.execute(""" - INSERT INTO licenses (license_key, customer_id, license_type, valid_from, valid_until, is_active, - domain_count, ipv4_count, phone_count, device_limit, is_test) - VALUES (%s, %s, %s, %s, %s, TRUE, %s, %s, %s, %s, %s) - RETURNING id - """, (license_key, customer_id, license_type, valid_from, valid_until, - domain_count, ipv4_count, phone_count, device_limit, is_test)) - license_id = cur.fetchone()[0] - - # Ressourcen zuweisen - try: - # Prüfe Verfügbarkeit - cur.execute(""" - SELECT - (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s) as domains, - (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s) as ipv4s, - (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s) as phones - """, (is_test, is_test, is_test)) - available = cur.fetchone() - - if available[0] < domain_count: - raise ValueError(f"Nicht genügend Domains verfügbar (benötigt: {domain_count}, verfügbar: {available[0]})") - if available[1] < ipv4_count: - raise ValueError(f"Nicht genügend IPv4-Adressen verfügbar (benötigt: {ipv4_count}, verfügbar: {available[1]})") - if available[2] < phone_count: - raise ValueError(f"Nicht genügend Telefonnummern verfügbar (benötigt: {phone_count}, verfügbar: {available[2]})") - - # Domains zuweisen - if domain_count > 0: - cur.execute(""" - SELECT id FROM resource_pools - WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s - LIMIT %s FOR UPDATE - """, (is_test, domain_count)) - for (resource_id,) in cur.fetchall(): - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], resource_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, resource_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (resource_id, license_id, session['username'], get_client_ip())) - - # IPv4s zuweisen - if ipv4_count > 0: - cur.execute(""" - SELECT id FROM resource_pools - WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s - LIMIT %s FOR UPDATE - """, (is_test, ipv4_count)) - for (resource_id,) in cur.fetchall(): - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], resource_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, resource_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (resource_id, license_id, session['username'], get_client_ip())) - - # Telefonnummern zuweisen - if phone_count > 0: - cur.execute(""" - SELECT id FROM resource_pools - WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s - LIMIT %s FOR UPDATE - """, (is_test, phone_count)) - for (resource_id,) in cur.fetchall(): - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], resource_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, resource_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (resource_id, license_id, session['username'], get_client_ip())) - - except ValueError as e: - conn.rollback() - flash(str(e), 'error') - return redirect(url_for('create_license')) - - conn.commit() - - # Audit-Log - log_audit('CREATE', 'license', license_id, - new_values={ - 'license_key': license_key, - 'customer_name': customer_info['name'], - 'customer_email': customer_info['email'], - 'license_type': license_type, - 'valid_from': valid_from, - 'valid_until': valid_until, - 'device_limit': device_limit, - 'is_test': is_test - }) - - flash(f'Lizenz {license_key} erfolgreich erstellt!', 'success') - - except Exception as e: - conn.rollback() - logging.error(f"Fehler beim Erstellen der Lizenz: {str(e)}") - flash('Fehler beim Erstellen der Lizenz!', 'error') - finally: - cur.close() - conn.close() - - # Preserve show_test parameter if present - redirect_url = "/create" - if request.args.get('show_test') == 'true': - redirect_url += "?show_test=true" - return redirect(redirect_url) - - # Unterstützung für vorausgewählten Kunden - preselected_customer_id = request.args.get('customer_id', type=int) - return render_template("index.html", username=session.get('username'), preselected_customer_id=preselected_customer_id) - -@app.route("/batch", methods=["GET", "POST"]) -@login_required -def batch_licenses(): - """Batch-Generierung mehrerer Lizenzen für einen Kunden""" - if request.method == "POST": - # Formulardaten - customer_id = request.form.get("customer_id") - license_type = request.form["license_type"] - quantity = int(request.form["quantity"]) - valid_from = request.form["valid_from"] - is_test = request.form.get("is_test") == "on" # Checkbox value - - # Berechne valid_until basierend auf Laufzeit - duration = int(request.form.get("duration", 1)) - duration_type = request.form.get("duration_type", "years") - - from datetime import datetime, timedelta - from dateutil.relativedelta import relativedelta - - start_date = datetime.strptime(valid_from, "%Y-%m-%d") - - if duration_type == "days": - end_date = start_date + timedelta(days=duration) - elif duration_type == "months": - end_date = start_date + relativedelta(months=duration) - else: # years - end_date = start_date + relativedelta(years=duration) - - # Ein Tag abziehen, da der Starttag mitgezählt wird - end_date = end_date - timedelta(days=1) - valid_until = end_date.strftime("%Y-%m-%d") - - # Resource counts - domain_count = int(request.form.get("domain_count", 1)) - ipv4_count = int(request.form.get("ipv4_count", 1)) - phone_count = int(request.form.get("phone_count", 1)) - device_limit = int(request.form.get("device_limit", 3)) - - # Sicherheitslimit - if quantity < 1 or quantity > 100: - flash('Anzahl muss zwischen 1 und 100 liegen!', 'error') - return redirect(url_for('batch_licenses')) - - conn = get_connection() - cur = conn.cursor() - - try: - # Prüfe ob neuer Kunde oder bestehender - if customer_id == "new": - # Neuer Kunde - name = request.form.get("customer_name") - email = request.form.get("email") - - if not name: - flash('Kundenname ist erforderlich!', 'error') - return redirect(url_for('batch_licenses')) - - # Prüfe ob E-Mail bereits existiert - if email: - cur.execute("SELECT id, name FROM customers WHERE LOWER(email) = LOWER(%s)", (email,)) - existing = cur.fetchone() - if existing: - flash(f'E-Mail bereits vergeben für Kunde: {existing[1]}', 'error') - return redirect(url_for('batch_licenses')) - - # Kunde einfügen (erbt Test-Status von Lizenz) - cur.execute(""" - INSERT INTO customers (name, email, is_test, created_at) - VALUES (%s, %s, %s, NOW()) - RETURNING id - """, (name, email, is_test)) - customer_id = cur.fetchone()[0] - - # Audit-Log für neuen Kunden - log_audit('CREATE', 'customer', customer_id, - new_values={'name': name, 'email': email, 'is_test': is_test}) - else: - # Bestehender Kunde - hole Infos - cur.execute("SELECT name, email, is_test FROM customers WHERE id = %s", (customer_id,)) - customer_data = cur.fetchone() - if not customer_data: - flash('Kunde nicht gefunden!', 'error') - return redirect(url_for('batch_licenses')) - name = customer_data[0] - email = customer_data[1] - - # Wenn Kunde Test-Kunde ist, Lizenzen auch als Test markieren - if customer_data[2]: # is_test des Kunden - is_test = True - - # Prüfe Ressourcen-Verfügbarkeit für gesamten Batch - total_domains_needed = domain_count * quantity - total_ipv4s_needed = ipv4_count * quantity - total_phones_needed = phone_count * quantity - - cur.execute(""" - SELECT - (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s) as domains, - (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s) as ipv4s, - (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s) as phones - """, (is_test, is_test, is_test)) - available = cur.fetchone() - - if available[0] < total_domains_needed: - flash(f"Nicht genügend Domains verfügbar (benötigt: {total_domains_needed}, verfügbar: {available[0]})", 'error') - return redirect(url_for('batch_licenses')) - if available[1] < total_ipv4s_needed: - flash(f"Nicht genügend IPv4-Adressen verfügbar (benötigt: {total_ipv4s_needed}, verfügbar: {available[1]})", 'error') - return redirect(url_for('batch_licenses')) - if available[2] < total_phones_needed: - flash(f"Nicht genügend Telefonnummern verfügbar (benötigt: {total_phones_needed}, verfügbar: {available[2]})", 'error') - return redirect(url_for('batch_licenses')) - - # Lizenzen generieren und speichern - generated_licenses = [] - for i in range(quantity): - # Eindeutigen Key generieren - attempts = 0 - while attempts < 10: - license_key = generate_license_key(license_type) - cur.execute("SELECT 1 FROM licenses WHERE license_key = %s", (license_key,)) - if not cur.fetchone(): - break - attempts += 1 - - # Lizenz einfügen - cur.execute(""" - INSERT INTO licenses (license_key, customer_id, license_type, is_test, - valid_from, valid_until, is_active, - domain_count, ipv4_count, phone_count, device_limit) - VALUES (%s, %s, %s, %s, %s, %s, true, %s, %s, %s, %s) - RETURNING id - """, (license_key, customer_id, license_type, is_test, valid_from, valid_until, - domain_count, ipv4_count, phone_count, device_limit)) - license_id = cur.fetchone()[0] - - # Ressourcen für diese Lizenz zuweisen - # Domains - if domain_count > 0: - cur.execute(""" - SELECT id FROM resource_pools - WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s - LIMIT %s FOR UPDATE - """, (is_test, domain_count)) - for (resource_id,) in cur.fetchall(): - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], resource_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, resource_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (resource_id, license_id, session['username'], get_client_ip())) - - # IPv4s - if ipv4_count > 0: - cur.execute(""" - SELECT id FROM resource_pools - WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s - LIMIT %s FOR UPDATE - """, (is_test, ipv4_count)) - for (resource_id,) in cur.fetchall(): - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], resource_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, resource_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (resource_id, license_id, session['username'], get_client_ip())) - - # Telefonnummern - if phone_count > 0: - cur.execute(""" - SELECT id FROM resource_pools - WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s - LIMIT %s FOR UPDATE - """, (is_test, phone_count)) - for (resource_id,) in cur.fetchall(): - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], resource_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, resource_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (resource_id, license_id, session['username'], get_client_ip())) - - generated_licenses.append({ - 'id': license_id, - 'key': license_key, - 'type': license_type - }) - - conn.commit() - - # Audit-Log - log_audit('CREATE_BATCH', 'license', - new_values={'customer': name, 'quantity': quantity, 'type': license_type}, - additional_info=f"Batch-Generierung von {quantity} Lizenzen") - - # Session für Export speichern - session['batch_export'] = { - 'customer': name, - 'email': email, - 'licenses': generated_licenses, - 'valid_from': valid_from, - 'valid_until': valid_until, - 'timestamp': datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() - } - - flash(f'{quantity} Lizenzen erfolgreich generiert!', 'success') - return render_template("batch_result.html", - customer=name, - email=email, - licenses=generated_licenses, - valid_from=valid_from, - valid_until=valid_until) - - except Exception as e: - conn.rollback() - logging.error(f"Fehler bei Batch-Generierung: {str(e)}") - flash('Fehler bei der Batch-Generierung!', 'error') - return redirect(url_for('batch_licenses')) - finally: - cur.close() - conn.close() - - # GET Request - return render_template("batch_form.html") - -@app.route("/batch/export") -@login_required -def export_batch(): - """Exportiert die zuletzt generierten Batch-Lizenzen""" - batch_data = session.get('batch_export') - if not batch_data: - flash('Keine Batch-Daten zum Exportieren vorhanden!', 'error') - return redirect(url_for('batch_licenses')) - - # CSV generieren - output = io.StringIO() - output.write('\ufeff') # UTF-8 BOM für Excel - - # Header - output.write(f"Kunde: {batch_data['customer']}\n") - output.write(f"E-Mail: {batch_data['email']}\n") - output.write(f"Generiert am: {datetime.fromisoformat(batch_data['timestamp']).strftime('%d.%m.%Y %H:%M')}\n") - output.write(f"Gültig von: {batch_data['valid_from']} bis {batch_data['valid_until']}\n") - output.write("\n") - output.write("Nr;Lizenzschlüssel;Typ\n") - - # Lizenzen - for i, license in enumerate(batch_data['licenses'], 1): - typ_text = "Vollversion" if license['type'] == 'full' else "Testversion" - output.write(f"{i};{license['key']};{typ_text}\n") - - output.seek(0) - - # Audit-Log - log_audit('EXPORT', 'batch_licenses', - additional_info=f"Export von {len(batch_data['licenses'])} Batch-Lizenzen") - - return send_file( - io.BytesIO(output.getvalue().encode('utf-8-sig')), - mimetype='text/csv', - as_attachment=True, - download_name=f"batch_licenses_{batch_data['customer'].replace(' ', '_')}_{datetime.now(ZoneInfo('Europe/Berlin')).strftime('%Y%m%d_%H%M%S')}.csv" - ) - -@app.route("/licenses") -@login_required -def licenses(): - # Redirect zur kombinierten Ansicht - return redirect("/customers-licenses") - -@app.route("/license/edit/", methods=["GET", "POST"]) -@login_required -def edit_license(license_id): - conn = get_connection() - cur = conn.cursor() - - if request.method == "POST": - # Alte Werte für Audit-Log abrufen - cur.execute(""" - SELECT license_key, license_type, valid_from, valid_until, is_active, is_test, device_limit - FROM licenses WHERE id = %s - """, (license_id,)) - old_license = cur.fetchone() - - # Update license - license_key = request.form["license_key"] - license_type = request.form["license_type"] - valid_from = request.form["valid_from"] - valid_until = request.form["valid_until"] - is_active = request.form.get("is_active") == "on" - is_test = request.form.get("is_test") == "on" - device_limit = int(request.form.get("device_limit", 3)) - - cur.execute(""" - UPDATE licenses - SET license_key = %s, license_type = %s, valid_from = %s, - valid_until = %s, is_active = %s, is_test = %s, device_limit = %s - WHERE id = %s - """, (license_key, license_type, valid_from, valid_until, is_active, is_test, device_limit, license_id)) - - conn.commit() - - # Audit-Log - log_audit('UPDATE', 'license', license_id, - old_values={ - 'license_key': old_license[0], - 'license_type': old_license[1], - 'valid_from': str(old_license[2]), - 'valid_until': str(old_license[3]), - 'is_active': old_license[4], - 'is_test': old_license[5], - 'device_limit': old_license[6] - }, - new_values={ - 'license_key': license_key, - 'license_type': license_type, - 'valid_from': valid_from, - 'valid_until': valid_until, - 'is_active': is_active, - 'is_test': is_test, - 'device_limit': device_limit - }) - - cur.close() - conn.close() - - # Redirect zurück zu customers-licenses mit beibehaltenen Parametern - redirect_url = "/customers-licenses" - - # Behalte show_test Parameter bei (aus Form oder GET-Parameter) - show_test = request.form.get('show_test') or request.args.get('show_test') - if show_test == 'true': - redirect_url += "?show_test=true" - - # Behalte customer_id bei wenn vorhanden - if request.referrer and 'customer_id=' in request.referrer: - import re - match = re.search(r'customer_id=(\d+)', request.referrer) - if match: - connector = "&" if "?" in redirect_url else "?" - redirect_url += f"{connector}customer_id={match.group(1)}" - - return redirect(redirect_url) - - # Get license data - cur.execute(""" - SELECT l.id, l.license_key, c.name, c.email, l.license_type, - l.valid_from, l.valid_until, l.is_active, c.id, l.is_test, l.device_limit - FROM licenses l - JOIN customers c ON l.customer_id = c.id - WHERE l.id = %s - """, (license_id,)) - - license = cur.fetchone() - cur.close() - conn.close() - - if not license: - return redirect("/licenses") - - return render_template("edit_license.html", license=license, username=session.get('username')) - -@app.route("/license/delete/", methods=["POST"]) -@login_required -def delete_license(license_id): - conn = get_connection() - cur = conn.cursor() - - # Lizenzdetails für Audit-Log abrufen - cur.execute(""" - SELECT l.license_key, c.name, l.license_type - FROM licenses l - JOIN customers c ON l.customer_id = c.id - WHERE l.id = %s - """, (license_id,)) - license_info = cur.fetchone() - - cur.execute("DELETE FROM licenses WHERE id = %s", (license_id,)) - - conn.commit() - - # Audit-Log - if license_info: - log_audit('DELETE', 'license', license_id, - old_values={ - 'license_key': license_info[0], - 'customer_name': license_info[1], - 'license_type': license_info[2] - }) - - cur.close() - conn.close() - - return redirect("/licenses") - -@app.route("/customers") -@login_required -def customers(): - # Redirect zur kombinierten Ansicht - return redirect("/customers-licenses") - -@app.route("/customer/edit/", methods=["GET", "POST"]) -@login_required -def edit_customer(customer_id): - conn = get_connection() - cur = conn.cursor() - - if request.method == "POST": - # Alte Werte für Audit-Log abrufen - cur.execute("SELECT name, email, is_test FROM customers WHERE id = %s", (customer_id,)) - old_customer = cur.fetchone() - - # Update customer - name = request.form["name"] - email = request.form["email"] - is_test = request.form.get("is_test") == "on" - - cur.execute(""" - UPDATE customers - SET name = %s, email = %s, is_test = %s - WHERE id = %s - """, (name, email, is_test, customer_id)) - - conn.commit() - - # Audit-Log - log_audit('UPDATE', 'customer', customer_id, - old_values={ - 'name': old_customer[0], - 'email': old_customer[1], - 'is_test': old_customer[2] - }, - new_values={ - 'name': name, - 'email': email, - 'is_test': is_test - }) - - cur.close() - conn.close() - - # Redirect zurück zu customers-licenses mit beibehaltenen Parametern - redirect_url = "/customers-licenses" - - # Behalte show_test Parameter bei (aus Form oder GET-Parameter) - show_test = request.form.get('show_test') or request.args.get('show_test') - if show_test == 'true': - redirect_url += "?show_test=true" - - # Behalte customer_id bei (immer der aktuelle Kunde) - connector = "&" if "?" in redirect_url else "?" - redirect_url += f"{connector}customer_id={customer_id}" - - return redirect(redirect_url) - - # Get customer data with licenses - cur.execute(""" - SELECT id, name, email, created_at, is_test FROM customers WHERE id = %s - """, (customer_id,)) - - customer = cur.fetchone() - if not customer: - cur.close() - conn.close() - return "Kunde nicht gefunden", 404 - - - # Get customer's licenses - cur.execute(""" - SELECT id, license_key, license_type, valid_from, valid_until, is_active - FROM licenses - WHERE customer_id = %s - ORDER BY valid_until DESC - """, (customer_id,)) - - licenses = cur.fetchall() - - cur.close() - conn.close() - - if not customer: - return redirect("/customers-licenses") - - return render_template("edit_customer.html", customer=customer, licenses=licenses, username=session.get('username')) - -@app.route("/customer/create", methods=["GET", "POST"]) -@login_required -def create_customer(): - """Erstellt einen neuen Kunden ohne Lizenz""" - if request.method == "POST": - name = request.form.get('name') - email = request.form.get('email') - is_test = request.form.get('is_test') == 'on' - - if not name or not email: - flash("Name und E-Mail sind Pflichtfelder!", "error") - return render_template("create_customer.html", username=session.get('username')) - - conn = get_connection() - cur = conn.cursor() - - try: - # Prüfen ob E-Mail bereits existiert - cur.execute("SELECT id, name FROM customers WHERE email = %s", (email,)) - existing = cur.fetchone() - if existing: - flash(f"Ein Kunde mit der E-Mail '{email}' existiert bereits: {existing[1]}", "error") - return render_template("create_customer.html", username=session.get('username')) - - # Kunde erstellen - cur.execute(""" - INSERT INTO customers (name, email, created_at, is_test) - VALUES (%s, %s, %s, %s) RETURNING id - """, (name, email, datetime.now(), is_test)) - - customer_id = cur.fetchone()[0] - conn.commit() - - # Audit-Log - log_audit('CREATE', 'customer', customer_id, - new_values={ - 'name': name, - 'email': email, - 'is_test': is_test - }) - - flash(f"Kunde '{name}' wurde erfolgreich angelegt!", "success") - return redirect(f"/customer/edit/{customer_id}") - - except Exception as e: - conn.rollback() - flash(f"Fehler beim Anlegen des Kunden: {str(e)}", "error") - return render_template("create_customer.html", username=session.get('username')) - finally: - cur.close() - conn.close() - - # GET Request - Formular anzeigen - return render_template("create_customer.html", username=session.get('username')) - -@app.route("/customer/delete/", methods=["POST"]) -@login_required -def delete_customer(customer_id): - conn = get_connection() - cur = conn.cursor() - - # Prüfen ob Kunde Lizenzen hat - cur.execute("SELECT COUNT(*) FROM licenses WHERE customer_id = %s", (customer_id,)) - license_count = cur.fetchone()[0] - - if license_count > 0: - # Kunde hat Lizenzen - nicht löschen - cur.close() - conn.close() - return redirect("/customers") - - # Kundendetails für Audit-Log abrufen - cur.execute("SELECT name, email FROM customers WHERE id = %s", (customer_id,)) - customer_info = cur.fetchone() - - # Kunde löschen wenn keine Lizenzen vorhanden - cur.execute("DELETE FROM customers WHERE id = %s", (customer_id,)) - - conn.commit() - - # Audit-Log - if customer_info: - log_audit('DELETE', 'customer', customer_id, - old_values={ - 'name': customer_info[0], - 'email': customer_info[1] - }) - - cur.close() - conn.close() - - return redirect("/customers") - -@app.route("/customers-licenses") -@login_required -def customers_licenses(): - """Kombinierte Ansicht für Kunden und deren Lizenzen""" - conn = get_connection() - cur = conn.cursor() - - # Hole alle Kunden mit Lizenzstatistiken (inkl. Testkunden wenn gewünscht) - show_test = request.args.get('show_test', 'false').lower() == 'true' - - query = """ - SELECT - c.id, - c.name, - c.email, - c.created_at, - COUNT(l.id) as total_licenses, - COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE THEN 1 END) as active_licenses, - COUNT(CASE WHEN l.valid_until < CURRENT_DATE THEN 1 END) as expired_licenses - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - """ - - if not show_test: - query += " WHERE c.is_test = FALSE" - - query += """ - GROUP BY c.id, c.name, c.email, c.created_at - ORDER BY c.name - """ - - cur.execute(query) - customers = cur.fetchall() - - # Hole ausgewählten Kunden nur wenn explizit in URL angegeben - selected_customer_id = request.args.get('customer_id', type=int) - licenses = [] - selected_customer = None - - if customers and selected_customer_id: - # Hole Daten des ausgewählten Kunden - for customer in customers: - if customer[0] == selected_customer_id: - selected_customer = customer - break - - # Hole Lizenzen des ausgewählten Kunden - if selected_customer: - cur.execute(""" - SELECT - l.id, - l.license_key, - l.license_type, - l.valid_from, - l.valid_until, - l.is_active, - CASE - WHEN l.is_active = FALSE THEN 'deaktiviert' - WHEN l.valid_until < CURRENT_DATE THEN 'abgelaufen' - WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'läuft bald ab' - ELSE 'aktiv' - END as status, - l.domain_count, - l.ipv4_count, - l.phone_count, - l.device_limit, - (SELECT COUNT(*) FROM device_registrations WHERE license_id = l.id AND is_active = TRUE) as active_devices, - -- Actual resource counts - (SELECT COUNT(*) FROM license_resources lr - JOIN resource_pools rp ON lr.resource_id = rp.id - WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'domain') as actual_domain_count, - (SELECT COUNT(*) FROM license_resources lr - JOIN resource_pools rp ON lr.resource_id = rp.id - WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'ipv4') as actual_ipv4_count, - (SELECT COUNT(*) FROM license_resources lr - JOIN resource_pools rp ON lr.resource_id = rp.id - WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'phone') as actual_phone_count - FROM licenses l - WHERE l.customer_id = %s - ORDER BY l.created_at DESC, l.id DESC - """, (selected_customer_id,)) - licenses = cur.fetchall() - - cur.close() - conn.close() - - return render_template("customers_licenses.html", - customers=customers, - selected_customer=selected_customer, - selected_customer_id=selected_customer_id, - licenses=licenses, - show_test=show_test) - -@app.route("/api/customer//licenses") -@login_required -def api_customer_licenses(customer_id): - """API-Endpoint für AJAX-Abruf der Lizenzen eines Kunden""" - conn = get_connection() - cur = conn.cursor() - - # Hole Lizenzen des Kunden - cur.execute(""" - SELECT - l.id, - l.license_key, - l.license_type, - l.valid_from, - l.valid_until, - l.is_active, - CASE - WHEN l.is_active = FALSE THEN 'deaktiviert' - WHEN l.valid_until < CURRENT_DATE THEN 'abgelaufen' - WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'läuft bald ab' - ELSE 'aktiv' - END as status, - l.domain_count, - l.ipv4_count, - l.phone_count, - l.device_limit, - (SELECT COUNT(*) FROM device_registrations WHERE license_id = l.id AND is_active = TRUE) as active_devices, - -- Actual resource counts - (SELECT COUNT(*) FROM license_resources lr - JOIN resource_pools rp ON lr.resource_id = rp.id - WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'domain') as actual_domain_count, - (SELECT COUNT(*) FROM license_resources lr - JOIN resource_pools rp ON lr.resource_id = rp.id - WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'ipv4') as actual_ipv4_count, - (SELECT COUNT(*) FROM license_resources lr - JOIN resource_pools rp ON lr.resource_id = rp.id - WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'phone') as actual_phone_count - FROM licenses l - WHERE l.customer_id = %s - ORDER BY l.created_at DESC, l.id DESC - """, (customer_id,)) - - licenses = [] - for row in cur.fetchall(): - license_id = row[0] - - # Hole die konkreten zugewiesenen Ressourcen für diese Lizenz - cur.execute(""" - SELECT rp.id, rp.resource_type, rp.resource_value, lr.assigned_at - FROM resource_pools rp - JOIN license_resources lr ON rp.id = lr.resource_id - WHERE lr.license_id = %s AND lr.is_active = true - ORDER BY rp.resource_type, rp.resource_value - """, (license_id,)) - - resources = { - 'domains': [], - 'ipv4s': [], - 'phones': [] - } - - for res_row in cur.fetchall(): - resource_info = { - 'id': res_row[0], - 'value': res_row[2], - 'assigned_at': res_row[3].strftime('%d.%m.%Y') if res_row[3] else '' - } - - if res_row[1] == 'domain': - resources['domains'].append(resource_info) - elif res_row[1] == 'ipv4': - resources['ipv4s'].append(resource_info) - elif res_row[1] == 'phone': - resources['phones'].append(resource_info) - - licenses.append({ - 'id': row[0], - 'license_key': row[1], - 'license_type': row[2], - 'valid_from': row[3].strftime('%d.%m.%Y') if row[3] else '', - 'valid_until': row[4].strftime('%d.%m.%Y') if row[4] else '', - 'is_active': row[5], - 'status': row[6], - 'domain_count': row[7], # limit - 'ipv4_count': row[8], # limit - 'phone_count': row[9], # limit - 'device_limit': row[10], - 'active_devices': row[11], - 'actual_domain_count': row[12], # actual count - 'actual_ipv4_count': row[13], # actual count - 'actual_phone_count': row[14], # actual count - 'resources': resources - }) - - cur.close() - conn.close() - - return jsonify({ - 'success': True, - 'licenses': licenses, - 'count': len(licenses) - }) - -@app.route("/api/customer//quick-stats") -@login_required -def api_customer_quick_stats(customer_id): - """API-Endpoint für Schnellstatistiken eines Kunden""" - conn = get_connection() - cur = conn.cursor() - - # Hole Kundenstatistiken - cur.execute(""" - SELECT - COUNT(l.id) as total_licenses, - COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE THEN 1 END) as active_licenses, - COUNT(CASE WHEN l.valid_until < CURRENT_DATE THEN 1 END) as expired_licenses, - COUNT(CASE WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' AND l.valid_until >= CURRENT_DATE THEN 1 END) as expiring_soon - FROM licenses l - WHERE l.customer_id = %s - """, (customer_id,)) - - stats = cur.fetchone() - - cur.close() - conn.close() - - return jsonify({ - 'success': True, - 'stats': { - 'total': stats[0], - 'active': stats[1], - 'expired': stats[2], - 'expiring_soon': stats[3] - } - }) - -@app.route("/api/license//quick-edit", methods=['POST']) -@login_required -def api_license_quick_edit(license_id): - """API-Endpoint für schnelle Lizenz-Bearbeitung""" - conn = get_connection() - cur = conn.cursor() - - try: - data = request.get_json() - - # Hole alte Werte für Audit-Log - cur.execute(""" - SELECT is_active, valid_until, license_type - FROM licenses WHERE id = %s - """, (license_id,)) - old_values = cur.fetchone() - - if not old_values: - return jsonify({'success': False, 'error': 'Lizenz nicht gefunden'}), 404 - - # Update-Felder vorbereiten - updates = [] - params = [] - new_values = {} - - if 'is_active' in data: - updates.append("is_active = %s") - params.append(data['is_active']) - new_values['is_active'] = data['is_active'] - - if 'valid_until' in data: - updates.append("valid_until = %s") - params.append(data['valid_until']) - new_values['valid_until'] = data['valid_until'] - - if 'license_type' in data: - updates.append("license_type = %s") - params.append(data['license_type']) - new_values['license_type'] = data['license_type'] - - if updates: - params.append(license_id) - cur.execute(f""" - UPDATE licenses - SET {', '.join(updates)} - WHERE id = %s - """, params) - - conn.commit() - - # Audit-Log - log_audit('UPDATE', 'license', license_id, - old_values={ - 'is_active': old_values[0], - 'valid_until': old_values[1].isoformat() if old_values[1] else None, - 'license_type': old_values[2] - }, - new_values=new_values) - - cur.close() - conn.close() - - return jsonify({'success': True}) - - except Exception as e: - conn.rollback() - cur.close() - conn.close() - return jsonify({'success': False, 'error': str(e)}), 500 - -@app.route("/api/license//resources") -@login_required -def api_license_resources(license_id): - """API-Endpoint für detaillierte Ressourcen-Informationen einer Lizenz""" - conn = get_connection() - cur = conn.cursor() - - try: - # Hole die konkreten zugewiesenen Ressourcen für diese Lizenz - cur.execute(""" - SELECT rp.id, rp.resource_type, rp.resource_value, lr.assigned_at - FROM resource_pools rp - JOIN license_resources lr ON rp.id = lr.resource_id - WHERE lr.license_id = %s AND lr.is_active = true - ORDER BY rp.resource_type, rp.resource_value - """, (license_id,)) - - resources = { - 'domains': [], - 'ipv4s': [], - 'phones': [] - } - - for row in cur.fetchall(): - resource_info = { - 'id': row[0], - 'value': row[2], - 'assigned_at': row[3].strftime('%d.%m.%Y') if row[3] else '' - } - - if row[1] == 'domain': - resources['domains'].append(resource_info) - elif row[1] == 'ipv4': - resources['ipv4s'].append(resource_info) - elif row[1] == 'phone': - resources['phones'].append(resource_info) - - cur.close() - conn.close() - - return jsonify({ - 'success': True, - 'resources': resources - }) - - except Exception as e: - cur.close() - conn.close() - return jsonify({'success': False, 'error': str(e)}), 500 - -@app.route("/sessions") -@login_required -def sessions(): - conn = get_connection() - cur = conn.cursor() - - # Sortierparameter - active_sort = request.args.get('active_sort', 'last_heartbeat') - active_order = request.args.get('active_order', 'desc') - ended_sort = request.args.get('ended_sort', 'ended_at') - ended_order = request.args.get('ended_order', 'desc') - - # Whitelist für erlaubte Sortierfelder - Aktive Sessions - active_sort_fields = { - 'customer': 'c.name', - 'license': 'l.license_key', - 'ip': 's.ip_address', - 'started': 's.started_at', - 'last_heartbeat': 's.last_heartbeat', - 'inactive': 'minutes_inactive' - } - - # Whitelist für erlaubte Sortierfelder - Beendete Sessions - ended_sort_fields = { - 'customer': 'c.name', - 'license': 'l.license_key', - 'ip': 's.ip_address', - 'started': 's.started_at', - 'ended_at': 's.ended_at', - 'duration': 'duration_minutes' - } - - # Validierung - if active_sort not in active_sort_fields: - active_sort = 'last_heartbeat' - if ended_sort not in ended_sort_fields: - ended_sort = 'ended_at' - if active_order not in ['asc', 'desc']: - active_order = 'desc' - if ended_order not in ['asc', 'desc']: - ended_order = 'desc' - - # Aktive Sessions abrufen - cur.execute(f""" - SELECT s.id, s.session_id, l.license_key, c.name, s.ip_address, - s.user_agent, s.started_at, s.last_heartbeat, - EXTRACT(EPOCH FROM (NOW() - s.last_heartbeat))/60 as minutes_inactive - FROM sessions s - JOIN licenses l ON s.license_id = l.id - JOIN customers c ON l.customer_id = c.id - WHERE s.is_active = TRUE - ORDER BY {active_sort_fields[active_sort]} {active_order.upper()} - """) - active_sessions = cur.fetchall() - - # Inaktive Sessions der letzten 24 Stunden - cur.execute(f""" - SELECT s.id, s.session_id, l.license_key, c.name, s.ip_address, - s.started_at, s.ended_at, - EXTRACT(EPOCH FROM (s.ended_at - s.started_at))/60 as duration_minutes - FROM sessions s - JOIN licenses l ON s.license_id = l.id - JOIN customers c ON l.customer_id = c.id - WHERE s.is_active = FALSE - AND s.ended_at > NOW() - INTERVAL '24 hours' - ORDER BY {ended_sort_fields[ended_sort]} {ended_order.upper()} - LIMIT 50 - """) - recent_sessions = cur.fetchall() - - cur.close() - conn.close() - - return render_template("sessions.html", - active_sessions=active_sessions, - recent_sessions=recent_sessions, - active_sort=active_sort, - active_order=active_order, - ended_sort=ended_sort, - ended_order=ended_order, - username=session.get('username')) - -@app.route("/session/end/", methods=["POST"]) -@login_required -def end_session(session_id): - conn = get_connection() - cur = conn.cursor() - - # Session beenden - cur.execute(""" - UPDATE sessions - SET is_active = FALSE, ended_at = NOW() - WHERE id = %s AND is_active = TRUE - """, (session_id,)) - - conn.commit() - cur.close() - conn.close() - - return redirect("/sessions") - -@app.route("/export/licenses") -@login_required -def export_licenses(): - conn = get_connection() - cur = conn.cursor() - - # Alle Lizenzen mit Kundeninformationen abrufen (ohne Testdaten, außer explizit gewünscht) - include_test = request.args.get('include_test', 'false').lower() == 'true' - customer_id = request.args.get('customer_id', type=int) - - query = """ - SELECT l.id, l.license_key, c.name as customer_name, c.email as customer_email, - l.license_type, l.valid_from, l.valid_until, l.is_active, l.is_test, - CASE - WHEN l.is_active = FALSE THEN 'Deaktiviert' - WHEN l.valid_until < CURRENT_DATE THEN 'Abgelaufen' - WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'Läuft bald ab' - ELSE 'Aktiv' - END as status - FROM licenses l - JOIN customers c ON l.customer_id = c.id - """ - - # Build WHERE clause - where_conditions = [] - params = [] - - if not include_test: - where_conditions.append("l.is_test = FALSE") - - if customer_id: - where_conditions.append("l.customer_id = %s") - params.append(customer_id) - - if where_conditions: - query += " WHERE " + " AND ".join(where_conditions) - - query += " ORDER BY l.id" - - cur.execute(query, params) - - # Spaltennamen - columns = ['ID', 'Lizenzschlüssel', 'Kunde', 'E-Mail', 'Typ', - 'Gültig von', 'Gültig bis', 'Aktiv', 'Testdaten', 'Status'] - - # Daten in DataFrame - data = cur.fetchall() - df = pd.DataFrame(data, columns=columns) - - # Datumsformatierung - df['Gültig von'] = pd.to_datetime(df['Gültig von']).dt.strftime('%d.%m.%Y') - df['Gültig bis'] = pd.to_datetime(df['Gültig bis']).dt.strftime('%d.%m.%Y') - - # Typ und Aktiv Status anpassen - df['Typ'] = df['Typ'].replace({'full': 'Vollversion', 'test': 'Testversion'}) - df['Aktiv'] = df['Aktiv'].replace({True: 'Ja', False: 'Nein'}) - df['Testdaten'] = df['Testdaten'].replace({True: 'Ja', False: 'Nein'}) - - cur.close() - conn.close() - - # Export Format - export_format = request.args.get('format', 'excel') - - # Audit-Log - log_audit('EXPORT', 'license', - additional_info=f"Export aller Lizenzen als {export_format.upper()}") - filename = f'lizenzen_export_{datetime.now(ZoneInfo("Europe/Berlin")).strftime("%Y%m%d_%H%M%S")}' - - if export_format == 'csv': - # CSV Export - output = io.StringIO() - df.to_csv(output, index=False, encoding='utf-8-sig', sep=';') - output.seek(0) - - return send_file( - io.BytesIO(output.getvalue().encode('utf-8-sig')), - mimetype='text/csv', - as_attachment=True, - download_name=f'{filename}.csv' - ) - else: - # Excel Export - output = io.BytesIO() - with pd.ExcelWriter(output, engine='openpyxl') as writer: - df.to_excel(writer, sheet_name='Lizenzen', index=False) - - # Formatierung - worksheet = writer.sheets['Lizenzen'] - for column in worksheet.columns: - max_length = 0 - column_letter = column[0].column_letter - for cell in column: - try: - if len(str(cell.value)) > max_length: - max_length = len(str(cell.value)) - except: - pass - adjusted_width = min(max_length + 2, 50) - worksheet.column_dimensions[column_letter].width = adjusted_width - - output.seek(0) - - return send_file( - output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=f'{filename}.xlsx' - ) - -@app.route("/export/audit") -@login_required -def export_audit(): - conn = get_connection() - cur = conn.cursor() - - # Holen der Filter-Parameter - filter_user = request.args.get('user', '') - filter_action = request.args.get('action', '') - filter_entity = request.args.get('entity', '') - export_format = request.args.get('format', 'excel') - - # SQL Query mit Filtern - query = """ - SELECT id, timestamp, username, action, entity_type, entity_id, - old_values, new_values, ip_address, user_agent, additional_info - FROM audit_log - WHERE 1=1 - """ - params = [] - - if filter_user: - query += " AND username ILIKE %s" - params.append(f'%{filter_user}%') - - if filter_action: - query += " AND action = %s" - params.append(filter_action) - - if filter_entity: - query += " AND entity_type = %s" - params.append(filter_entity) - - query += " ORDER BY timestamp DESC" - - cur.execute(query, params) - audit_logs = cur.fetchall() - cur.close() - conn.close() - - # Daten für Export vorbereiten - data = [] - for log in audit_logs: - action_text = { - 'CREATE': 'Erstellt', - 'UPDATE': 'Bearbeitet', - 'DELETE': 'Gelöscht', - 'LOGIN': 'Anmeldung', - 'LOGOUT': 'Abmeldung', - 'AUTO_LOGOUT': 'Auto-Logout', - 'EXPORT': 'Export', - 'GENERATE_KEY': 'Key generiert', - 'CREATE_BATCH': 'Batch erstellt', - 'BACKUP': 'Backup erstellt', - 'LOGIN_2FA_SUCCESS': '2FA-Anmeldung', - 'LOGIN_2FA_BACKUP': '2FA-Backup-Code', - 'LOGIN_2FA_FAILED': '2FA-Fehlgeschlagen', - 'LOGIN_BLOCKED': 'Login-Blockiert', - 'RESTORE': 'Wiederhergestellt', - 'PASSWORD_CHANGE': 'Passwort geändert', - '2FA_ENABLED': '2FA aktiviert', - '2FA_DISABLED': '2FA deaktiviert' - }.get(log[3], log[3]) - - data.append({ - 'ID': log[0], - 'Zeitstempel': log[1].strftime('%d.%m.%Y %H:%M:%S'), - 'Benutzer': log[2], - 'Aktion': action_text, - 'Entität': log[4], - 'Entität-ID': log[5] or '', - 'IP-Adresse': log[8] or '', - 'Zusatzinfo': log[10] or '' - }) - - # DataFrame erstellen - df = pd.DataFrame(data) - - # Timestamp für Dateiname - timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') - filename = f'audit_log_export_{timestamp}' - - # Audit Log für Export - log_audit('EXPORT', 'audit_log', - additional_info=f"{export_format.upper()} Export mit {len(data)} Einträgen") - - if export_format == 'csv': - # CSV Export - output = io.StringIO() - # UTF-8 BOM für Excel - output.write('\ufeff') - df.to_csv(output, index=False, sep=';', encoding='utf-8') - output.seek(0) - - return send_file( - io.BytesIO(output.getvalue().encode('utf-8')), - mimetype='text/csv;charset=utf-8', - as_attachment=True, - download_name=f'{filename}.csv' - ) - else: - # Excel Export - output = BytesIO() - with pd.ExcelWriter(output, engine='openpyxl') as writer: - df.to_excel(writer, index=False, sheet_name='Audit Log') - - # Spaltenbreiten anpassen - worksheet = writer.sheets['Audit Log'] - for idx, col in enumerate(df.columns): - max_length = max( - df[col].astype(str).map(len).max(), - len(col) - ) + 2 - worksheet.column_dimensions[get_column_letter(idx + 1)].width = min(max_length, 50) - - output.seek(0) - - return send_file( - output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=f'{filename}.xlsx' - ) - -@app.route("/export/customers") -@login_required -def export_customers(): - conn = get_connection() - cur = conn.cursor() - - # Check if test data should be included - include_test = request.args.get('include_test', 'false').lower() == 'true' - - # Build query based on test data filter - if include_test: - # Include all customers - query = """ - SELECT c.id, c.name, c.email, c.created_at, c.is_test, - COUNT(l.id) as total_licenses, - COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE THEN 1 END) as active_licenses, - COUNT(CASE WHEN l.valid_until < CURRENT_DATE THEN 1 END) as expired_licenses - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - GROUP BY c.id, c.name, c.email, c.created_at, c.is_test - ORDER BY c.id - """ - else: - # Exclude test customers and test licenses - query = """ - SELECT c.id, c.name, c.email, c.created_at, c.is_test, - COUNT(CASE WHEN l.is_test = FALSE THEN 1 END) as total_licenses, - COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE AND l.is_test = FALSE THEN 1 END) as active_licenses, - COUNT(CASE WHEN l.valid_until < CURRENT_DATE AND l.is_test = FALSE THEN 1 END) as expired_licenses - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - WHERE c.is_test = FALSE - GROUP BY c.id, c.name, c.email, c.created_at, c.is_test - ORDER BY c.id - """ - - cur.execute(query) - - # Spaltennamen - columns = ['ID', 'Name', 'E-Mail', 'Erstellt am', 'Testdaten', - 'Lizenzen gesamt', 'Aktive Lizenzen', 'Abgelaufene Lizenzen'] - - # Daten in DataFrame - data = cur.fetchall() - df = pd.DataFrame(data, columns=columns) - - # Datumsformatierung - df['Erstellt am'] = pd.to_datetime(df['Erstellt am']).dt.strftime('%d.%m.%Y %H:%M') - - # Testdaten formatting - df['Testdaten'] = df['Testdaten'].replace({True: 'Ja', False: 'Nein'}) - - cur.close() - conn.close() - - # Export Format - export_format = request.args.get('format', 'excel') - - # Audit-Log - log_audit('EXPORT', 'customer', - additional_info=f"Export aller Kunden als {export_format.upper()}") - filename = f'kunden_export_{datetime.now(ZoneInfo("Europe/Berlin")).strftime("%Y%m%d_%H%M%S")}' - - if export_format == 'csv': - # CSV Export - output = io.StringIO() - df.to_csv(output, index=False, encoding='utf-8-sig', sep=';') - output.seek(0) - - return send_file( - io.BytesIO(output.getvalue().encode('utf-8-sig')), - mimetype='text/csv', - as_attachment=True, - download_name=f'{filename}.csv' - ) - else: - # Excel Export - output = io.BytesIO() - with pd.ExcelWriter(output, engine='openpyxl') as writer: - df.to_excel(writer, sheet_name='Kunden', index=False) - - # Formatierung - worksheet = writer.sheets['Kunden'] - for column in worksheet.columns: - max_length = 0 - column_letter = column[0].column_letter - for cell in column: - try: - if len(str(cell.value)) > max_length: - max_length = len(str(cell.value)) - except: - pass - adjusted_width = min(max_length + 2, 50) - worksheet.column_dimensions[column_letter].width = adjusted_width - - output.seek(0) - - return send_file( - output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=f'{filename}.xlsx' - ) - -@app.route("/export/sessions") -@login_required -def export_sessions(): - conn = get_connection() - cur = conn.cursor() - - # Holen des Session-Typs (active oder ended) - session_type = request.args.get('type', 'active') - export_format = request.args.get('format', 'excel') - - # Daten je nach Typ abrufen - if session_type == 'active': - # Aktive Lizenz-Sessions - cur.execute(""" - SELECT s.id, l.license_key, c.name as customer_name, s.session_id, - s.started_at, s.last_heartbeat, - EXTRACT(EPOCH FROM (NOW() - s.started_at))::INT as duration_seconds, - s.ip_address, s.user_agent - FROM sessions s - JOIN licenses l ON s.license_id = l.id - JOIN customers c ON l.customer_id = c.id - WHERE s.is_active = true - ORDER BY s.last_heartbeat DESC - """) - sessions = cur.fetchall() - - # Daten für Export vorbereiten - data = [] - for sess in sessions: - duration = sess[6] - hours = duration // 3600 - minutes = (duration % 3600) // 60 - seconds = duration % 60 - - data.append({ - 'Session-ID': sess[0], - 'Lizenzschlüssel': sess[1], - 'Kunde': sess[2], - 'Session-ID (Tech)': sess[3], - 'Startzeit': sess[4].strftime('%d.%m.%Y %H:%M:%S'), - 'Letzte Aktivität': sess[5].strftime('%d.%m.%Y %H:%M:%S'), - 'Dauer': f"{hours}h {minutes}m {seconds}s", - 'IP-Adresse': sess[7], - 'Browser': sess[8] - }) - - sheet_name = 'Aktive Sessions' - filename_prefix = 'aktive_sessions' - else: - # Beendete Lizenz-Sessions - cur.execute(""" - SELECT s.id, l.license_key, c.name as customer_name, s.session_id, - s.started_at, s.ended_at, - EXTRACT(EPOCH FROM (s.ended_at - s.started_at))::INT as duration_seconds, - s.ip_address, s.user_agent - FROM sessions s - JOIN licenses l ON s.license_id = l.id - JOIN customers c ON l.customer_id = c.id - WHERE s.is_active = false AND s.ended_at IS NOT NULL - ORDER BY s.ended_at DESC - LIMIT 1000 - """) - sessions = cur.fetchall() - - # Daten für Export vorbereiten - data = [] - for sess in sessions: - duration = sess[6] if sess[6] else 0 - hours = duration // 3600 - minutes = (duration % 3600) // 60 - seconds = duration % 60 - - data.append({ - 'Session-ID': sess[0], - 'Lizenzschlüssel': sess[1], - 'Kunde': sess[2], - 'Session-ID (Tech)': sess[3], - 'Startzeit': sess[4].strftime('%d.%m.%Y %H:%M:%S'), - 'Endzeit': sess[5].strftime('%d.%m.%Y %H:%M:%S') if sess[5] else '', - 'Dauer': f"{hours}h {minutes}m {seconds}s", - 'IP-Adresse': sess[7], - 'Browser': sess[8] - }) - - sheet_name = 'Beendete Sessions' - filename_prefix = 'beendete_sessions' - - cur.close() - conn.close() - - # DataFrame erstellen - df = pd.DataFrame(data) - - # Timestamp für Dateiname - timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') - filename = f'{filename_prefix}_export_{timestamp}' - - # Audit Log für Export - log_audit('EXPORT', 'sessions', - additional_info=f"{export_format.upper()} Export von {session_type} Sessions mit {len(data)} Einträgen") - - if export_format == 'csv': - # CSV Export - output = io.StringIO() - # UTF-8 BOM für Excel - output.write('\ufeff') - df.to_csv(output, index=False, sep=';', encoding='utf-8') - output.seek(0) - - return send_file( - io.BytesIO(output.getvalue().encode('utf-8')), - mimetype='text/csv;charset=utf-8', - as_attachment=True, - download_name=f'{filename}.csv' - ) - else: - # Excel Export - output = BytesIO() - with pd.ExcelWriter(output, engine='openpyxl') as writer: - df.to_excel(writer, index=False, sheet_name=sheet_name) - - # Spaltenbreiten anpassen - worksheet = writer.sheets[sheet_name] - for idx, col in enumerate(df.columns): - max_length = max( - df[col].astype(str).map(len).max(), - len(col) - ) + 2 - worksheet.column_dimensions[get_column_letter(idx + 1)].width = min(max_length, 50) - - output.seek(0) - - return send_file( - output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=f'{filename}.xlsx' - ) - -@app.route("/export/resources") -@login_required -def export_resources(): - conn = get_connection() - cur = conn.cursor() - - # Holen der Filter-Parameter - filter_type = request.args.get('type', '') - filter_status = request.args.get('status', '') - search_query = request.args.get('search', '') - show_test = request.args.get('show_test', 'false').lower() == 'true' - export_format = request.args.get('format', 'excel') - - # SQL Query mit Filtern - query = """ - SELECT r.id, r.resource_type, r.resource_value, r.status, r.allocated_to_license, - r.created_at, r.status_changed_at, - l.license_key, c.name as customer_name, c.email as customer_email, - l.license_type - FROM resource_pools r - LEFT JOIN licenses l ON r.allocated_to_license = l.id - LEFT JOIN customers c ON l.customer_id = c.id - WHERE 1=1 - """ - params = [] - - # Filter für Testdaten - if not show_test: - query += " AND (r.is_test = false OR r.is_test IS NULL)" - - # Filter für Ressourcentyp - if filter_type: - query += " AND r.resource_type = %s" - params.append(filter_type) - - # Filter für Status - if filter_status: - query += " AND r.status = %s" - params.append(filter_status) - - # Suchfilter - if search_query: - query += " AND (r.resource_value ILIKE %s OR l.license_key ILIKE %s OR c.name ILIKE %s)" - params.extend([f'%{search_query}%', f'%{search_query}%', f'%{search_query}%']) - - query += " ORDER BY r.id DESC" - - cur.execute(query, params) - resources = cur.fetchall() - cur.close() - conn.close() - - # Daten für Export vorbereiten - data = [] - for res in resources: - status_text = { - 'available': 'Verfügbar', - 'allocated': 'Zugewiesen', - 'quarantine': 'Quarantäne' - }.get(res[3], res[3]) - - type_text = { - 'domain': 'Domain', - 'ipv4': 'IPv4', - 'phone': 'Telefon' - }.get(res[1], res[1]) - - data.append({ - 'ID': res[0], - 'Typ': type_text, - 'Ressource': res[2], - 'Status': status_text, - 'Lizenzschlüssel': res[7] or '', - 'Kunde': res[8] or '', - 'Kunden-Email': res[9] or '', - 'Lizenztyp': res[10] or '', - 'Erstellt am': res[5].strftime('%d.%m.%Y %H:%M:%S') if res[5] else '', - 'Zugewiesen am': res[6].strftime('%d.%m.%Y %H:%M:%S') if res[6] else '' - }) - - # DataFrame erstellen - df = pd.DataFrame(data) - - # Timestamp für Dateiname - timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') - filename = f'resources_export_{timestamp}' - - # Audit Log für Export - log_audit('EXPORT', 'resources', - additional_info=f"{export_format.upper()} Export mit {len(data)} Ressourcen") - - if export_format == 'csv': - # CSV Export - output = io.StringIO() - # UTF-8 BOM für Excel - output.write('\ufeff') - df.to_csv(output, index=False, sep=';', encoding='utf-8') - output.seek(0) - - return send_file( - io.BytesIO(output.getvalue().encode('utf-8')), - mimetype='text/csv;charset=utf-8', - as_attachment=True, - download_name=f'{filename}.csv' - ) - else: - # Excel Export - output = BytesIO() - with pd.ExcelWriter(output, engine='openpyxl') as writer: - df.to_excel(writer, index=False, sheet_name='Resources') - - # Spaltenbreiten anpassen - worksheet = writer.sheets['Resources'] - for idx, col in enumerate(df.columns): - max_length = max( - df[col].astype(str).map(len).max(), - len(col) - ) + 2 - worksheet.column_dimensions[get_column_letter(idx + 1)].width = min(max_length, 50) - - output.seek(0) - - return send_file( - output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=f'{filename}.xlsx' - ) - -@app.route("/audit") -@login_required -def audit_log(): - conn = get_connection() - cur = conn.cursor() - - # Parameter - filter_user = request.args.get('user', '').strip() - filter_action = request.args.get('action', '').strip() - filter_entity = request.args.get('entity', '').strip() - page = request.args.get('page', 1, type=int) - sort = request.args.get('sort', 'timestamp') - order = request.args.get('order', 'desc') - per_page = 50 - - # Whitelist für erlaubte Sortierfelder - allowed_sort_fields = { - 'timestamp': 'timestamp', - 'username': 'username', - 'action': 'action', - 'entity': 'entity_type', - 'ip': 'ip_address' - } - - # Validierung - if sort not in allowed_sort_fields: - sort = 'timestamp' - if order not in ['asc', 'desc']: - order = 'desc' - - sort_field = allowed_sort_fields[sort] - - # SQL Query mit optionalen Filtern - query = """ - SELECT id, timestamp, username, action, entity_type, entity_id, - old_values, new_values, ip_address, user_agent, additional_info - FROM audit_log - WHERE 1=1 - """ - - params = [] - - # Filter - if filter_user: - query += " AND LOWER(username) LIKE LOWER(%s)" - params.append(f'%{filter_user}%') - - if filter_action: - query += " AND action = %s" - params.append(filter_action) - - if filter_entity: - query += " AND entity_type = %s" - params.append(filter_entity) - - # Gesamtanzahl für Pagination - count_query = "SELECT COUNT(*) FROM (" + query + ") as count_table" - cur.execute(count_query, params) - total = cur.fetchone()[0] - - # Pagination - offset = (page - 1) * per_page - query += f" ORDER BY {sort_field} {order.upper()} LIMIT %s OFFSET %s" - params.extend([per_page, offset]) - - cur.execute(query, params) - logs = cur.fetchall() - - # JSON-Werte parsen - parsed_logs = [] - for log in logs: - parsed_log = list(log) - # old_values und new_values sind bereits Dictionaries (JSONB) - # Keine Konvertierung nötig - parsed_logs.append(parsed_log) - - # Pagination Info - total_pages = (total + per_page - 1) // per_page - - cur.close() - conn.close() - - return render_template("audit_log.html", - logs=parsed_logs, - filter_user=filter_user, - filter_action=filter_action, - filter_entity=filter_entity, - page=page, - total_pages=total_pages, - total=total, - sort=sort, - order=order, - username=session.get('username')) - -@app.route("/backups") -@login_required -def backups(): - """Zeigt die Backup-Historie an""" - conn = get_connection() - cur = conn.cursor() - - # Letztes erfolgreiches Backup für Dashboard - cur.execute(""" - SELECT created_at, filesize, duration_seconds - FROM backup_history - WHERE status = 'success' - ORDER BY created_at DESC - LIMIT 1 - """) - last_backup = cur.fetchone() - - # Alle Backups abrufen - cur.execute(""" - SELECT id, filename, filesize, backup_type, status, error_message, - created_at, created_by, tables_count, records_count, - duration_seconds, is_encrypted - FROM backup_history - ORDER BY created_at DESC - """) - backups = cur.fetchall() - - cur.close() - conn.close() - - return render_template("backups.html", - backups=backups, - last_backup=last_backup, - username=session.get('username')) - -@app.route("/backup/create", methods=["POST"]) -@login_required -def create_backup_route(): - """Erstellt ein manuelles Backup""" - username = session.get('username') - success, result = create_backup(backup_type="manual", created_by=username) - - if success: - return jsonify({ - 'success': True, - 'message': f'Backup erfolgreich erstellt: {result}' - }) - else: - return jsonify({ - 'success': False, - 'message': f'Backup fehlgeschlagen: {result}' - }), 500 - -@app.route("/backup/restore/", methods=["POST"]) -@login_required -def restore_backup_route(backup_id): - """Stellt ein Backup wieder her""" - encryption_key = request.form.get('encryption_key') - - success, message = restore_backup(backup_id, encryption_key) - - if success: - return jsonify({ - 'success': True, - 'message': message - }) - else: - return jsonify({ - 'success': False, - 'message': message - }), 500 - -@app.route("/backup/download/") -@login_required -def download_backup(backup_id): - """Lädt eine Backup-Datei herunter""" - conn = get_connection() - cur = conn.cursor() - - cur.execute(""" - SELECT filename, filepath - FROM backup_history - WHERE id = %s - """, (backup_id,)) - backup_info = cur.fetchone() - - cur.close() - conn.close() - - if not backup_info: - return "Backup nicht gefunden", 404 - - filename, filepath = backup_info - filepath = Path(filepath) - - if not filepath.exists(): - return "Backup-Datei nicht gefunden", 404 - - # Audit-Log - log_audit('DOWNLOAD', 'backup', backup_id, - additional_info=f"Backup heruntergeladen: {filename}") - - return send_file(filepath, as_attachment=True, download_name=filename) - -@app.route("/backup/delete/", methods=["DELETE"]) -@login_required -def delete_backup(backup_id): - """Löscht ein Backup""" - conn = get_connection() - cur = conn.cursor() - - try: - # Backup-Informationen abrufen - cur.execute(""" - SELECT filename, filepath - FROM backup_history - WHERE id = %s - """, (backup_id,)) - backup_info = cur.fetchone() - - if not backup_info: - return jsonify({ - 'success': False, - 'message': 'Backup nicht gefunden' - }), 404 - - filename, filepath = backup_info - filepath = Path(filepath) - - # Datei löschen, wenn sie existiert - if filepath.exists(): - filepath.unlink() - - # Aus Datenbank löschen - cur.execute(""" - DELETE FROM backup_history - WHERE id = %s - """, (backup_id,)) - - conn.commit() - - # Audit-Log - log_audit('DELETE', 'backup', backup_id, - additional_info=f"Backup gelöscht: {filename}") - - return jsonify({ - 'success': True, - 'message': f'Backup "{filename}" wurde erfolgreich gelöscht' - }) - - except Exception as e: - conn.rollback() - return jsonify({ - 'success': False, - 'message': f'Fehler beim Löschen des Backups: {str(e)}' - }), 500 - finally: - cur.close() - conn.close() - -@app.route("/security/blocked-ips") -@login_required -def blocked_ips(): - """Zeigt alle gesperrten IPs an""" - conn = get_connection() - cur = conn.cursor() - - cur.execute(""" - SELECT - ip_address, - attempt_count, - first_attempt, - last_attempt, - blocked_until, - last_username_tried, - last_error_message - FROM login_attempts - WHERE blocked_until IS NOT NULL - ORDER BY blocked_until DESC - """) - - blocked_ips_list = [] - for ip in cur.fetchall(): - blocked_ips_list.append({ - 'ip_address': ip[0], - 'attempt_count': ip[1], - 'first_attempt': ip[2].strftime('%d.%m.%Y %H:%M'), - 'last_attempt': ip[3].strftime('%d.%m.%Y %H:%M'), - 'blocked_until': ip[4].strftime('%d.%m.%Y %H:%M'), - 'is_active': ip[4] > datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None), - 'last_username': ip[5], - 'last_error': ip[6] - }) - - cur.close() - conn.close() - - return render_template("blocked_ips.html", - blocked_ips=blocked_ips_list, - username=session.get('username')) - -@app.route("/security/unblock-ip", methods=["POST"]) -@login_required -def unblock_ip(): - """Entsperrt eine IP-Adresse""" - ip_address = request.form.get('ip_address') - - if ip_address: - conn = get_connection() - cur = conn.cursor() - - cur.execute(""" - UPDATE login_attempts - SET blocked_until = NULL - WHERE ip_address = %s - """, (ip_address,)) - - conn.commit() - cur.close() - conn.close() - - # Audit-Log - log_audit('UNBLOCK_IP', 'security', - additional_info=f"IP {ip_address} manuell entsperrt") - - return redirect(url_for('blocked_ips')) - -@app.route("/security/clear-attempts", methods=["POST"]) -@login_required -def clear_attempts(): - """Löscht alle Login-Versuche für eine IP""" - ip_address = request.form.get('ip_address') - - if ip_address: - reset_login_attempts(ip_address) - - # Audit-Log - log_audit('CLEAR_ATTEMPTS', 'security', - additional_info=f"Login-Versuche für IP {ip_address} zurückgesetzt") - - return redirect(url_for('blocked_ips')) - -# API Endpoints for License Management -@app.route("/api/license//toggle", methods=["POST"]) -@login_required -def toggle_license_api(license_id): - """Toggle license active status via API""" - try: - data = request.get_json() - is_active = data.get('is_active', False) - - conn = get_connection() - cur = conn.cursor() - - # Update license status - cur.execute(""" - UPDATE licenses - SET is_active = %s - WHERE id = %s - """, (is_active, license_id)) - - conn.commit() - - # Log the action - log_audit('UPDATE', 'license', license_id, - new_values={'is_active': is_active}, - additional_info=f"Lizenz {'aktiviert' if is_active else 'deaktiviert'} via Toggle") - - cur.close() - conn.close() - - return jsonify({'success': True, 'message': 'Status erfolgreich geändert'}) - except Exception as e: - return jsonify({'success': False, 'message': str(e)}), 500 - -@app.route("/api/licenses/bulk-activate", methods=["POST"]) -@login_required -def bulk_activate_licenses(): - """Activate multiple licenses at once""" - try: - data = request.get_json() - license_ids = data.get('ids', []) - - if not license_ids: - return jsonify({'success': False, 'message': 'Keine Lizenzen ausgewählt'}), 400 - - conn = get_connection() - cur = conn.cursor() - - # Update all selected licenses (nur Live-Daten) - cur.execute(""" - UPDATE licenses - SET is_active = TRUE - WHERE id = ANY(%s) AND is_test = FALSE - """, (license_ids,)) - - affected_rows = cur.rowcount - conn.commit() - - # Log the bulk action - log_audit('BULK_UPDATE', 'licenses', None, - new_values={'is_active': True, 'count': affected_rows}, - additional_info=f"{affected_rows} Lizenzen aktiviert") - - cur.close() - conn.close() - - return jsonify({'success': True, 'message': f'{affected_rows} Lizenzen aktiviert'}) - except Exception as e: - return jsonify({'success': False, 'message': str(e)}), 500 - -@app.route("/api/licenses/bulk-deactivate", methods=["POST"]) -@login_required -def bulk_deactivate_licenses(): - """Deactivate multiple licenses at once""" - try: - data = request.get_json() - license_ids = data.get('ids', []) - - if not license_ids: - return jsonify({'success': False, 'message': 'Keine Lizenzen ausgewählt'}), 400 - - conn = get_connection() - cur = conn.cursor() - - # Update all selected licenses (nur Live-Daten) - cur.execute(""" - UPDATE licenses - SET is_active = FALSE - WHERE id = ANY(%s) AND is_test = FALSE - """, (license_ids,)) - - affected_rows = cur.rowcount - conn.commit() - - # Log the bulk action - log_audit('BULK_UPDATE', 'licenses', None, - new_values={'is_active': False, 'count': affected_rows}, - additional_info=f"{affected_rows} Lizenzen deaktiviert") - - cur.close() - conn.close() - - return jsonify({'success': True, 'message': f'{affected_rows} Lizenzen deaktiviert'}) - except Exception as e: - return jsonify({'success': False, 'message': str(e)}), 500 - -@app.route("/api/license//devices") -@login_required -def get_license_devices(license_id): - """Hole alle registrierten Geräte einer Lizenz""" - try: - conn = get_connection() - cur = conn.cursor() - - # Prüfe ob Lizenz existiert und hole device_limit - cur.execute(""" - SELECT device_limit FROM licenses WHERE id = %s - """, (license_id,)) - license_data = cur.fetchone() - - if not license_data: - return jsonify({'success': False, 'message': 'Lizenz nicht gefunden'}), 404 - - device_limit = license_data[0] - - # Hole alle Geräte für diese Lizenz - cur.execute(""" - SELECT id, hardware_id, device_name, operating_system, - first_seen, last_seen, is_active, ip_address - FROM device_registrations - WHERE license_id = %s - ORDER BY is_active DESC, last_seen DESC - """, (license_id,)) - - devices = [] - for row in cur.fetchall(): - devices.append({ - 'id': row[0], - 'hardware_id': row[1], - 'device_name': row[2] or 'Unbekanntes Gerät', - 'operating_system': row[3] or 'Unbekannt', - 'first_seen': row[4].strftime('%d.%m.%Y %H:%M') if row[4] else '', - 'last_seen': row[5].strftime('%d.%m.%Y %H:%M') if row[5] else '', - 'is_active': row[6], - 'ip_address': row[7] or '-' - }) - - cur.close() - conn.close() - - return jsonify({ - 'success': True, - 'devices': devices, - 'device_limit': device_limit, - 'active_count': sum(1 for d in devices if d['is_active']) - }) - - except Exception as e: - logging.error(f"Fehler beim Abrufen der Geräte: {str(e)}") - return jsonify({'success': False, 'message': 'Fehler beim Abrufen der Geräte'}), 500 - -@app.route("/api/license//register-device", methods=["POST"]) -def register_device(license_id): - """Registriere ein neues Gerät für eine Lizenz""" - try: - data = request.get_json() - hardware_id = data.get('hardware_id') - device_name = data.get('device_name', '') - operating_system = data.get('operating_system', '') - - if not hardware_id: - return jsonify({'success': False, 'message': 'Hardware-ID fehlt'}), 400 - - conn = get_connection() - cur = conn.cursor() - - # Prüfe ob Lizenz existiert und aktiv ist - cur.execute(""" - SELECT device_limit, is_active, valid_until - FROM licenses - WHERE id = %s - """, (license_id,)) - license_data = cur.fetchone() - - if not license_data: - return jsonify({'success': False, 'message': 'Lizenz nicht gefunden'}), 404 - - device_limit, is_active, valid_until = license_data - - # Prüfe ob Lizenz aktiv und gültig ist - if not is_active: - return jsonify({'success': False, 'message': 'Lizenz ist deaktiviert'}), 403 - - if valid_until < datetime.now(ZoneInfo("Europe/Berlin")).date(): - return jsonify({'success': False, 'message': 'Lizenz ist abgelaufen'}), 403 - - # Prüfe ob Gerät bereits registriert ist - cur.execute(""" - SELECT id, is_active FROM device_registrations - WHERE license_id = %s AND hardware_id = %s - """, (license_id, hardware_id)) - existing_device = cur.fetchone() - - if existing_device: - device_id, is_device_active = existing_device - if is_device_active: - # Gerät ist bereits aktiv, update last_seen - cur.execute(""" - UPDATE device_registrations - SET last_seen = CURRENT_TIMESTAMP, - ip_address = %s, - user_agent = %s - WHERE id = %s - """, (get_client_ip(), request.headers.get('User-Agent', ''), device_id)) - conn.commit() - return jsonify({'success': True, 'message': 'Gerät bereits registriert', 'device_id': device_id}) - else: - # Gerät war deaktiviert, prüfe ob wir es reaktivieren können - cur.execute(""" - SELECT COUNT(*) FROM device_registrations - WHERE license_id = %s AND is_active = TRUE - """, (license_id,)) - active_count = cur.fetchone()[0] - - if active_count >= device_limit: - return jsonify({'success': False, 'message': f'Gerätelimit erreicht ({device_limit} Geräte)'}), 403 - - # Reaktiviere das Gerät - cur.execute(""" - UPDATE device_registrations - SET is_active = TRUE, - last_seen = CURRENT_TIMESTAMP, - deactivated_at = NULL, - deactivated_by = NULL, - ip_address = %s, - user_agent = %s - WHERE id = %s - """, (get_client_ip(), request.headers.get('User-Agent', ''), device_id)) - conn.commit() - return jsonify({'success': True, 'message': 'Gerät reaktiviert', 'device_id': device_id}) - - # Neues Gerät - prüfe Gerätelimit - cur.execute(""" - SELECT COUNT(*) FROM device_registrations - WHERE license_id = %s AND is_active = TRUE - """, (license_id,)) - active_count = cur.fetchone()[0] - - if active_count >= device_limit: - return jsonify({'success': False, 'message': f'Gerätelimit erreicht ({device_limit} Geräte)'}), 403 - - # Registriere neues Gerät - cur.execute(""" - INSERT INTO device_registrations - (license_id, hardware_id, device_name, operating_system, ip_address, user_agent) - VALUES (%s, %s, %s, %s, %s, %s) - RETURNING id - """, (license_id, hardware_id, device_name, operating_system, - get_client_ip(), request.headers.get('User-Agent', ''))) - device_id = cur.fetchone()[0] - - conn.commit() - - # Audit Log - log_audit('DEVICE_REGISTER', 'device', device_id, - new_values={'license_id': license_id, 'hardware_id': hardware_id}) - - cur.close() - conn.close() - - return jsonify({'success': True, 'message': 'Gerät erfolgreich registriert', 'device_id': device_id}) - - except Exception as e: - logging.error(f"Fehler bei Geräte-Registrierung: {str(e)}") - return jsonify({'success': False, 'message': 'Fehler bei der Registrierung'}), 500 - -@app.route("/api/license//deactivate-device/", methods=["POST"]) -@login_required -def deactivate_device(license_id, device_id): - """Deaktiviere ein registriertes Gerät""" - try: - conn = get_connection() - cur = conn.cursor() - - # Prüfe ob das Gerät zu dieser Lizenz gehört - cur.execute(""" - SELECT id FROM device_registrations - WHERE id = %s AND license_id = %s AND is_active = TRUE - """, (device_id, license_id)) - - if not cur.fetchone(): - return jsonify({'success': False, 'message': 'Gerät nicht gefunden oder bereits deaktiviert'}), 404 - - # Deaktiviere das Gerät - cur.execute(""" - UPDATE device_registrations - SET is_active = FALSE, - deactivated_at = CURRENT_TIMESTAMP, - deactivated_by = %s - WHERE id = %s - """, (session['username'], device_id)) - - conn.commit() - - # Audit Log - log_audit('DEVICE_DEACTIVATE', 'device', device_id, - old_values={'is_active': True}, - new_values={'is_active': False}) - - cur.close() - conn.close() - - return jsonify({'success': True, 'message': 'Gerät erfolgreich deaktiviert'}) - - except Exception as e: - logging.error(f"Fehler beim Deaktivieren des Geräts: {str(e)}") - return jsonify({'success': False, 'message': 'Fehler beim Deaktivieren'}), 500 - -@app.route("/api/licenses/bulk-delete", methods=["POST"]) -@login_required -def bulk_delete_licenses(): - """Delete multiple licenses at once""" - try: - data = request.get_json() - license_ids = data.get('ids', []) - - if not license_ids: - return jsonify({'success': False, 'message': 'Keine Lizenzen ausgewählt'}), 400 - - conn = get_connection() - cur = conn.cursor() - - # Get license info for audit log (nur Live-Daten) - cur.execute(""" - SELECT license_key - FROM licenses - WHERE id = ANY(%s) AND is_test = FALSE - """, (license_ids,)) - license_keys = [row[0] for row in cur.fetchall()] - - # Delete all selected licenses (nur Live-Daten) - cur.execute(""" - DELETE FROM licenses - WHERE id = ANY(%s) AND is_test = FALSE - """, (license_ids,)) - - affected_rows = cur.rowcount - conn.commit() - - # Log the bulk action - log_audit('BULK_DELETE', 'licenses', None, - old_values={'license_keys': license_keys, 'count': affected_rows}, - additional_info=f"{affected_rows} Lizenzen gelöscht") - - cur.close() - conn.close() - - return jsonify({'success': True, 'message': f'{affected_rows} Lizenzen gelöscht'}) - except Exception as e: - return jsonify({'success': False, 'message': str(e)}), 500 - -# ===================== RESOURCE POOL MANAGEMENT ===================== - -@app.route('/resources') -@login_required -def resources(): - """Resource Pool Hauptübersicht""" - conn = get_connection() - cur = conn.cursor() - - # Prüfe ob Testdaten angezeigt werden sollen (gleiche Logik wie bei Kunden) - show_test = request.args.get('show_test', 'false').lower() == 'true' - - # Statistiken abrufen - cur.execute(""" - SELECT - resource_type, - COUNT(*) FILTER (WHERE status = 'available') as available, - COUNT(*) FILTER (WHERE status = 'allocated') as allocated, - COUNT(*) FILTER (WHERE status = 'quarantine') as quarantine, - COUNT(*) as total - FROM resource_pools - WHERE is_test = %s - GROUP BY resource_type - """, (show_test,)) - - stats = {} - for row in cur.fetchall(): - stats[row[0]] = { - 'available': row[1], - 'allocated': row[2], - 'quarantine': row[3], - 'total': row[4], - 'available_percent': round((row[1] / row[4] * 100) if row[4] > 0 else 0, 1) - } - - # Letzte Aktivitäten (gefiltert nach Test/Live) - cur.execute(""" - SELECT - rh.action, - rh.action_by, - rh.action_at, - rp.resource_type, - rp.resource_value, - rh.details - FROM resource_history rh - JOIN resource_pools rp ON rh.resource_id = rp.id - WHERE rp.is_test = %s - ORDER BY rh.action_at DESC - LIMIT 10 - """, (show_test,)) - recent_activities = cur.fetchall() - - # Ressourcen-Liste mit Pagination - page = request.args.get('page', 1, type=int) - per_page = 50 - offset = (page - 1) * per_page - - resource_type = request.args.get('type', '') - status_filter = request.args.get('status', '') - search = request.args.get('search', '') - - # Sortierung - sort_by = request.args.get('sort', 'id') - sort_order = request.args.get('order', 'desc') - - # Base Query - query = """ - SELECT - rp.id, - rp.resource_type, - rp.resource_value, - rp.status, - rp.allocated_to_license, - l.license_key, - c.name as customer_name, - rp.status_changed_at, - rp.quarantine_reason, - rp.quarantine_until, - c.id as customer_id - FROM resource_pools rp - LEFT JOIN licenses l ON rp.allocated_to_license = l.id - LEFT JOIN customers c ON l.customer_id = c.id - WHERE rp.is_test = %s - """ - params = [show_test] - - if resource_type: - query += " AND rp.resource_type = %s" - params.append(resource_type) - - if status_filter: - query += " AND rp.status = %s" - params.append(status_filter) - - if search: - query += " AND rp.resource_value ILIKE %s" - params.append(f'%{search}%') - - # Count total - count_query = f"SELECT COUNT(*) FROM ({query}) as cnt" - cur.execute(count_query, params) - total = cur.fetchone()[0] - total_pages = (total + per_page - 1) // per_page - - # Get paginated results with dynamic sorting - sort_column_map = { - 'id': 'rp.id', - 'type': 'rp.resource_type', - 'resource': 'rp.resource_value', - 'status': 'rp.status', - 'assigned': 'c.name', - 'changed': 'rp.status_changed_at' - } - - sort_column = sort_column_map.get(sort_by, 'rp.id') - sort_direction = 'ASC' if sort_order == 'asc' else 'DESC' - - query += f" ORDER BY {sort_column} {sort_direction} LIMIT %s OFFSET %s" - params.extend([per_page, offset]) - - cur.execute(query, params) - resources = cur.fetchall() - - cur.close() - conn.close() - - return render_template('resources.html', - stats=stats, - resources=resources, - recent_activities=recent_activities, - page=page, - total_pages=total_pages, - total=total, - resource_type=resource_type, - status_filter=status_filter, - search=search, - show_test=show_test, - sort_by=sort_by, - sort_order=sort_order, - datetime=datetime, - timedelta=timedelta) - -@app.route('/resources/add', methods=['GET', 'POST']) -@login_required -def add_resources(): - """Ressourcen zum Pool hinzufügen""" - # Hole show_test Parameter für die Anzeige - show_test = request.args.get('show_test', 'false').lower() == 'true' - - if request.method == 'POST': - resource_type = request.form.get('resource_type') - resources_text = request.form.get('resources_text', '') - is_test = request.form.get('is_test') == 'on' # Checkbox für Testdaten - - # Parse resources (one per line) - resources = [r.strip() for r in resources_text.split('\n') if r.strip()] - - if not resources: - flash('Keine Ressourcen angegeben', 'error') - return redirect(url_for('add_resources', show_test=show_test)) - - conn = get_connection() - cur = conn.cursor() - - added = 0 - duplicates = 0 - - for resource_value in resources: - try: - cur.execute(""" - INSERT INTO resource_pools (resource_type, resource_value, status_changed_by, is_test) - VALUES (%s, %s, %s, %s) - ON CONFLICT (resource_type, resource_value) DO NOTHING - """, (resource_type, resource_value, session['username'], is_test)) - - if cur.rowcount > 0: - added += 1 - # Get the inserted ID - cur.execute("SELECT id FROM resource_pools WHERE resource_type = %s AND resource_value = %s", - (resource_type, resource_value)) - resource_id = cur.fetchone()[0] - - # Log in history - cur.execute(""" - INSERT INTO resource_history (resource_id, action, action_by, ip_address) - VALUES (%s, 'created', %s, %s) - """, (resource_id, session['username'], get_client_ip())) - else: - duplicates += 1 - - except Exception as e: - app.logger.error(f"Error adding resource {resource_value}: {e}") - - conn.commit() - cur.close() - conn.close() - - log_audit('CREATE', 'resource_pool', None, - new_values={'type': resource_type, 'added': added, 'duplicates': duplicates, 'is_test': is_test}, - additional_info=f"{added} {'Test-' if is_test else ''}Ressourcen hinzugefügt, {duplicates} Duplikate übersprungen") - - flash(f'{added} {"Test-" if is_test else ""}Ressourcen hinzugefügt, {duplicates} Duplikate übersprungen', 'success') - return redirect(url_for('resources', show_test=show_test)) - - return render_template('add_resources.html', show_test=show_test) - -@app.route('/resources/quarantine/', methods=['POST']) -@login_required -def quarantine_resource(resource_id): - """Ressource in Quarantäne setzen""" - reason = request.form.get('reason', 'review') - until_date = request.form.get('until_date') - notes = request.form.get('notes', '') - - conn = get_connection() - cur = conn.cursor() - - # Get current resource info - cur.execute("SELECT resource_type, resource_value, status FROM resource_pools WHERE id = %s", (resource_id,)) - resource = cur.fetchone() - - if not resource: - flash('Ressource nicht gefunden', 'error') - return redirect(url_for('resources')) - - old_status = resource[2] - - # Update resource - cur.execute(""" - UPDATE resource_pools - SET status = 'quarantine', - quarantine_reason = %s, - quarantine_until = %s, - notes = %s, - status_changed_at = CURRENT_TIMESTAMP, - status_changed_by = %s - WHERE id = %s - """, (reason, until_date if until_date else None, notes, session['username'], resource_id)) - - # Log in history - cur.execute(""" - INSERT INTO resource_history (resource_id, action, action_by, ip_address, details) - VALUES (%s, 'quarantined', %s, %s, %s) - """, (resource_id, session['username'], get_client_ip(), - Json({'reason': reason, 'until': until_date, 'notes': notes, 'old_status': old_status}))) - - conn.commit() - cur.close() - conn.close() - - log_audit('UPDATE', 'resource', resource_id, - old_values={'status': old_status}, - new_values={'status': 'quarantine', 'reason': reason}, - additional_info=f"Ressource {resource[0]}: {resource[1]} in Quarantäne") - - flash('Ressource in Quarantäne gesetzt', 'success') - - # Redirect mit allen aktuellen Filtern - return redirect(url_for('resources', - show_test=request.args.get('show_test', request.form.get('show_test', 'false')), - type=request.args.get('type', request.form.get('type', '')), - status=request.args.get('status', request.form.get('status', '')), - search=request.args.get('search', request.form.get('search', '')))) - -@app.route('/resources/release', methods=['POST']) -@login_required -def release_resources(): - """Ressourcen aus Quarantäne freigeben""" - resource_ids = request.form.getlist('resource_ids') - - if not resource_ids: - flash('Keine Ressourcen ausgewählt', 'error') - return redirect(url_for('resources')) - - conn = get_connection() - cur = conn.cursor() - - released = 0 - for resource_id in resource_ids: - cur.execute(""" - UPDATE resource_pools - SET status = 'available', - quarantine_reason = NULL, - quarantine_until = NULL, - allocated_to_license = NULL, - status_changed_at = CURRENT_TIMESTAMP, - status_changed_by = %s - WHERE id = %s AND status = 'quarantine' - """, (session['username'], resource_id)) - - if cur.rowcount > 0: - released += 1 - # Log in history - cur.execute(""" - INSERT INTO resource_history (resource_id, action, action_by, ip_address) - VALUES (%s, 'released', %s, %s) - """, (resource_id, session['username'], get_client_ip())) - - conn.commit() - cur.close() - conn.close() - - log_audit('UPDATE', 'resource_pool', None, - new_values={'released': released}, - additional_info=f"{released} Ressourcen aus Quarantäne freigegeben") - - flash(f'{released} Ressourcen freigegeben', 'success') - - # Redirect mit allen aktuellen Filtern - return redirect(url_for('resources', - show_test=request.args.get('show_test', request.form.get('show_test', 'false')), - type=request.args.get('type', request.form.get('type', '')), - status=request.args.get('status', request.form.get('status', '')), - search=request.args.get('search', request.form.get('search', '')))) - -@app.route('/api/resources/allocate', methods=['POST']) -@login_required -def allocate_resources_api(): - """API für Ressourcen-Zuweisung bei Lizenzerstellung""" - data = request.json - license_id = data.get('license_id') - domain_count = data.get('domain_count', 1) - ipv4_count = data.get('ipv4_count', 1) - phone_count = data.get('phone_count', 1) - - conn = get_connection() - cur = conn.cursor() - - try: - allocated = {'domains': [], 'ipv4s': [], 'phones': []} - - # Allocate domains - if domain_count > 0: - cur.execute(""" - SELECT id, resource_value FROM resource_pools - WHERE resource_type = 'domain' AND status = 'available' - LIMIT %s FOR UPDATE - """, (domain_count,)) - domains = cur.fetchall() - - if len(domains) < domain_count: - raise ValueError(f"Nicht genügend Domains verfügbar (benötigt: {domain_count}, verfügbar: {len(domains)})") - - for domain_id, domain_value in domains: - # Update resource status - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', - allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, - status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], domain_id)) - - # Create assignment - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, domain_id, session['username'])) - - # Log history - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (domain_id, license_id, session['username'], get_client_ip())) - - allocated['domains'].append(domain_value) - - # Allocate IPv4s (similar logic) - if ipv4_count > 0: - cur.execute(""" - SELECT id, resource_value FROM resource_pools - WHERE resource_type = 'ipv4' AND status = 'available' - LIMIT %s FOR UPDATE - """, (ipv4_count,)) - ipv4s = cur.fetchall() - - if len(ipv4s) < ipv4_count: - raise ValueError(f"Nicht genügend IPv4-Adressen verfügbar") - - for ipv4_id, ipv4_value in ipv4s: - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', - allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, - status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], ipv4_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, ipv4_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (ipv4_id, license_id, session['username'], get_client_ip())) - - allocated['ipv4s'].append(ipv4_value) - - # Allocate phones (similar logic) - if phone_count > 0: - cur.execute(""" - SELECT id, resource_value FROM resource_pools - WHERE resource_type = 'phone' AND status = 'available' - LIMIT %s FOR UPDATE - """, (phone_count,)) - phones = cur.fetchall() - - if len(phones) < phone_count: - raise ValueError(f"Nicht genügend Telefonnummern verfügbar") - - for phone_id, phone_value in phones: - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', - allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, - status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], phone_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, phone_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (phone_id, license_id, session['username'], get_client_ip())) - - allocated['phones'].append(phone_value) - - # Update license resource counts - cur.execute(""" - UPDATE licenses - SET domain_count = %s, - ipv4_count = %s, - phone_count = %s - WHERE id = %s - """, (domain_count, ipv4_count, phone_count, license_id)) - - conn.commit() - cur.close() - conn.close() - - return jsonify({ - 'success': True, - 'allocated': allocated - }) - - except Exception as e: - conn.rollback() - cur.close() - conn.close() - return jsonify({ - 'success': False, - 'error': str(e) - }), 400 - -@app.route('/api/resources/check-availability', methods=['GET']) -@login_required -def check_resource_availability(): - """Prüft verfügbare Ressourcen""" - resource_type = request.args.get('type', '') - count = request.args.get('count', 10, type=int) - show_test = request.args.get('show_test', 'false').lower() == 'true' - - conn = get_connection() - cur = conn.cursor() - - if resource_type: - # Spezifische Ressourcen für einen Typ - cur.execute(""" - SELECT id, resource_value - FROM resource_pools - WHERE status = 'available' - AND resource_type = %s - AND is_test = %s - ORDER BY resource_value - LIMIT %s - """, (resource_type, show_test, count)) - - resources = [] - for row in cur.fetchall(): - resources.append({ - 'id': row[0], - 'value': row[1] - }) - - cur.close() - conn.close() - - return jsonify({ - 'available': resources, - 'type': resource_type, - 'count': len(resources) - }) - else: - # Zusammenfassung aller Typen - cur.execute(""" - SELECT - resource_type, - COUNT(*) as available - FROM resource_pools - WHERE status = 'available' - AND is_test = %s - GROUP BY resource_type - """, (show_test,)) - - availability = {} - for row in cur.fetchall(): - availability[row[0]] = row[1] - - cur.close() - conn.close() - - return jsonify(availability) - -@app.route('/api/global-search', methods=['GET']) -@login_required -def global_search(): - """Global search API endpoint for searching customers and licenses""" - query = request.args.get('q', '').strip() - - if not query or len(query) < 2: - return jsonify({'customers': [], 'licenses': []}) - - conn = get_connection() - cur = conn.cursor() - - # Search pattern with wildcards - search_pattern = f'%{query}%' - - # Search customers - cur.execute(""" - SELECT id, name, email, company_name - FROM customers - WHERE (LOWER(name) LIKE LOWER(%s) - OR LOWER(email) LIKE LOWER(%s) - OR LOWER(company_name) LIKE LOWER(%s)) - AND is_test = FALSE - ORDER BY name - LIMIT 5 - """, (search_pattern, search_pattern, search_pattern)) - - customers = [] - for row in cur.fetchall(): - customers.append({ - 'id': row[0], - 'name': row[1], - 'email': row[2], - 'company_name': row[3] - }) - - # Search licenses - cur.execute(""" - SELECT l.id, l.license_key, c.name as customer_name - FROM licenses l - JOIN customers c ON l.customer_id = c.id - WHERE LOWER(l.license_key) LIKE LOWER(%s) - AND l.is_test = FALSE - ORDER BY l.created_at DESC - LIMIT 5 - """, (search_pattern,)) - - licenses = [] - for row in cur.fetchall(): - licenses.append({ - 'id': row[0], - 'license_key': row[1], - 'customer_name': row[2] - }) - - cur.close() - conn.close() - - return jsonify({ - 'customers': customers, - 'licenses': licenses - }) - -@app.route('/resources/history/') -@login_required -def resource_history(resource_id): - """Zeigt die komplette Historie einer Ressource""" - conn = get_connection() - cur = conn.cursor() - - # Get complete resource info using named columns - cur.execute(""" - SELECT id, resource_type, resource_value, status, allocated_to_license, - status_changed_at, status_changed_by, quarantine_reason, - quarantine_until, created_at, notes - FROM resource_pools - WHERE id = %s - """, (resource_id,)) - row = cur.fetchone() - - if not row: - flash('Ressource nicht gefunden', 'error') - return redirect(url_for('resources')) - - # Create resource object with named attributes - resource = { - 'id': row[0], - 'resource_type': row[1], - 'resource_value': row[2], - 'status': row[3], - 'allocated_to_license': row[4], - 'status_changed_at': row[5], - 'status_changed_by': row[6], - 'quarantine_reason': row[7], - 'quarantine_until': row[8], - 'created_at': row[9], - 'notes': row[10] - } - - # Get license info if allocated - license_info = None - if resource['allocated_to_license']: - cur.execute("SELECT license_key FROM licenses WHERE id = %s", - (resource['allocated_to_license'],)) - lic = cur.fetchone() - if lic: - license_info = {'license_key': lic[0]} - - # Get history with named columns - cur.execute(""" - SELECT - rh.action, - rh.action_by, - rh.action_at, - rh.details, - rh.license_id, - rh.ip_address - FROM resource_history rh - WHERE rh.resource_id = %s - ORDER BY rh.action_at DESC - """, (resource_id,)) - - history = [] - for row in cur.fetchall(): - history.append({ - 'action': row[0], - 'action_by': row[1], - 'action_at': row[2], - 'details': row[3], - 'license_id': row[4], - 'ip_address': row[5] - }) - - cur.close() - conn.close() - - # Convert to object-like for template - class ResourceObj: - def __init__(self, data): - for key, value in data.items(): - setattr(self, key, value) - - resource_obj = ResourceObj(resource) - history_objs = [ResourceObj(h) for h in history] - - return render_template('resource_history.html', - resource=resource_obj, - license_info=license_info, - history=history_objs) - -@app.route('/resources/metrics') -@login_required -def resources_metrics(): - """Dashboard für Resource Metrics und Reports""" - conn = get_connection() - cur = conn.cursor() - - # Overall stats with fallback values - cur.execute(""" - SELECT - COUNT(DISTINCT resource_id) as total_resources, - COALESCE(AVG(performance_score), 0) as avg_performance, - COALESCE(SUM(cost), 0) as total_cost, - COALESCE(SUM(revenue), 0) as total_revenue, - COALESCE(SUM(issues_count), 0) as total_issues - FROM resource_metrics - WHERE metric_date >= CURRENT_DATE - INTERVAL '30 days' - """) - row = cur.fetchone() - - # Calculate ROI - roi = 0 - if row[2] > 0: # if total_cost > 0 - roi = row[3] / row[2] # revenue / cost - - stats = { - 'total_resources': row[0] or 0, - 'avg_performance': row[1] or 0, - 'total_cost': row[2] or 0, - 'total_revenue': row[3] or 0, - 'total_issues': row[4] or 0, - 'roi': roi - } - - # Performance by type - cur.execute(""" - SELECT - rp.resource_type, - COALESCE(AVG(rm.performance_score), 0) as avg_score, - COUNT(DISTINCT rp.id) as resource_count - FROM resource_pools rp - LEFT JOIN resource_metrics rm ON rp.id = rm.resource_id - AND rm.metric_date >= CURRENT_DATE - INTERVAL '30 days' - GROUP BY rp.resource_type - ORDER BY rp.resource_type - """) - performance_by_type = cur.fetchall() - - # Utilization data - cur.execute(""" - SELECT - resource_type, - COUNT(*) FILTER (WHERE status = 'allocated') as allocated, - COUNT(*) as total, - ROUND(COUNT(*) FILTER (WHERE status = 'allocated') * 100.0 / COUNT(*), 1) as allocated_percent - FROM resource_pools - GROUP BY resource_type - """) - utilization_rows = cur.fetchall() - utilization_data = [ - { - 'type': row[0].upper(), - 'allocated': row[1], - 'total': row[2], - 'allocated_percent': row[3] - } - for row in utilization_rows - ] - - # Top performing resources - cur.execute(""" - SELECT - rp.id, - rp.resource_type, - rp.resource_value, - COALESCE(AVG(rm.performance_score), 0) as avg_score, - COALESCE(SUM(rm.revenue), 0) as total_revenue, - COALESCE(SUM(rm.cost), 1) as total_cost, - CASE - WHEN COALESCE(SUM(rm.cost), 0) = 0 THEN 0 - ELSE COALESCE(SUM(rm.revenue), 0) / COALESCE(SUM(rm.cost), 1) - END as roi - FROM resource_pools rp - LEFT JOIN resource_metrics rm ON rp.id = rm.resource_id - AND rm.metric_date >= CURRENT_DATE - INTERVAL '30 days' - WHERE rp.status != 'quarantine' - GROUP BY rp.id, rp.resource_type, rp.resource_value - HAVING AVG(rm.performance_score) IS NOT NULL - ORDER BY avg_score DESC - LIMIT 10 - """) - top_rows = cur.fetchall() - top_performers = [ - { - 'id': row[0], - 'resource_type': row[1], - 'resource_value': row[2], - 'avg_score': row[3], - 'roi': row[6] - } - for row in top_rows - ] - - # Resources with issues - cur.execute(""" - SELECT - rp.id, - rp.resource_type, - rp.resource_value, - rp.status, - COALESCE(SUM(rm.issues_count), 0) as total_issues - FROM resource_pools rp - LEFT JOIN resource_metrics rm ON rp.id = rm.resource_id - AND rm.metric_date >= CURRENT_DATE - INTERVAL '30 days' - WHERE rm.issues_count > 0 OR rp.status = 'quarantine' - GROUP BY rp.id, rp.resource_type, rp.resource_value, rp.status - HAVING SUM(rm.issues_count) > 0 - ORDER BY total_issues DESC - LIMIT 10 - """) - problem_rows = cur.fetchall() - problem_resources = [ - { - 'id': row[0], - 'resource_type': row[1], - 'resource_value': row[2], - 'status': row[3], - 'total_issues': row[4] - } - for row in problem_rows - ] - - # Daily metrics for trend chart (last 30 days) - cur.execute(""" - SELECT - metric_date, - COALESCE(AVG(performance_score), 0) as avg_performance, - COALESCE(SUM(issues_count), 0) as total_issues - FROM resource_metrics - WHERE metric_date >= CURRENT_DATE - INTERVAL '30 days' - GROUP BY metric_date - ORDER BY metric_date - """) - daily_rows = cur.fetchall() - daily_metrics = [ - { - 'date': row[0].strftime('%d.%m'), - 'performance': float(row[1]), - 'issues': int(row[2]) - } - for row in daily_rows - ] - - cur.close() - conn.close() - - return render_template('resource_metrics.html', - stats=stats, - performance_by_type=performance_by_type, - utilization_data=utilization_data, - top_performers=top_performers, - problem_resources=problem_resources, - daily_metrics=daily_metrics) - -@app.route('/resources/report', methods=['GET']) -@login_required -def resources_report(): - """Generiert Ressourcen-Reports oder zeigt Report-Formular""" - # Prüfe ob Download angefordert wurde - if request.args.get('download') == 'true': - report_type = request.args.get('type', 'usage') - format_type = request.args.get('format', 'excel') - date_from = request.args.get('from', (datetime.now(ZoneInfo("Europe/Berlin")) - timedelta(days=30)).strftime('%Y-%m-%d')) - date_to = request.args.get('to', datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y-%m-%d')) - - conn = get_connection() - cur = conn.cursor() - - if report_type == 'usage': - # Auslastungsreport - query = """ - SELECT - rp.resource_type, - rp.resource_value, - rp.status, - COUNT(DISTINCT rh.license_id) as unique_licenses, - COUNT(rh.id) as total_allocations, - MIN(rh.action_at) as first_used, - MAX(rh.action_at) as last_used - FROM resource_pools rp - LEFT JOIN resource_history rh ON rp.id = rh.resource_id - AND rh.action = 'allocated' - AND rh.action_at BETWEEN %s AND %s - GROUP BY rp.id, rp.resource_type, rp.resource_value, rp.status - ORDER BY rp.resource_type, total_allocations DESC - """ - cur.execute(query, (date_from, date_to)) - columns = ['Typ', 'Ressource', 'Status', 'Unique Lizenzen', 'Gesamt Zuweisungen', 'Erste Nutzung', 'Letzte Nutzung'] - - elif report_type == 'performance': - # Performance-Report - query = """ - SELECT - rp.resource_type, - rp.resource_value, - AVG(rm.performance_score) as avg_performance, - SUM(rm.usage_count) as total_usage, - SUM(rm.revenue) as total_revenue, - SUM(rm.cost) as total_cost, - SUM(rm.revenue - rm.cost) as profit, - SUM(rm.issues_count) as total_issues - FROM resource_pools rp - JOIN resource_metrics rm ON rp.id = rm.resource_id - WHERE rm.metric_date BETWEEN %s AND %s - GROUP BY rp.id, rp.resource_type, rp.resource_value - ORDER BY profit DESC - """ - cur.execute(query, (date_from, date_to)) - columns = ['Typ', 'Ressource', 'Durchschn. Performance', 'Gesamt Nutzung', 'Umsatz', 'Kosten', 'Gewinn', 'Issues'] - - elif report_type == 'compliance': - # Compliance-Report - query = """ - SELECT - rh.action_at, - rh.action, - rh.action_by, - rp.resource_type, - rp.resource_value, - l.license_key, - c.name as customer_name, - rh.ip_address - FROM resource_history rh - JOIN resource_pools rp ON rh.resource_id = rp.id - LEFT JOIN licenses l ON rh.license_id = l.id - LEFT JOIN customers c ON l.customer_id = c.id - WHERE rh.action_at BETWEEN %s AND %s - ORDER BY rh.action_at DESC - """ - cur.execute(query, (date_from, date_to)) - columns = ['Zeit', 'Aktion', 'Von', 'Typ', 'Ressource', 'Lizenz', 'Kunde', 'IP-Adresse'] - - else: # inventory report - # Inventar-Report - query = """ - SELECT - resource_type, - COUNT(*) FILTER (WHERE status = 'available') as available, - COUNT(*) FILTER (WHERE status = 'allocated') as allocated, - COUNT(*) FILTER (WHERE status = 'quarantine') as quarantine, - COUNT(*) as total - FROM resource_pools - GROUP BY resource_type - ORDER BY resource_type - """ - cur.execute(query) - columns = ['Typ', 'Verfügbar', 'Zugeteilt', 'Quarantäne', 'Gesamt'] - - # Convert to DataFrame - data = cur.fetchall() - df = pd.DataFrame(data, columns=columns) - - cur.close() - conn.close() - - # Generate file - timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') - filename = f"resource_report_{report_type}_{timestamp}" - - if format_type == 'excel': - output = io.BytesIO() - with pd.ExcelWriter(output, engine='openpyxl') as writer: - df.to_excel(writer, sheet_name='Report', index=False) - - # Auto-adjust columns width - worksheet = writer.sheets['Report'] - for column in worksheet.columns: - max_length = 0 - column = [cell for cell in column] - for cell in column: - try: - if len(str(cell.value)) > max_length: - max_length = len(str(cell.value)) - except: - pass - adjusted_width = (max_length + 2) - worksheet.column_dimensions[column[0].column_letter].width = adjusted_width - - output.seek(0) - - log_audit('EXPORT', 'resource_report', None, - new_values={'type': report_type, 'format': 'excel', 'rows': len(df)}, - additional_info=f"Resource Report {report_type} exportiert") - - return send_file(output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=f'{filename}.xlsx') - - else: # CSV - output = io.StringIO() - df.to_csv(output, index=False, sep=';', encoding='utf-8-sig') - output.seek(0) - - log_audit('EXPORT', 'resource_report', None, - new_values={'type': report_type, 'format': 'csv', 'rows': len(df)}, - additional_info=f"Resource Report {report_type} exportiert") - - return send_file(io.BytesIO(output.getvalue().encode('utf-8-sig')), - mimetype='text/csv', - as_attachment=True, - download_name=f'{filename}.csv') - - # Wenn kein Download, zeige Report-Formular - return render_template('resource_report.html', - datetime=datetime, - timedelta=timedelta, - username=session.get('username')) - -if __name__ == "__main__": - app.run(host="0.0.0.0", port=5000) diff --git a/v2_adminpanel/app_new.py b/v2_adminpanel/app_new.py deleted file mode 100644 index c391073..0000000 --- a/v2_adminpanel/app_new.py +++ /dev/null @@ -1,124 +0,0 @@ -import os -import time -import json -import logging -import requests -from io import BytesIO -from datetime import datetime, timedelta -from zoneinfo import ZoneInfo -from pathlib import Path - -from flask import Flask, render_template, request, redirect, session, url_for, send_file, jsonify, flash -from flask_session import Session -from werkzeug.middleware.proxy_fix import ProxyFix -from apscheduler.schedulers.background import BackgroundScheduler -import pandas as pd -from psycopg2.extras import Json - -# Import our new modules -import config -from db import get_connection, get_db_connection, get_db_cursor, execute_query -from auth.decorators import login_required -from auth.password import hash_password, verify_password -from auth.two_factor import ( - generate_totp_secret, generate_qr_code, verify_totp, - generate_backup_codes, hash_backup_code, verify_backup_code -) -from auth.rate_limiting import ( - get_client_ip, check_ip_blocked, record_failed_attempt, - reset_login_attempts, get_login_attempts -) -from utils.audit import log_audit -from utils.license import generate_license_key, validate_license_key -from utils.backup import create_backup, restore_backup, get_or_create_encryption_key -from utils.export import ( - create_excel_export, format_datetime_for_export, - prepare_license_export_data, prepare_customer_export_data, - prepare_session_export_data, prepare_audit_export_data -) -from models import get_user_by_username - -app = Flask(__name__) -# Load configuration from config module -app.config['SECRET_KEY'] = config.SECRET_KEY -app.config['SESSION_TYPE'] = config.SESSION_TYPE -app.config['JSON_AS_ASCII'] = config.JSON_AS_ASCII -app.config['JSONIFY_MIMETYPE'] = config.JSONIFY_MIMETYPE -app.config['PERMANENT_SESSION_LIFETIME'] = config.PERMANENT_SESSION_LIFETIME -app.config['SESSION_COOKIE_HTTPONLY'] = config.SESSION_COOKIE_HTTPONLY -app.config['SESSION_COOKIE_SECURE'] = config.SESSION_COOKIE_SECURE -app.config['SESSION_COOKIE_SAMESITE'] = config.SESSION_COOKIE_SAMESITE -app.config['SESSION_COOKIE_NAME'] = config.SESSION_COOKIE_NAME -app.config['SESSION_REFRESH_EACH_REQUEST'] = config.SESSION_REFRESH_EACH_REQUEST -Session(app) - -# ProxyFix für korrekte IP-Adressen hinter Nginx -app.wsgi_app = ProxyFix( - app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1 -) - -# Configuration is now loaded from config module - -# Scheduler für automatische Backups -scheduler = BackgroundScheduler() -scheduler.start() - -# Logging konfigurieren -logging.basicConfig(level=logging.INFO) - - -# Scheduled Backup Job -def scheduled_backup(): - """Führt ein geplantes Backup aus""" - logging.info("Starte geplantes Backup...") - create_backup(backup_type="scheduled", created_by="scheduler") - -# Scheduler konfigurieren - täglich um 3:00 Uhr -scheduler.add_job( - scheduled_backup, - 'cron', - hour=config.SCHEDULER_CONFIG['backup_hour'], - minute=config.SCHEDULER_CONFIG['backup_minute'], - id='daily_backup', - replace_existing=True -) - - -def verify_recaptcha(response): - """Verifiziert die reCAPTCHA v2 Response mit Google""" - secret_key = config.RECAPTCHA_SECRET_KEY - - # Wenn kein Secret Key konfiguriert ist, CAPTCHA als bestanden werten (für PoC) - if not secret_key: - logging.warning("RECAPTCHA_SECRET_KEY nicht konfiguriert - CAPTCHA wird übersprungen") - return True - - # Verifizierung bei Google - try: - verify_url = 'https://www.google.com/recaptcha/api/siteverify' - data = { - 'secret': secret_key, - 'response': response - } - - # Timeout für Request setzen - r = requests.post(verify_url, data=data, timeout=5) - result = r.json() - - # Log für Debugging - if not result.get('success'): - logging.warning(f"reCAPTCHA Validierung fehlgeschlagen: {result.get('error-codes', [])}") - - return result.get('success', False) - - except requests.exceptions.RequestException as e: - logging.error(f"reCAPTCHA Verifizierung fehlgeschlagen: {str(e)}") - # Bei Netzwerkfehlern CAPTCHA als bestanden werten - return True - except Exception as e: - logging.error(f"Unerwarteter Fehler bei reCAPTCHA: {str(e)}") - return False - - -# Now copy all the route handlers from the original file -# Starting from line 693... \ No newline at end of file diff --git a/v2_adminpanel/app_with_duplicates.py b/v2_adminpanel/app_with_duplicates.py deleted file mode 100644 index 5b203fe..0000000 --- a/v2_adminpanel/app_with_duplicates.py +++ /dev/null @@ -1,4462 +0,0 @@ -import os -import sys -import time -import json -import logging -import requests -import re -import random -import base64 -from io import BytesIO -from datetime import datetime, timedelta -from zoneinfo import ZoneInfo -from pathlib import Path - -# Add current directory to Python path to ensure modules can be imported -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) - -from flask import Flask, render_template, request, redirect, session, url_for, send_file, jsonify, flash -from flask_session import Session -from werkzeug.middleware.proxy_fix import ProxyFix -from apscheduler.schedulers.background import BackgroundScheduler -import pandas as pd -from psycopg2.extras import Json - -# Import our new modules -import config -from db import get_connection, get_db_connection, get_db_cursor, execute_query -from auth.decorators import login_required -from auth.password import hash_password, verify_password -from auth.two_factor import ( - generate_totp_secret, generate_qr_code, verify_totp, - generate_backup_codes, hash_backup_code, verify_backup_code -) -from auth.rate_limiting import ( - check_ip_blocked, record_failed_attempt, - reset_login_attempts, get_login_attempts -) -from utils.network import get_client_ip -from utils.audit import log_audit -from utils.license import generate_license_key, validate_license_key -from utils.backup import create_backup, restore_backup, get_or_create_encryption_key -from utils.export import ( - create_excel_export, format_datetime_for_export, - prepare_license_export_data, prepare_customer_export_data, - prepare_session_export_data, prepare_audit_export_data -) -from models import get_user_by_username - -app = Flask(__name__) -# Load configuration from config module -app.config['SECRET_KEY'] = config.SECRET_KEY -app.config['SESSION_TYPE'] = config.SESSION_TYPE -app.config['JSON_AS_ASCII'] = config.JSON_AS_ASCII -app.config['JSONIFY_MIMETYPE'] = config.JSONIFY_MIMETYPE -app.config['PERMANENT_SESSION_LIFETIME'] = config.PERMANENT_SESSION_LIFETIME -app.config['SESSION_COOKIE_HTTPONLY'] = config.SESSION_COOKIE_HTTPONLY -app.config['SESSION_COOKIE_SECURE'] = config.SESSION_COOKIE_SECURE -app.config['SESSION_COOKIE_SAMESITE'] = config.SESSION_COOKIE_SAMESITE -app.config['SESSION_COOKIE_NAME'] = config.SESSION_COOKIE_NAME -app.config['SESSION_REFRESH_EACH_REQUEST'] = config.SESSION_REFRESH_EACH_REQUEST -Session(app) - -# ProxyFix für korrekte IP-Adressen hinter Nginx -app.wsgi_app = ProxyFix( - app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1 -) - -# Configuration is now loaded from config module - -# Scheduler für automatische Backups -scheduler = BackgroundScheduler() -scheduler.start() - -# Logging konfigurieren -logging.basicConfig(level=logging.INFO) - -# Import and register blueprints -from routes.auth_routes import auth_bp -from routes.admin_routes import admin_bp - -app.register_blueprint(auth_bp) -app.register_blueprint(admin_bp) - - -# Scheduled Backup Job -def scheduled_backup(): - """Führt ein geplantes Backup aus""" - logging.info("Starte geplantes Backup...") - create_backup(backup_type="scheduled", created_by="scheduler") - -# Scheduler konfigurieren - täglich um 3:00 Uhr -scheduler.add_job( - scheduled_backup, - 'cron', - hour=config.SCHEDULER_CONFIG['backup_hour'], - minute=config.SCHEDULER_CONFIG['backup_minute'], - id='daily_backup', - replace_existing=True -) - - -def verify_recaptcha(response): - """Verifiziert die reCAPTCHA v2 Response mit Google""" - secret_key = config.RECAPTCHA_SECRET_KEY - - # Wenn kein Secret Key konfiguriert ist, CAPTCHA als bestanden werten (für PoC) - if not secret_key: - logging.warning("RECAPTCHA_SECRET_KEY nicht konfiguriert - CAPTCHA wird übersprungen") - return True - - # Verifizierung bei Google - try: - verify_url = 'https://www.google.com/recaptcha/api/siteverify' - data = { - 'secret': secret_key, - 'response': response - } - - # Timeout für Request setzen - r = requests.post(verify_url, data=data, timeout=5) - result = r.json() - - # Log für Debugging - if not result.get('success'): - logging.warning(f"reCAPTCHA Validierung fehlgeschlagen: {result.get('error-codes', [])}") - - return result.get('success', False) - - except requests.exceptions.RequestException as e: - logging.error(f"reCAPTCHA Verifizierung fehlgeschlagen: {str(e)}") - # Bei Netzwerkfehlern CAPTCHA als bestanden werten - return True - except Exception as e: - logging.error(f"Unerwarteter Fehler bei reCAPTCHA: {str(e)}") - return False - - -# MOVED TO AUTH BLUEPRINT -# @app.route("/login", methods=["GET", "POST"]) -# def login(): -# # Timing-Attack Schutz - Start Zeit merken -# start_time = time.time() - - # IP-Adresse ermitteln - ip_address = get_client_ip() - - # Prüfen ob IP gesperrt ist - is_blocked, blocked_until = check_ip_blocked(ip_address) - if is_blocked: - time_remaining = (blocked_until - datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None)).total_seconds() / 3600 - error_msg = f"IP GESPERRT! Noch {time_remaining:.1f} Stunden warten." - return render_template("login.html", error=error_msg, error_type="blocked") - - # Anzahl bisheriger Versuche - attempt_count = get_login_attempts(ip_address) - - if request.method == "POST": - username = request.form.get("username") - password = request.form.get("password") - captcha_response = request.form.get("g-recaptcha-response") - - # CAPTCHA-Prüfung nur wenn Keys konfiguriert sind - recaptcha_site_key = config.RECAPTCHA_SITE_KEY - if attempt_count >= config.CAPTCHA_AFTER_ATTEMPTS and recaptcha_site_key: - if not captcha_response: - # Timing-Attack Schutz - elapsed = time.time() - start_time - if elapsed < 1.0: - time.sleep(1.0 - elapsed) - return render_template("login.html", - error="CAPTCHA ERFORDERLICH!", - show_captcha=True, - error_type="captcha", - attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - attempt_count), - recaptcha_site_key=recaptcha_site_key) - - # CAPTCHA validieren - if not verify_recaptcha(captcha_response): - # Timing-Attack Schutz - elapsed = time.time() - start_time - if elapsed < 1.0: - time.sleep(1.0 - elapsed) - return render_template("login.html", - error="CAPTCHA UNGÜLTIG! Bitte erneut versuchen.", - show_captcha=True, - error_type="captcha", - attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - attempt_count), - recaptcha_site_key=recaptcha_site_key) - - # Check user in database first, fallback to env vars - user = get_user_by_username(username) - login_success = False - needs_2fa = False - - if user: - # Database user authentication - if verify_password(password, user['password_hash']): - login_success = True - needs_2fa = user['totp_enabled'] - else: - # Fallback to environment variables for backward compatibility - if username in config.ADMIN_USERS and password == config.ADMIN_USERS[username]: - login_success = True - - # Timing-Attack Schutz - Mindestens 1 Sekunde warten - elapsed = time.time() - start_time - if elapsed < 1.0: - time.sleep(1.0 - elapsed) - - if login_success: - # Erfolgreicher Login - if needs_2fa: - # Store temporary session for 2FA verification - session['temp_username'] = username - session['temp_user_id'] = user['id'] - session['awaiting_2fa'] = True - return redirect(url_for('verify_2fa')) - else: - # Complete login without 2FA - session.permanent = True # Aktiviert das Timeout - session['logged_in'] = True - session['username'] = username - session['user_id'] = user['id'] if user else None - session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() - reset_login_attempts(ip_address) - log_audit('LOGIN_SUCCESS', 'user', - additional_info=f"Erfolgreiche Anmeldung von IP: {ip_address}") - return redirect(url_for('dashboard')) - else: - # Fehlgeschlagener Login - error_message = record_failed_attempt(ip_address, username) - new_attempt_count = get_login_attempts(ip_address) - - # Prüfen ob jetzt gesperrt - is_now_blocked, _ = check_ip_blocked(ip_address) - if is_now_blocked: - log_audit('LOGIN_BLOCKED', 'security', - additional_info=f"IP {ip_address} wurde nach {config.MAX_LOGIN_ATTEMPTS} Versuchen gesperrt") - - return render_template("login.html", - error=error_message, - show_captcha=(new_attempt_count >= config.CAPTCHA_AFTER_ATTEMPTS and config.RECAPTCHA_SITE_KEY), - error_type="failed", - attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - new_attempt_count), - recaptcha_site_key=config.RECAPTCHA_SITE_KEY) - - # GET Request - return render_template("login.html", - show_captcha=(attempt_count >= config.CAPTCHA_AFTER_ATTEMPTS and config.RECAPTCHA_SITE_KEY), - attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - attempt_count), - recaptcha_site_key=config.RECAPTCHA_SITE_KEY) - -@app.route("/logout") -def logout(): - username = session.get('username', 'unknown') - log_audit('LOGOUT', 'user', additional_info=f"Abmeldung") - session.pop('logged_in', None) - session.pop('username', None) - session.pop('user_id', None) - session.pop('temp_username', None) - session.pop('temp_user_id', None) - session.pop('awaiting_2fa', None) - return redirect(url_for('login')) - -# MOVED TO AUTH BLUEPRINT -# @app.route("/verify-2fa", methods=["GET", "POST"]) -# def verify_2fa(): -# if not session.get('awaiting_2fa'): -# return redirect(url_for('login')) - - if request.method == "POST": - token = request.form.get('token', '').replace(' ', '') - username = session.get('temp_username') - user_id = session.get('temp_user_id') - - if not username or not user_id: - flash('Session expired. Please login again.', 'error') - return redirect(url_for('login')) - - user = get_user_by_username(username) - if not user: - flash('User not found.', 'error') - return redirect(url_for('login')) - - # Check if it's a backup code - if len(token) == 8 and token.isupper(): - # Try backup code - backup_codes = json.loads(user['backup_codes']) if user['backup_codes'] else [] - if verify_backup_code(token, backup_codes): - # Remove used backup code - code_hash = hash_backup_code(token) - backup_codes.remove(code_hash) - - conn = get_connection() - cur = conn.cursor() - cur.execute("UPDATE users SET backup_codes = %s WHERE id = %s", - (json.dumps(backup_codes), user_id)) - conn.commit() - cur.close() - conn.close() - - # Complete login - session.permanent = True - session['logged_in'] = True - session['username'] = username - session['user_id'] = user_id - session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() - session.pop('temp_username', None) - session.pop('temp_user_id', None) - session.pop('awaiting_2fa', None) - - flash('Login successful using backup code. Please generate new backup codes.', 'warning') - log_audit('LOGIN_2FA_BACKUP', 'user', additional_info=f"2FA login with backup code") - return redirect(url_for('dashboard')) - else: - # Try TOTP token - if verify_totp(user['totp_secret'], token): - # Complete login - session.permanent = True - session['logged_in'] = True - session['username'] = username - session['user_id'] = user_id - session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() - session.pop('temp_username', None) - session.pop('temp_user_id', None) - session.pop('awaiting_2fa', None) - - log_audit('LOGIN_2FA_SUCCESS', 'user', additional_info=f"2FA login successful") - return redirect(url_for('dashboard')) - - # Failed verification - conn = get_connection() - cur = conn.cursor() - cur.execute("UPDATE users SET failed_2fa_attempts = failed_2fa_attempts + 1, last_failed_2fa = %s WHERE id = %s", - (datetime.now(), user_id)) - conn.commit() - cur.close() - conn.close() - - flash('Invalid authentication code. Please try again.', 'error') - log_audit('LOGIN_2FA_FAILED', 'user', additional_info=f"Failed 2FA attempt") - - return render_template('verify_2fa.html') - -@app.route("/profile") -@login_required -def profile(): - user = get_user_by_username(session['username']) - if not user: - # For environment-based users, redirect with message - flash('Bitte führen Sie das Migrations-Script aus, um Passwort-Änderung und 2FA zu aktivieren.', 'info') - return redirect(url_for('dashboard')) - return render_template('profile.html', user=user) - -@app.route("/profile/change-password", methods=["POST"]) -@login_required -def change_password(): - current_password = request.form.get('current_password') - new_password = request.form.get('new_password') - confirm_password = request.form.get('confirm_password') - - user = get_user_by_username(session['username']) - - # Verify current password - if not verify_password(current_password, user['password_hash']): - flash('Current password is incorrect.', 'error') - return redirect(url_for('profile')) - - # Check new password - if new_password != confirm_password: - flash('New passwords do not match.', 'error') - return redirect(url_for('profile')) - - if len(new_password) < 8: - flash('Password must be at least 8 characters long.', 'error') - return redirect(url_for('profile')) - - # Update password - new_hash = hash_password(new_password) - conn = get_connection() - cur = conn.cursor() - cur.execute("UPDATE users SET password_hash = %s, last_password_change = %s WHERE id = %s", - (new_hash, datetime.now(), user['id'])) - conn.commit() - cur.close() - conn.close() - - log_audit('PASSWORD_CHANGE', 'user', entity_id=user['id'], - additional_info="Password changed successfully") - flash('Password changed successfully.', 'success') - return redirect(url_for('profile')) - -@app.route("/profile/setup-2fa") -@login_required -def setup_2fa(): - user = get_user_by_username(session['username']) - - if user['totp_enabled']: - flash('2FA is already enabled for your account.', 'info') - return redirect(url_for('profile')) - - # Generate new TOTP secret - totp_secret = generate_totp_secret() - session['temp_totp_secret'] = totp_secret - - # Generate QR code - qr_code = generate_qr_code(user['username'], totp_secret) - - return render_template('setup_2fa.html', - totp_secret=totp_secret, - qr_code=qr_code) - -@app.route("/profile/enable-2fa", methods=["POST"]) -@login_required -def enable_2fa(): - token = request.form.get('token', '').replace(' ', '') - totp_secret = session.get('temp_totp_secret') - - if not totp_secret: - flash('2FA setup session expired. Please try again.', 'error') - return redirect(url_for('setup_2fa')) - - # Verify the token - if not verify_totp(totp_secret, token): - flash('Invalid authentication code. Please try again.', 'error') - return redirect(url_for('setup_2fa')) - - # Generate backup codes - backup_codes = generate_backup_codes() - hashed_codes = [hash_backup_code(code) for code in backup_codes] - - # Enable 2FA - conn = get_connection() - cur = conn.cursor() - cur.execute(""" - UPDATE users - SET totp_secret = %s, totp_enabled = TRUE, backup_codes = %s - WHERE username = %s - """, (totp_secret, json.dumps(hashed_codes), session['username'])) - conn.commit() - cur.close() - conn.close() - - session.pop('temp_totp_secret', None) - - log_audit('2FA_ENABLED', 'user', additional_info="2FA enabled successfully") - - # Show backup codes - return render_template('backup_codes.html', backup_codes=backup_codes) - -@app.route("/profile/disable-2fa", methods=["POST"]) -@login_required -def disable_2fa(): - password = request.form.get('password') - user = get_user_by_username(session['username']) - - # Verify password - if not verify_password(password, user['password_hash']): - flash('Incorrect password.', 'error') - return redirect(url_for('profile')) - - # Disable 2FA - conn = get_connection() - cur = conn.cursor() - cur.execute(""" - UPDATE users - SET totp_enabled = FALSE, totp_secret = NULL, backup_codes = NULL - WHERE username = %s - """, (session['username'],)) - conn.commit() - cur.close() - conn.close() - - log_audit('2FA_DISABLED', 'user', additional_info="2FA disabled") - flash('2FA has been disabled for your account.', 'success') - return redirect(url_for('profile')) - -@app.route("/heartbeat", methods=['POST']) -@login_required -def heartbeat(): - """Endpoint für Session Keep-Alive - aktualisiert last_activity""" - # Aktualisiere last_activity nur wenn explizit angefordert - session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() - # Force session save - session.modified = True - - return jsonify({ - 'status': 'ok', - 'last_activity': session['last_activity'], - 'username': session.get('username') - }) - -@app.route("/api/generate-license-key", methods=['POST']) -@login_required -def api_generate_key(): - """API Endpoint zur Generierung eines neuen Lizenzschlüssels""" - try: - # Lizenztyp aus Request holen (default: full) - data = request.get_json() or {} - license_type = data.get('type', 'full') - - # Key generieren - key = generate_license_key(license_type) - - # Prüfen ob Key bereits existiert (sehr unwahrscheinlich aber sicher ist sicher) - conn = get_connection() - cur = conn.cursor() - - # Wiederhole bis eindeutiger Key gefunden - attempts = 0 - while attempts < 10: # Max 10 Versuche - cur.execute("SELECT 1 FROM licenses WHERE license_key = %s", (key,)) - if not cur.fetchone(): - break # Key ist eindeutig - key = generate_license_key(license_type) - attempts += 1 - - cur.close() - conn.close() - - # Log für Audit - log_audit('GENERATE_KEY', 'license', - additional_info={'type': license_type, 'key': key}) - - return jsonify({ - 'success': True, - 'key': key, - 'type': license_type - }) - - except Exception as e: - logging.error(f"Fehler bei Key-Generierung: {str(e)}") - return jsonify({ - 'success': False, - 'error': 'Fehler bei der Key-Generierung' - }), 500 - -@app.route("/api/customers", methods=['GET']) -@login_required -def api_customers(): - """API Endpoint für die Kundensuche mit Select2""" - try: - # Suchparameter - search = request.args.get('q', '').strip() - page = request.args.get('page', 1, type=int) - per_page = 20 - customer_id = request.args.get('id', type=int) - - conn = get_connection() - cur = conn.cursor() - - # Einzelnen Kunden per ID abrufen - if customer_id: - cur.execute(""" - SELECT c.id, c.name, c.email, - COUNT(l.id) as license_count - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - WHERE c.id = %s - GROUP BY c.id, c.name, c.email - """, (customer_id,)) - - customer = cur.fetchone() - results = [] - if customer: - results.append({ - 'id': customer[0], - 'text': f"{customer[1]} ({customer[2]})", - 'name': customer[1], - 'email': customer[2], - 'license_count': customer[3] - }) - - cur.close() - conn.close() - - return jsonify({ - 'results': results, - 'pagination': {'more': False} - }) - - # SQL Query mit optionaler Suche - elif search: - cur.execute(""" - SELECT c.id, c.name, c.email, - COUNT(l.id) as license_count - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - WHERE LOWER(c.name) LIKE LOWER(%s) - OR LOWER(c.email) LIKE LOWER(%s) - GROUP BY c.id, c.name, c.email - ORDER BY c.name - LIMIT %s OFFSET %s - """, (f'%{search}%', f'%{search}%', per_page, (page - 1) * per_page)) - else: - cur.execute(""" - SELECT c.id, c.name, c.email, - COUNT(l.id) as license_count - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - GROUP BY c.id, c.name, c.email - ORDER BY c.name - LIMIT %s OFFSET %s - """, (per_page, (page - 1) * per_page)) - - customers = cur.fetchall() - - # Format für Select2 - results = [] - for customer in customers: - results.append({ - 'id': customer[0], - 'text': f"{customer[1]} - {customer[2]} ({customer[3]} Lizenzen)", - 'name': customer[1], - 'email': customer[2], - 'license_count': customer[3] - }) - - # Gesamtanzahl für Pagination - if search: - cur.execute(""" - SELECT COUNT(*) FROM customers - WHERE LOWER(name) LIKE LOWER(%s) - OR LOWER(email) LIKE LOWER(%s) - """, (f'%{search}%', f'%{search}%')) - else: - cur.execute("SELECT COUNT(*) FROM customers") - - total_count = cur.fetchone()[0] - - cur.close() - conn.close() - - # Select2 Response Format - return jsonify({ - 'results': results, - 'pagination': { - 'more': (page * per_page) < total_count - } - }) - - except Exception as e: - logging.error(f"Fehler bei Kundensuche: {str(e)}") - return jsonify({ - 'results': [], - 'error': 'Fehler bei der Kundensuche' - }), 500 - -@app.route("/") -@login_required -def dashboard(): - conn = get_connection() - cur = conn.cursor() - - # Statistiken abrufen - # Gesamtanzahl Kunden (ohne Testdaten) - cur.execute("SELECT COUNT(*) FROM customers WHERE is_test = FALSE") - total_customers = cur.fetchone()[0] - - # Gesamtanzahl Lizenzen (ohne Testdaten) - cur.execute("SELECT COUNT(*) FROM licenses WHERE is_test = FALSE") - total_licenses = cur.fetchone()[0] - - # Aktive Lizenzen (nicht abgelaufen und is_active = true, ohne Testdaten) - cur.execute(""" - SELECT COUNT(*) FROM licenses - WHERE valid_until >= CURRENT_DATE AND is_active = TRUE AND is_test = FALSE - """) - active_licenses = cur.fetchone()[0] - - # Aktive Sessions - cur.execute("SELECT COUNT(*) FROM sessions WHERE is_active = TRUE") - active_sessions_count = cur.fetchone()[0] - - # Abgelaufene Lizenzen (ohne Testdaten) - cur.execute(""" - SELECT COUNT(*) FROM licenses - WHERE valid_until < CURRENT_DATE AND is_test = FALSE - """) - expired_licenses = cur.fetchone()[0] - - # Deaktivierte Lizenzen (ohne Testdaten) - cur.execute(""" - SELECT COUNT(*) FROM licenses - WHERE is_active = FALSE AND is_test = FALSE - """) - inactive_licenses = cur.fetchone()[0] - - # Lizenzen die in den nächsten 30 Tagen ablaufen (ohne Testdaten) - cur.execute(""" - SELECT COUNT(*) FROM licenses - WHERE valid_until >= CURRENT_DATE - AND valid_until < CURRENT_DATE + INTERVAL '30 days' - AND is_active = TRUE - AND is_test = FALSE - """) - expiring_soon = cur.fetchone()[0] - - # Testlizenzen vs Vollversionen (ohne Testdaten) - cur.execute(""" - SELECT license_type, COUNT(*) - FROM licenses - WHERE is_test = FALSE - GROUP BY license_type - """) - license_types = dict(cur.fetchall()) - - # Anzahl Testdaten - cur.execute("SELECT COUNT(*) FROM licenses WHERE is_test = TRUE") - test_data_count = cur.fetchone()[0] - - # Anzahl Test-Kunden - cur.execute("SELECT COUNT(*) FROM customers WHERE is_test = TRUE") - test_customers_count = cur.fetchone()[0] - - # Anzahl Test-Ressourcen - cur.execute("SELECT COUNT(*) FROM resource_pools WHERE is_test = TRUE") - test_resources_count = cur.fetchone()[0] - - # Letzte 5 erstellten Lizenzen (ohne Testdaten) - cur.execute(""" - SELECT l.id, l.license_key, c.name, l.valid_until, - CASE - WHEN l.is_active = FALSE THEN 'deaktiviert' - WHEN l.valid_until < CURRENT_DATE THEN 'abgelaufen' - WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'läuft bald ab' - ELSE 'aktiv' - END as status - FROM licenses l - JOIN customers c ON l.customer_id = c.id - WHERE l.is_test = FALSE - ORDER BY l.id DESC - LIMIT 5 - """) - recent_licenses = cur.fetchall() - - # Bald ablaufende Lizenzen (nächste 30 Tage, ohne Testdaten) - cur.execute(""" - SELECT l.id, l.license_key, c.name, l.valid_until, - l.valid_until - CURRENT_DATE as days_left - FROM licenses l - JOIN customers c ON l.customer_id = c.id - WHERE l.valid_until >= CURRENT_DATE - AND l.valid_until < CURRENT_DATE + INTERVAL '30 days' - AND l.is_active = TRUE - AND l.is_test = FALSE - ORDER BY l.valid_until - LIMIT 10 - """) - expiring_licenses = cur.fetchall() - - # Letztes Backup - cur.execute(""" - SELECT created_at, filesize, duration_seconds, backup_type, status - FROM backup_history - ORDER BY created_at DESC - LIMIT 1 - """) - last_backup_info = cur.fetchone() - - # Sicherheitsstatistiken - # Gesperrte IPs - cur.execute(""" - SELECT COUNT(*) FROM login_attempts - WHERE blocked_until IS NOT NULL AND blocked_until > CURRENT_TIMESTAMP - """) - blocked_ips_count = cur.fetchone()[0] - - # Fehlversuche heute - cur.execute(""" - SELECT COALESCE(SUM(attempt_count), 0) FROM login_attempts - WHERE last_attempt::date = CURRENT_DATE - """) - failed_attempts_today = cur.fetchone()[0] - - # Letzte 5 Sicherheitsereignisse - cur.execute(""" - SELECT - la.ip_address, - la.attempt_count, - la.last_attempt, - la.blocked_until, - la.last_username_tried, - la.last_error_message - FROM login_attempts la - ORDER BY la.last_attempt DESC - LIMIT 5 - """) - recent_security_events = [] - for event in cur.fetchall(): - recent_security_events.append({ - 'ip_address': event[0], - 'attempt_count': event[1], - 'last_attempt': event[2].strftime('%d.%m %H:%M'), - 'blocked_until': event[3].strftime('%d.%m %H:%M') if event[3] and event[3] > datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None) else None, - 'username_tried': event[4], - 'error_message': event[5] - }) - - # Sicherheitslevel berechnen - if blocked_ips_count > 5 or failed_attempts_today > 50: - security_level = 'danger' - security_level_text = 'KRITISCH' - elif blocked_ips_count > 2 or failed_attempts_today > 20: - security_level = 'warning' - security_level_text = 'ERHÖHT' - else: - security_level = 'success' - security_level_text = 'NORMAL' - - # Resource Pool Statistiken (nur Live-Daten, keine Testdaten) - cur.execute(""" - SELECT - resource_type, - COUNT(*) FILTER (WHERE status = 'available') as available, - COUNT(*) FILTER (WHERE status = 'allocated') as allocated, - COUNT(*) FILTER (WHERE status = 'quarantine') as quarantine, - COUNT(*) as total - FROM resource_pools - WHERE is_test = FALSE - GROUP BY resource_type - """) - - resource_stats = {} - resource_warning = None - - for row in cur.fetchall(): - available_percent = round((row[1] / row[4] * 100) if row[4] > 0 else 0, 1) - resource_stats[row[0]] = { - 'available': row[1], - 'allocated': row[2], - 'quarantine': row[3], - 'total': row[4], - 'available_percent': available_percent, - 'warning_level': 'danger' if row[1] < 50 else 'warning' if row[1] < 100 else 'success' - } - - # Warnung bei niedrigem Bestand - if row[1] < 50: - if not resource_warning: - resource_warning = f"Niedriger Bestand bei {row[0].upper()}: nur noch {row[1]} verfügbar!" - else: - resource_warning += f" | {row[0].upper()}: {row[1]}" - - cur.close() - conn.close() - - stats = { - 'total_customers': total_customers, - 'total_licenses': total_licenses, - 'active_licenses': active_licenses, - 'expired_licenses': expired_licenses, - 'inactive_licenses': inactive_licenses, - 'expiring_soon': expiring_soon, - 'full_licenses': license_types.get('full', 0), - 'test_licenses': license_types.get('test', 0), - 'test_data_count': test_data_count, - 'test_customers_count': test_customers_count, - 'test_resources_count': test_resources_count, - 'recent_licenses': recent_licenses, - 'expiring_licenses': expiring_licenses, - 'active_sessions': active_sessions_count, - 'last_backup': last_backup_info, - # Sicherheitsstatistiken - 'blocked_ips_count': blocked_ips_count, - 'failed_attempts_today': failed_attempts_today, - 'recent_security_events': recent_security_events, - 'security_level': security_level, - 'security_level_text': security_level_text, - 'resource_stats': resource_stats - } - - return render_template("dashboard.html", - stats=stats, - resource_stats=resource_stats, - resource_warning=resource_warning, - username=session.get('username')) - -@app.route("/create", methods=["GET", "POST"]) -@login_required -def create_license(): - if request.method == "POST": - customer_id = request.form.get("customer_id") - license_key = request.form["license_key"].upper() # Immer Großbuchstaben - license_type = request.form["license_type"] - valid_from = request.form["valid_from"] - is_test = request.form.get("is_test") == "on" # Checkbox value - - # Berechne valid_until basierend auf Laufzeit - duration = int(request.form.get("duration", 1)) - duration_type = request.form.get("duration_type", "years") - - from datetime import datetime, timedelta - from dateutil.relativedelta import relativedelta - - start_date = datetime.strptime(valid_from, "%Y-%m-%d") - - if duration_type == "days": - end_date = start_date + timedelta(days=duration) - elif duration_type == "months": - end_date = start_date + relativedelta(months=duration) - else: # years - end_date = start_date + relativedelta(years=duration) - - # Ein Tag abziehen, da der Starttag mitgezählt wird - end_date = end_date - timedelta(days=1) - valid_until = end_date.strftime("%Y-%m-%d") - - # Validiere License Key Format - if not validate_license_key(license_key): - flash('Ungültiges License Key Format! Erwartet: AF-YYYYMMFT-XXXX-YYYY-ZZZZ', 'error') - return redirect(url_for('create_license')) - - # Resource counts - domain_count = int(request.form.get("domain_count", 1)) - ipv4_count = int(request.form.get("ipv4_count", 1)) - phone_count = int(request.form.get("phone_count", 1)) - device_limit = int(request.form.get("device_limit", 3)) - - conn = get_connection() - cur = conn.cursor() - - try: - # Prüfe ob neuer Kunde oder bestehender - if customer_id == "new": - # Neuer Kunde - name = request.form.get("customer_name") - email = request.form.get("email") - - if not name: - flash('Kundenname ist erforderlich!', 'error') - return redirect(url_for('create_license')) - - # Prüfe ob E-Mail bereits existiert - if email: - cur.execute("SELECT id, name FROM customers WHERE LOWER(email) = LOWER(%s)", (email,)) - existing = cur.fetchone() - if existing: - flash(f'E-Mail bereits vergeben für Kunde: {existing[1]}', 'error') - return redirect(url_for('create_license')) - - # Kunde einfügen (erbt Test-Status von Lizenz) - cur.execute(""" - INSERT INTO customers (name, email, is_test, created_at) - VALUES (%s, %s, %s, NOW()) - RETURNING id - """, (name, email, is_test)) - customer_id = cur.fetchone()[0] - customer_info = {'name': name, 'email': email, 'is_test': is_test} - - # Audit-Log für neuen Kunden - log_audit('CREATE', 'customer', customer_id, - new_values={'name': name, 'email': email, 'is_test': is_test}) - else: - # Bestehender Kunde - hole Infos für Audit-Log - cur.execute("SELECT name, email, is_test FROM customers WHERE id = %s", (customer_id,)) - customer_data = cur.fetchone() - if not customer_data: - flash('Kunde nicht gefunden!', 'error') - return redirect(url_for('create_license')) - customer_info = {'name': customer_data[0], 'email': customer_data[1]} - - # Wenn Kunde Test-Kunde ist, Lizenz auch als Test markieren - if customer_data[2]: # is_test des Kunden - is_test = True - - # Lizenz hinzufügen - cur.execute(""" - INSERT INTO licenses (license_key, customer_id, license_type, valid_from, valid_until, is_active, - domain_count, ipv4_count, phone_count, device_limit, is_test) - VALUES (%s, %s, %s, %s, %s, TRUE, %s, %s, %s, %s, %s) - RETURNING id - """, (license_key, customer_id, license_type, valid_from, valid_until, - domain_count, ipv4_count, phone_count, device_limit, is_test)) - license_id = cur.fetchone()[0] - - # Ressourcen zuweisen - try: - # Prüfe Verfügbarkeit - cur.execute(""" - SELECT - (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s) as domains, - (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s) as ipv4s, - (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s) as phones - """, (is_test, is_test, is_test)) - available = cur.fetchone() - - if available[0] < domain_count: - raise ValueError(f"Nicht genügend Domains verfügbar (benötigt: {domain_count}, verfügbar: {available[0]})") - if available[1] < ipv4_count: - raise ValueError(f"Nicht genügend IPv4-Adressen verfügbar (benötigt: {ipv4_count}, verfügbar: {available[1]})") - if available[2] < phone_count: - raise ValueError(f"Nicht genügend Telefonnummern verfügbar (benötigt: {phone_count}, verfügbar: {available[2]})") - - # Domains zuweisen - if domain_count > 0: - cur.execute(""" - SELECT id FROM resource_pools - WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s - LIMIT %s FOR UPDATE - """, (is_test, domain_count)) - for (resource_id,) in cur.fetchall(): - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], resource_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, resource_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (resource_id, license_id, session['username'], get_client_ip())) - - # IPv4s zuweisen - if ipv4_count > 0: - cur.execute(""" - SELECT id FROM resource_pools - WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s - LIMIT %s FOR UPDATE - """, (is_test, ipv4_count)) - for (resource_id,) in cur.fetchall(): - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], resource_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, resource_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (resource_id, license_id, session['username'], get_client_ip())) - - # Telefonnummern zuweisen - if phone_count > 0: - cur.execute(""" - SELECT id FROM resource_pools - WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s - LIMIT %s FOR UPDATE - """, (is_test, phone_count)) - for (resource_id,) in cur.fetchall(): - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], resource_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, resource_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (resource_id, license_id, session['username'], get_client_ip())) - - except ValueError as e: - conn.rollback() - flash(str(e), 'error') - return redirect(url_for('create_license')) - - conn.commit() - - # Audit-Log - log_audit('CREATE', 'license', license_id, - new_values={ - 'license_key': license_key, - 'customer_name': customer_info['name'], - 'customer_email': customer_info['email'], - 'license_type': license_type, - 'valid_from': valid_from, - 'valid_until': valid_until, - 'device_limit': device_limit, - 'is_test': is_test - }) - - flash(f'Lizenz {license_key} erfolgreich erstellt!', 'success') - - except Exception as e: - conn.rollback() - logging.error(f"Fehler beim Erstellen der Lizenz: {str(e)}") - flash('Fehler beim Erstellen der Lizenz!', 'error') - finally: - cur.close() - conn.close() - - # Preserve show_test parameter if present - redirect_url = "/create" - if request.args.get('show_test') == 'true': - redirect_url += "?show_test=true" - return redirect(redirect_url) - - # Unterstützung für vorausgewählten Kunden - preselected_customer_id = request.args.get('customer_id', type=int) - return render_template("index.html", username=session.get('username'), preselected_customer_id=preselected_customer_id) - -@app.route("/batch", methods=["GET", "POST"]) -@login_required -def batch_licenses(): - """Batch-Generierung mehrerer Lizenzen für einen Kunden""" - if request.method == "POST": - # Formulardaten - customer_id = request.form.get("customer_id") - license_type = request.form["license_type"] - quantity = int(request.form["quantity"]) - valid_from = request.form["valid_from"] - is_test = request.form.get("is_test") == "on" # Checkbox value - - # Berechne valid_until basierend auf Laufzeit - duration = int(request.form.get("duration", 1)) - duration_type = request.form.get("duration_type", "years") - - from datetime import datetime, timedelta - from dateutil.relativedelta import relativedelta - - start_date = datetime.strptime(valid_from, "%Y-%m-%d") - - if duration_type == "days": - end_date = start_date + timedelta(days=duration) - elif duration_type == "months": - end_date = start_date + relativedelta(months=duration) - else: # years - end_date = start_date + relativedelta(years=duration) - - # Ein Tag abziehen, da der Starttag mitgezählt wird - end_date = end_date - timedelta(days=1) - valid_until = end_date.strftime("%Y-%m-%d") - - # Resource counts - domain_count = int(request.form.get("domain_count", 1)) - ipv4_count = int(request.form.get("ipv4_count", 1)) - phone_count = int(request.form.get("phone_count", 1)) - device_limit = int(request.form.get("device_limit", 3)) - - # Sicherheitslimit - if quantity < 1 or quantity > 100: - flash('Anzahl muss zwischen 1 und 100 liegen!', 'error') - return redirect(url_for('batch_licenses')) - - conn = get_connection() - cur = conn.cursor() - - try: - # Prüfe ob neuer Kunde oder bestehender - if customer_id == "new": - # Neuer Kunde - name = request.form.get("customer_name") - email = request.form.get("email") - - if not name: - flash('Kundenname ist erforderlich!', 'error') - return redirect(url_for('batch_licenses')) - - # Prüfe ob E-Mail bereits existiert - if email: - cur.execute("SELECT id, name FROM customers WHERE LOWER(email) = LOWER(%s)", (email,)) - existing = cur.fetchone() - if existing: - flash(f'E-Mail bereits vergeben für Kunde: {existing[1]}', 'error') - return redirect(url_for('batch_licenses')) - - # Kunde einfügen (erbt Test-Status von Lizenz) - cur.execute(""" - INSERT INTO customers (name, email, is_test, created_at) - VALUES (%s, %s, %s, NOW()) - RETURNING id - """, (name, email, is_test)) - customer_id = cur.fetchone()[0] - - # Audit-Log für neuen Kunden - log_audit('CREATE', 'customer', customer_id, - new_values={'name': name, 'email': email, 'is_test': is_test}) - else: - # Bestehender Kunde - hole Infos - cur.execute("SELECT name, email, is_test FROM customers WHERE id = %s", (customer_id,)) - customer_data = cur.fetchone() - if not customer_data: - flash('Kunde nicht gefunden!', 'error') - return redirect(url_for('batch_licenses')) - name = customer_data[0] - email = customer_data[1] - - # Wenn Kunde Test-Kunde ist, Lizenzen auch als Test markieren - if customer_data[2]: # is_test des Kunden - is_test = True - - # Prüfe Ressourcen-Verfügbarkeit für gesamten Batch - total_domains_needed = domain_count * quantity - total_ipv4s_needed = ipv4_count * quantity - total_phones_needed = phone_count * quantity - - cur.execute(""" - SELECT - (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s) as domains, - (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s) as ipv4s, - (SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s) as phones - """, (is_test, is_test, is_test)) - available = cur.fetchone() - - if available[0] < total_domains_needed: - flash(f"Nicht genügend Domains verfügbar (benötigt: {total_domains_needed}, verfügbar: {available[0]})", 'error') - return redirect(url_for('batch_licenses')) - if available[1] < total_ipv4s_needed: - flash(f"Nicht genügend IPv4-Adressen verfügbar (benötigt: {total_ipv4s_needed}, verfügbar: {available[1]})", 'error') - return redirect(url_for('batch_licenses')) - if available[2] < total_phones_needed: - flash(f"Nicht genügend Telefonnummern verfügbar (benötigt: {total_phones_needed}, verfügbar: {available[2]})", 'error') - return redirect(url_for('batch_licenses')) - - # Lizenzen generieren und speichern - generated_licenses = [] - for i in range(quantity): - # Eindeutigen Key generieren - attempts = 0 - while attempts < 10: - license_key = generate_license_key(license_type) - cur.execute("SELECT 1 FROM licenses WHERE license_key = %s", (license_key,)) - if not cur.fetchone(): - break - attempts += 1 - - # Lizenz einfügen - cur.execute(""" - INSERT INTO licenses (license_key, customer_id, license_type, is_test, - valid_from, valid_until, is_active, - domain_count, ipv4_count, phone_count, device_limit) - VALUES (%s, %s, %s, %s, %s, %s, true, %s, %s, %s, %s) - RETURNING id - """, (license_key, customer_id, license_type, is_test, valid_from, valid_until, - domain_count, ipv4_count, phone_count, device_limit)) - license_id = cur.fetchone()[0] - - # Ressourcen für diese Lizenz zuweisen - # Domains - if domain_count > 0: - cur.execute(""" - SELECT id FROM resource_pools - WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s - LIMIT %s FOR UPDATE - """, (is_test, domain_count)) - for (resource_id,) in cur.fetchall(): - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], resource_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, resource_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (resource_id, license_id, session['username'], get_client_ip())) - - # IPv4s - if ipv4_count > 0: - cur.execute(""" - SELECT id FROM resource_pools - WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s - LIMIT %s FOR UPDATE - """, (is_test, ipv4_count)) - for (resource_id,) in cur.fetchall(): - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], resource_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, resource_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (resource_id, license_id, session['username'], get_client_ip())) - - # Telefonnummern - if phone_count > 0: - cur.execute(""" - SELECT id FROM resource_pools - WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s - LIMIT %s FOR UPDATE - """, (is_test, phone_count)) - for (resource_id,) in cur.fetchall(): - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], resource_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, resource_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (resource_id, license_id, session['username'], get_client_ip())) - - generated_licenses.append({ - 'id': license_id, - 'key': license_key, - 'type': license_type - }) - - conn.commit() - - # Audit-Log - log_audit('CREATE_BATCH', 'license', - new_values={'customer': name, 'quantity': quantity, 'type': license_type}, - additional_info=f"Batch-Generierung von {quantity} Lizenzen") - - # Session für Export speichern - session['batch_export'] = { - 'customer': name, - 'email': email, - 'licenses': generated_licenses, - 'valid_from': valid_from, - 'valid_until': valid_until, - 'timestamp': datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat() - } - - flash(f'{quantity} Lizenzen erfolgreich generiert!', 'success') - return render_template("batch_result.html", - customer=name, - email=email, - licenses=generated_licenses, - valid_from=valid_from, - valid_until=valid_until) - - except Exception as e: - conn.rollback() - logging.error(f"Fehler bei Batch-Generierung: {str(e)}") - flash('Fehler bei der Batch-Generierung!', 'error') - return redirect(url_for('batch_licenses')) - finally: - cur.close() - conn.close() - - # GET Request - return render_template("batch_form.html") - -@app.route("/batch/export") -@login_required -def export_batch(): - """Exportiert die zuletzt generierten Batch-Lizenzen""" - batch_data = session.get('batch_export') - if not batch_data: - flash('Keine Batch-Daten zum Exportieren vorhanden!', 'error') - return redirect(url_for('batch_licenses')) - - # CSV generieren - output = io.StringIO() - output.write('\ufeff') # UTF-8 BOM für Excel - - # Header - output.write(f"Kunde: {batch_data['customer']}\n") - output.write(f"E-Mail: {batch_data['email']}\n") - output.write(f"Generiert am: {datetime.fromisoformat(batch_data['timestamp']).strftime('%d.%m.%Y %H:%M')}\n") - output.write(f"Gültig von: {batch_data['valid_from']} bis {batch_data['valid_until']}\n") - output.write("\n") - output.write("Nr;Lizenzschlüssel;Typ\n") - - # Lizenzen - for i, license in enumerate(batch_data['licenses'], 1): - typ_text = "Vollversion" if license['type'] == 'full' else "Testversion" - output.write(f"{i};{license['key']};{typ_text}\n") - - output.seek(0) - - # Audit-Log - log_audit('EXPORT', 'batch_licenses', - additional_info=f"Export von {len(batch_data['licenses'])} Batch-Lizenzen") - - return send_file( - io.BytesIO(output.getvalue().encode('utf-8-sig')), - mimetype='text/csv', - as_attachment=True, - download_name=f"batch_licenses_{batch_data['customer'].replace(' ', '_')}_{datetime.now(ZoneInfo('Europe/Berlin')).strftime('%Y%m%d_%H%M%S')}.csv" - ) - -@app.route("/licenses") -@login_required -def licenses(): - # Redirect zur kombinierten Ansicht - return redirect("/customers-licenses") - -@app.route("/license/edit/", methods=["GET", "POST"]) -@login_required -def edit_license(license_id): - conn = get_connection() - cur = conn.cursor() - - if request.method == "POST": - # Alte Werte für Audit-Log abrufen - cur.execute(""" - SELECT license_key, license_type, valid_from, valid_until, is_active, is_test, device_limit - FROM licenses WHERE id = %s - """, (license_id,)) - old_license = cur.fetchone() - - # Update license - license_key = request.form["license_key"] - license_type = request.form["license_type"] - valid_from = request.form["valid_from"] - valid_until = request.form["valid_until"] - is_active = request.form.get("is_active") == "on" - is_test = request.form.get("is_test") == "on" - device_limit = int(request.form.get("device_limit", 3)) - - cur.execute(""" - UPDATE licenses - SET license_key = %s, license_type = %s, valid_from = %s, - valid_until = %s, is_active = %s, is_test = %s, device_limit = %s - WHERE id = %s - """, (license_key, license_type, valid_from, valid_until, is_active, is_test, device_limit, license_id)) - - conn.commit() - - # Audit-Log - log_audit('UPDATE', 'license', license_id, - old_values={ - 'license_key': old_license[0], - 'license_type': old_license[1], - 'valid_from': str(old_license[2]), - 'valid_until': str(old_license[3]), - 'is_active': old_license[4], - 'is_test': old_license[5], - 'device_limit': old_license[6] - }, - new_values={ - 'license_key': license_key, - 'license_type': license_type, - 'valid_from': valid_from, - 'valid_until': valid_until, - 'is_active': is_active, - 'is_test': is_test, - 'device_limit': device_limit - }) - - cur.close() - conn.close() - - # Redirect zurück zu customers-licenses mit beibehaltenen Parametern - redirect_url = "/customers-licenses" - - # Behalte show_test Parameter bei (aus Form oder GET-Parameter) - show_test = request.form.get('show_test') or request.args.get('show_test') - if show_test == 'true': - redirect_url += "?show_test=true" - - # Behalte customer_id bei wenn vorhanden - if request.referrer and 'customer_id=' in request.referrer: - import re - match = re.search(r'customer_id=(\d+)', request.referrer) - if match: - connector = "&" if "?" in redirect_url else "?" - redirect_url += f"{connector}customer_id={match.group(1)}" - - return redirect(redirect_url) - - # Get license data - cur.execute(""" - SELECT l.id, l.license_key, c.name, c.email, l.license_type, - l.valid_from, l.valid_until, l.is_active, c.id, l.is_test, l.device_limit - FROM licenses l - JOIN customers c ON l.customer_id = c.id - WHERE l.id = %s - """, (license_id,)) - - license = cur.fetchone() - cur.close() - conn.close() - - if not license: - return redirect("/licenses") - - return render_template("edit_license.html", license=license, username=session.get('username')) - -@app.route("/license/delete/", methods=["POST"]) -@login_required -def delete_license(license_id): - conn = get_connection() - cur = conn.cursor() - - # Lizenzdetails für Audit-Log abrufen - cur.execute(""" - SELECT l.license_key, c.name, l.license_type - FROM licenses l - JOIN customers c ON l.customer_id = c.id - WHERE l.id = %s - """, (license_id,)) - license_info = cur.fetchone() - - cur.execute("DELETE FROM licenses WHERE id = %s", (license_id,)) - - conn.commit() - - # Audit-Log - if license_info: - log_audit('DELETE', 'license', license_id, - old_values={ - 'license_key': license_info[0], - 'customer_name': license_info[1], - 'license_type': license_info[2] - }) - - cur.close() - conn.close() - - return redirect("/licenses") - -@app.route("/customers") -@login_required -def customers(): - # Redirect zur kombinierten Ansicht - return redirect("/customers-licenses") - -@app.route("/customer/edit/", methods=["GET", "POST"]) -@login_required -def edit_customer(customer_id): - conn = get_connection() - cur = conn.cursor() - - if request.method == "POST": - # Alte Werte für Audit-Log abrufen - cur.execute("SELECT name, email, is_test FROM customers WHERE id = %s", (customer_id,)) - old_customer = cur.fetchone() - - # Update customer - name = request.form["name"] - email = request.form["email"] - is_test = request.form.get("is_test") == "on" - - cur.execute(""" - UPDATE customers - SET name = %s, email = %s, is_test = %s - WHERE id = %s - """, (name, email, is_test, customer_id)) - - conn.commit() - - # Audit-Log - log_audit('UPDATE', 'customer', customer_id, - old_values={ - 'name': old_customer[0], - 'email': old_customer[1], - 'is_test': old_customer[2] - }, - new_values={ - 'name': name, - 'email': email, - 'is_test': is_test - }) - - cur.close() - conn.close() - - # Redirect zurück zu customers-licenses mit beibehaltenen Parametern - redirect_url = "/customers-licenses" - - # Behalte show_test Parameter bei (aus Form oder GET-Parameter) - show_test = request.form.get('show_test') or request.args.get('show_test') - if show_test == 'true': - redirect_url += "?show_test=true" - - # Behalte customer_id bei (immer der aktuelle Kunde) - connector = "&" if "?" in redirect_url else "?" - redirect_url += f"{connector}customer_id={customer_id}" - - return redirect(redirect_url) - - # Get customer data with licenses - cur.execute(""" - SELECT id, name, email, created_at, is_test FROM customers WHERE id = %s - """, (customer_id,)) - - customer = cur.fetchone() - if not customer: - cur.close() - conn.close() - return "Kunde nicht gefunden", 404 - - - # Get customer's licenses - cur.execute(""" - SELECT id, license_key, license_type, valid_from, valid_until, is_active - FROM licenses - WHERE customer_id = %s - ORDER BY valid_until DESC - """, (customer_id,)) - - licenses = cur.fetchall() - - cur.close() - conn.close() - - if not customer: - return redirect("/customers-licenses") - - return render_template("edit_customer.html", customer=customer, licenses=licenses, username=session.get('username')) - -@app.route("/customer/create", methods=["GET", "POST"]) -@login_required -def create_customer(): - """Erstellt einen neuen Kunden ohne Lizenz""" - if request.method == "POST": - name = request.form.get('name') - email = request.form.get('email') - is_test = request.form.get('is_test') == 'on' - - if not name or not email: - flash("Name und E-Mail sind Pflichtfelder!", "error") - return render_template("create_customer.html", username=session.get('username')) - - conn = get_connection() - cur = conn.cursor() - - try: - # Prüfen ob E-Mail bereits existiert - cur.execute("SELECT id, name FROM customers WHERE email = %s", (email,)) - existing = cur.fetchone() - if existing: - flash(f"Ein Kunde mit der E-Mail '{email}' existiert bereits: {existing[1]}", "error") - return render_template("create_customer.html", username=session.get('username')) - - # Kunde erstellen - cur.execute(""" - INSERT INTO customers (name, email, created_at, is_test) - VALUES (%s, %s, %s, %s) RETURNING id - """, (name, email, datetime.now(), is_test)) - - customer_id = cur.fetchone()[0] - conn.commit() - - # Audit-Log - log_audit('CREATE', 'customer', customer_id, - new_values={ - 'name': name, - 'email': email, - 'is_test': is_test - }) - - flash(f"Kunde '{name}' wurde erfolgreich angelegt!", "success") - return redirect(f"/customer/edit/{customer_id}") - - except Exception as e: - conn.rollback() - flash(f"Fehler beim Anlegen des Kunden: {str(e)}", "error") - return render_template("create_customer.html", username=session.get('username')) - finally: - cur.close() - conn.close() - - # GET Request - Formular anzeigen - return render_template("create_customer.html", username=session.get('username')) - -@app.route("/customer/delete/", methods=["POST"]) -@login_required -def delete_customer(customer_id): - conn = get_connection() - cur = conn.cursor() - - # Prüfen ob Kunde Lizenzen hat - cur.execute("SELECT COUNT(*) FROM licenses WHERE customer_id = %s", (customer_id,)) - license_count = cur.fetchone()[0] - - if license_count > 0: - # Kunde hat Lizenzen - nicht löschen - cur.close() - conn.close() - return redirect("/customers") - - # Kundendetails für Audit-Log abrufen - cur.execute("SELECT name, email FROM customers WHERE id = %s", (customer_id,)) - customer_info = cur.fetchone() - - # Kunde löschen wenn keine Lizenzen vorhanden - cur.execute("DELETE FROM customers WHERE id = %s", (customer_id,)) - - conn.commit() - - # Audit-Log - if customer_info: - log_audit('DELETE', 'customer', customer_id, - old_values={ - 'name': customer_info[0], - 'email': customer_info[1] - }) - - cur.close() - conn.close() - - return redirect("/customers") - -@app.route("/customers-licenses") -@login_required -def customers_licenses(): - """Kombinierte Ansicht für Kunden und deren Lizenzen""" - conn = get_connection() - cur = conn.cursor() - - # Hole alle Kunden mit Lizenzstatistiken (inkl. Testkunden wenn gewünscht) - show_test = request.args.get('show_test', 'false').lower() == 'true' - - query = """ - SELECT - c.id, - c.name, - c.email, - c.created_at, - COUNT(l.id) as total_licenses, - COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE THEN 1 END) as active_licenses, - COUNT(CASE WHEN l.valid_until < CURRENT_DATE THEN 1 END) as expired_licenses - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - """ - - if not show_test: - query += " WHERE c.is_test = FALSE" - - query += """ - GROUP BY c.id, c.name, c.email, c.created_at - ORDER BY c.name - """ - - cur.execute(query) - customers = cur.fetchall() - - # Hole ausgewählten Kunden nur wenn explizit in URL angegeben - selected_customer_id = request.args.get('customer_id', type=int) - licenses = [] - selected_customer = None - - if customers and selected_customer_id: - # Hole Daten des ausgewählten Kunden - for customer in customers: - if customer[0] == selected_customer_id: - selected_customer = customer - break - - # Hole Lizenzen des ausgewählten Kunden - if selected_customer: - cur.execute(""" - SELECT - l.id, - l.license_key, - l.license_type, - l.valid_from, - l.valid_until, - l.is_active, - CASE - WHEN l.is_active = FALSE THEN 'deaktiviert' - WHEN l.valid_until < CURRENT_DATE THEN 'abgelaufen' - WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'läuft bald ab' - ELSE 'aktiv' - END as status, - l.domain_count, - l.ipv4_count, - l.phone_count, - l.device_limit, - (SELECT COUNT(*) FROM device_registrations WHERE license_id = l.id AND is_active = TRUE) as active_devices, - -- Actual resource counts - (SELECT COUNT(*) FROM license_resources lr - JOIN resource_pools rp ON lr.resource_id = rp.id - WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'domain') as actual_domain_count, - (SELECT COUNT(*) FROM license_resources lr - JOIN resource_pools rp ON lr.resource_id = rp.id - WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'ipv4') as actual_ipv4_count, - (SELECT COUNT(*) FROM license_resources lr - JOIN resource_pools rp ON lr.resource_id = rp.id - WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'phone') as actual_phone_count - FROM licenses l - WHERE l.customer_id = %s - ORDER BY l.created_at DESC, l.id DESC - """, (selected_customer_id,)) - licenses = cur.fetchall() - - cur.close() - conn.close() - - return render_template("customers_licenses.html", - customers=customers, - selected_customer=selected_customer, - selected_customer_id=selected_customer_id, - licenses=licenses, - show_test=show_test) - -@app.route("/api/customer//licenses") -@login_required -def api_customer_licenses(customer_id): - """API-Endpoint für AJAX-Abruf der Lizenzen eines Kunden""" - conn = get_connection() - cur = conn.cursor() - - # Hole Lizenzen des Kunden - cur.execute(""" - SELECT - l.id, - l.license_key, - l.license_type, - l.valid_from, - l.valid_until, - l.is_active, - CASE - WHEN l.is_active = FALSE THEN 'deaktiviert' - WHEN l.valid_until < CURRENT_DATE THEN 'abgelaufen' - WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'läuft bald ab' - ELSE 'aktiv' - END as status, - l.domain_count, - l.ipv4_count, - l.phone_count, - l.device_limit, - (SELECT COUNT(*) FROM device_registrations WHERE license_id = l.id AND is_active = TRUE) as active_devices, - -- Actual resource counts - (SELECT COUNT(*) FROM license_resources lr - JOIN resource_pools rp ON lr.resource_id = rp.id - WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'domain') as actual_domain_count, - (SELECT COUNT(*) FROM license_resources lr - JOIN resource_pools rp ON lr.resource_id = rp.id - WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'ipv4') as actual_ipv4_count, - (SELECT COUNT(*) FROM license_resources lr - JOIN resource_pools rp ON lr.resource_id = rp.id - WHERE lr.license_id = l.id AND lr.is_active = true AND rp.resource_type = 'phone') as actual_phone_count - FROM licenses l - WHERE l.customer_id = %s - ORDER BY l.created_at DESC, l.id DESC - """, (customer_id,)) - - licenses = [] - for row in cur.fetchall(): - license_id = row[0] - - # Hole die konkreten zugewiesenen Ressourcen für diese Lizenz - cur.execute(""" - SELECT rp.id, rp.resource_type, rp.resource_value, lr.assigned_at - FROM resource_pools rp - JOIN license_resources lr ON rp.id = lr.resource_id - WHERE lr.license_id = %s AND lr.is_active = true - ORDER BY rp.resource_type, rp.resource_value - """, (license_id,)) - - resources = { - 'domains': [], - 'ipv4s': [], - 'phones': [] - } - - for res_row in cur.fetchall(): - resource_info = { - 'id': res_row[0], - 'value': res_row[2], - 'assigned_at': res_row[3].strftime('%d.%m.%Y') if res_row[3] else '' - } - - if res_row[1] == 'domain': - resources['domains'].append(resource_info) - elif res_row[1] == 'ipv4': - resources['ipv4s'].append(resource_info) - elif res_row[1] == 'phone': - resources['phones'].append(resource_info) - - licenses.append({ - 'id': row[0], - 'license_key': row[1], - 'license_type': row[2], - 'valid_from': row[3].strftime('%d.%m.%Y') if row[3] else '', - 'valid_until': row[4].strftime('%d.%m.%Y') if row[4] else '', - 'is_active': row[5], - 'status': row[6], - 'domain_count': row[7], # limit - 'ipv4_count': row[8], # limit - 'phone_count': row[9], # limit - 'device_limit': row[10], - 'active_devices': row[11], - 'actual_domain_count': row[12], # actual count - 'actual_ipv4_count': row[13], # actual count - 'actual_phone_count': row[14], # actual count - 'resources': resources - }) - - cur.close() - conn.close() - - return jsonify({ - 'success': True, - 'licenses': licenses, - 'count': len(licenses) - }) - -@app.route("/api/customer//quick-stats") -@login_required -def api_customer_quick_stats(customer_id): - """API-Endpoint für Schnellstatistiken eines Kunden""" - conn = get_connection() - cur = conn.cursor() - - # Hole Kundenstatistiken - cur.execute(""" - SELECT - COUNT(l.id) as total_licenses, - COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE THEN 1 END) as active_licenses, - COUNT(CASE WHEN l.valid_until < CURRENT_DATE THEN 1 END) as expired_licenses, - COUNT(CASE WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' AND l.valid_until >= CURRENT_DATE THEN 1 END) as expiring_soon - FROM licenses l - WHERE l.customer_id = %s - """, (customer_id,)) - - stats = cur.fetchone() - - cur.close() - conn.close() - - return jsonify({ - 'success': True, - 'stats': { - 'total': stats[0], - 'active': stats[1], - 'expired': stats[2], - 'expiring_soon': stats[3] - } - }) - -@app.route("/api/license//quick-edit", methods=['POST']) -@login_required -def api_license_quick_edit(license_id): - """API-Endpoint für schnelle Lizenz-Bearbeitung""" - conn = get_connection() - cur = conn.cursor() - - try: - data = request.get_json() - - # Hole alte Werte für Audit-Log - cur.execute(""" - SELECT is_active, valid_until, license_type - FROM licenses WHERE id = %s - """, (license_id,)) - old_values = cur.fetchone() - - if not old_values: - return jsonify({'success': False, 'error': 'Lizenz nicht gefunden'}), 404 - - # Update-Felder vorbereiten - updates = [] - params = [] - new_values = {} - - if 'is_active' in data: - updates.append("is_active = %s") - params.append(data['is_active']) - new_values['is_active'] = data['is_active'] - - if 'valid_until' in data: - updates.append("valid_until = %s") - params.append(data['valid_until']) - new_values['valid_until'] = data['valid_until'] - - if 'license_type' in data: - updates.append("license_type = %s") - params.append(data['license_type']) - new_values['license_type'] = data['license_type'] - - if updates: - params.append(license_id) - cur.execute(f""" - UPDATE licenses - SET {', '.join(updates)} - WHERE id = %s - """, params) - - conn.commit() - - # Audit-Log - log_audit('UPDATE', 'license', license_id, - old_values={ - 'is_active': old_values[0], - 'valid_until': old_values[1].isoformat() if old_values[1] else None, - 'license_type': old_values[2] - }, - new_values=new_values) - - cur.close() - conn.close() - - return jsonify({'success': True}) - - except Exception as e: - conn.rollback() - cur.close() - conn.close() - return jsonify({'success': False, 'error': str(e)}), 500 - -@app.route("/api/license//resources") -@login_required -def api_license_resources(license_id): - """API-Endpoint für detaillierte Ressourcen-Informationen einer Lizenz""" - conn = get_connection() - cur = conn.cursor() - - try: - # Hole die konkreten zugewiesenen Ressourcen für diese Lizenz - cur.execute(""" - SELECT rp.id, rp.resource_type, rp.resource_value, lr.assigned_at - FROM resource_pools rp - JOIN license_resources lr ON rp.id = lr.resource_id - WHERE lr.license_id = %s AND lr.is_active = true - ORDER BY rp.resource_type, rp.resource_value - """, (license_id,)) - - resources = { - 'domains': [], - 'ipv4s': [], - 'phones': [] - } - - for row in cur.fetchall(): - resource_info = { - 'id': row[0], - 'value': row[2], - 'assigned_at': row[3].strftime('%d.%m.%Y') if row[3] else '' - } - - if row[1] == 'domain': - resources['domains'].append(resource_info) - elif row[1] == 'ipv4': - resources['ipv4s'].append(resource_info) - elif row[1] == 'phone': - resources['phones'].append(resource_info) - - cur.close() - conn.close() - - return jsonify({ - 'success': True, - 'resources': resources - }) - - except Exception as e: - cur.close() - conn.close() - return jsonify({'success': False, 'error': str(e)}), 500 - -@app.route("/sessions") -@login_required -def sessions(): - conn = get_connection() - cur = conn.cursor() - - # Sortierparameter - active_sort = request.args.get('active_sort', 'last_heartbeat') - active_order = request.args.get('active_order', 'desc') - ended_sort = request.args.get('ended_sort', 'ended_at') - ended_order = request.args.get('ended_order', 'desc') - - # Whitelist für erlaubte Sortierfelder - Aktive Sessions - active_sort_fields = { - 'customer': 'c.name', - 'license': 'l.license_key', - 'ip': 's.ip_address', - 'started': 's.started_at', - 'last_heartbeat': 's.last_heartbeat', - 'inactive': 'minutes_inactive' - } - - # Whitelist für erlaubte Sortierfelder - Beendete Sessions - ended_sort_fields = { - 'customer': 'c.name', - 'license': 'l.license_key', - 'ip': 's.ip_address', - 'started': 's.started_at', - 'ended_at': 's.ended_at', - 'duration': 'duration_minutes' - } - - # Validierung - if active_sort not in active_sort_fields: - active_sort = 'last_heartbeat' - if ended_sort not in ended_sort_fields: - ended_sort = 'ended_at' - if active_order not in ['asc', 'desc']: - active_order = 'desc' - if ended_order not in ['asc', 'desc']: - ended_order = 'desc' - - # Aktive Sessions abrufen - cur.execute(f""" - SELECT s.id, s.session_id, l.license_key, c.name, s.ip_address, - s.user_agent, s.started_at, s.last_heartbeat, - EXTRACT(EPOCH FROM (NOW() - s.last_heartbeat))/60 as minutes_inactive - FROM sessions s - JOIN licenses l ON s.license_id = l.id - JOIN customers c ON l.customer_id = c.id - WHERE s.is_active = TRUE - ORDER BY {active_sort_fields[active_sort]} {active_order.upper()} - """) - active_sessions = cur.fetchall() - - # Inaktive Sessions der letzten 24 Stunden - cur.execute(f""" - SELECT s.id, s.session_id, l.license_key, c.name, s.ip_address, - s.started_at, s.ended_at, - EXTRACT(EPOCH FROM (s.ended_at - s.started_at))/60 as duration_minutes - FROM sessions s - JOIN licenses l ON s.license_id = l.id - JOIN customers c ON l.customer_id = c.id - WHERE s.is_active = FALSE - AND s.ended_at > NOW() - INTERVAL '24 hours' - ORDER BY {ended_sort_fields[ended_sort]} {ended_order.upper()} - LIMIT 50 - """) - recent_sessions = cur.fetchall() - - cur.close() - conn.close() - - return render_template("sessions.html", - active_sessions=active_sessions, - recent_sessions=recent_sessions, - active_sort=active_sort, - active_order=active_order, - ended_sort=ended_sort, - ended_order=ended_order, - username=session.get('username')) - -@app.route("/session/end/", methods=["POST"]) -@login_required -def end_session(session_id): - conn = get_connection() - cur = conn.cursor() - - # Session beenden - cur.execute(""" - UPDATE sessions - SET is_active = FALSE, ended_at = NOW() - WHERE id = %s AND is_active = TRUE - """, (session_id,)) - - conn.commit() - cur.close() - conn.close() - - return redirect("/sessions") - -@app.route("/export/licenses") -@login_required -def export_licenses(): - conn = get_connection() - cur = conn.cursor() - - # Alle Lizenzen mit Kundeninformationen abrufen (ohne Testdaten, außer explizit gewünscht) - include_test = request.args.get('include_test', 'false').lower() == 'true' - customer_id = request.args.get('customer_id', type=int) - - query = """ - SELECT l.id, l.license_key, c.name as customer_name, c.email as customer_email, - l.license_type, l.valid_from, l.valid_until, l.is_active, l.is_test, - CASE - WHEN l.is_active = FALSE THEN 'Deaktiviert' - WHEN l.valid_until < CURRENT_DATE THEN 'Abgelaufen' - WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'Läuft bald ab' - ELSE 'Aktiv' - END as status - FROM licenses l - JOIN customers c ON l.customer_id = c.id - """ - - # Build WHERE clause - where_conditions = [] - params = [] - - if not include_test: - where_conditions.append("l.is_test = FALSE") - - if customer_id: - where_conditions.append("l.customer_id = %s") - params.append(customer_id) - - if where_conditions: - query += " WHERE " + " AND ".join(where_conditions) - - query += " ORDER BY l.id" - - cur.execute(query, params) - - # Spaltennamen - columns = ['ID', 'Lizenzschlüssel', 'Kunde', 'E-Mail', 'Typ', - 'Gültig von', 'Gültig bis', 'Aktiv', 'Testdaten', 'Status'] - - # Daten in DataFrame - data = cur.fetchall() - df = pd.DataFrame(data, columns=columns) - - # Datumsformatierung - df['Gültig von'] = pd.to_datetime(df['Gültig von']).dt.strftime('%d.%m.%Y') - df['Gültig bis'] = pd.to_datetime(df['Gültig bis']).dt.strftime('%d.%m.%Y') - - # Typ und Aktiv Status anpassen - df['Typ'] = df['Typ'].replace({'full': 'Vollversion', 'test': 'Testversion'}) - df['Aktiv'] = df['Aktiv'].replace({True: 'Ja', False: 'Nein'}) - df['Testdaten'] = df['Testdaten'].replace({True: 'Ja', False: 'Nein'}) - - cur.close() - conn.close() - - # Export Format - export_format = request.args.get('format', 'excel') - - # Audit-Log - log_audit('EXPORT', 'license', - additional_info=f"Export aller Lizenzen als {export_format.upper()}") - filename = f'lizenzen_export_{datetime.now(ZoneInfo("Europe/Berlin")).strftime("%Y%m%d_%H%M%S")}' - - if export_format == 'csv': - # CSV Export - output = io.StringIO() - df.to_csv(output, index=False, encoding='utf-8-sig', sep=';') - output.seek(0) - - return send_file( - io.BytesIO(output.getvalue().encode('utf-8-sig')), - mimetype='text/csv', - as_attachment=True, - download_name=f'{filename}.csv' - ) - else: - # Excel Export - output = io.BytesIO() - with pd.ExcelWriter(output, engine='openpyxl') as writer: - df.to_excel(writer, sheet_name='Lizenzen', index=False) - - # Formatierung - worksheet = writer.sheets['Lizenzen'] - for column in worksheet.columns: - max_length = 0 - column_letter = column[0].column_letter - for cell in column: - try: - if len(str(cell.value)) > max_length: - max_length = len(str(cell.value)) - except: - pass - adjusted_width = min(max_length + 2, 50) - worksheet.column_dimensions[column_letter].width = adjusted_width - - output.seek(0) - - return send_file( - output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=f'{filename}.xlsx' - ) - -@app.route("/export/audit") -@login_required -def export_audit(): - conn = get_connection() - cur = conn.cursor() - - # Holen der Filter-Parameter - filter_user = request.args.get('user', '') - filter_action = request.args.get('action', '') - filter_entity = request.args.get('entity', '') - export_format = request.args.get('format', 'excel') - - # SQL Query mit Filtern - query = """ - SELECT id, timestamp, username, action, entity_type, entity_id, - old_values, new_values, ip_address, user_agent, additional_info - FROM audit_log - WHERE 1=1 - """ - params = [] - - if filter_user: - query += " AND username ILIKE %s" - params.append(f'%{filter_user}%') - - if filter_action: - query += " AND action = %s" - params.append(filter_action) - - if filter_entity: - query += " AND entity_type = %s" - params.append(filter_entity) - - query += " ORDER BY timestamp DESC" - - cur.execute(query, params) - audit_logs = cur.fetchall() - cur.close() - conn.close() - - # Daten für Export vorbereiten - data = [] - for log in audit_logs: - action_text = { - 'CREATE': 'Erstellt', - 'UPDATE': 'Bearbeitet', - 'DELETE': 'Gelöscht', - 'LOGIN': 'Anmeldung', - 'LOGOUT': 'Abmeldung', - 'AUTO_LOGOUT': 'Auto-Logout', - 'EXPORT': 'Export', - 'GENERATE_KEY': 'Key generiert', - 'CREATE_BATCH': 'Batch erstellt', - 'BACKUP': 'Backup erstellt', - 'LOGIN_2FA_SUCCESS': '2FA-Anmeldung', - 'LOGIN_2FA_BACKUP': '2FA-Backup-Code', - 'LOGIN_2FA_FAILED': '2FA-Fehlgeschlagen', - 'LOGIN_BLOCKED': 'Login-Blockiert', - 'RESTORE': 'Wiederhergestellt', - 'PASSWORD_CHANGE': 'Passwort geändert', - '2FA_ENABLED': '2FA aktiviert', - '2FA_DISABLED': '2FA deaktiviert' - }.get(log[3], log[3]) - - data.append({ - 'ID': log[0], - 'Zeitstempel': log[1].strftime('%d.%m.%Y %H:%M:%S'), - 'Benutzer': log[2], - 'Aktion': action_text, - 'Entität': log[4], - 'Entität-ID': log[5] or '', - 'IP-Adresse': log[8] or '', - 'Zusatzinfo': log[10] or '' - }) - - # DataFrame erstellen - df = pd.DataFrame(data) - - # Timestamp für Dateiname - timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') - filename = f'audit_log_export_{timestamp}' - - # Audit Log für Export - log_audit('EXPORT', 'audit_log', - additional_info=f"{export_format.upper()} Export mit {len(data)} Einträgen") - - if export_format == 'csv': - # CSV Export - output = io.StringIO() - # UTF-8 BOM für Excel - output.write('\ufeff') - df.to_csv(output, index=False, sep=';', encoding='utf-8') - output.seek(0) - - return send_file( - io.BytesIO(output.getvalue().encode('utf-8')), - mimetype='text/csv;charset=utf-8', - as_attachment=True, - download_name=f'{filename}.csv' - ) - else: - # Excel Export - output = BytesIO() - with pd.ExcelWriter(output, engine='openpyxl') as writer: - df.to_excel(writer, index=False, sheet_name='Audit Log') - - # Spaltenbreiten anpassen - worksheet = writer.sheets['Audit Log'] - for idx, col in enumerate(df.columns): - max_length = max( - df[col].astype(str).map(len).max(), - len(col) - ) + 2 - worksheet.column_dimensions[get_column_letter(idx + 1)].width = min(max_length, 50) - - output.seek(0) - - return send_file( - output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=f'{filename}.xlsx' - ) - -@app.route("/export/customers") -@login_required -def export_customers(): - conn = get_connection() - cur = conn.cursor() - - # Check if test data should be included - include_test = request.args.get('include_test', 'false').lower() == 'true' - - # Build query based on test data filter - if include_test: - # Include all customers - query = """ - SELECT c.id, c.name, c.email, c.created_at, c.is_test, - COUNT(l.id) as total_licenses, - COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE THEN 1 END) as active_licenses, - COUNT(CASE WHEN l.valid_until < CURRENT_DATE THEN 1 END) as expired_licenses - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - GROUP BY c.id, c.name, c.email, c.created_at, c.is_test - ORDER BY c.id - """ - else: - # Exclude test customers and test licenses - query = """ - SELECT c.id, c.name, c.email, c.created_at, c.is_test, - COUNT(CASE WHEN l.is_test = FALSE THEN 1 END) as total_licenses, - COUNT(CASE WHEN l.is_active = TRUE AND l.valid_until >= CURRENT_DATE AND l.is_test = FALSE THEN 1 END) as active_licenses, - COUNT(CASE WHEN l.valid_until < CURRENT_DATE AND l.is_test = FALSE THEN 1 END) as expired_licenses - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - WHERE c.is_test = FALSE - GROUP BY c.id, c.name, c.email, c.created_at, c.is_test - ORDER BY c.id - """ - - cur.execute(query) - - # Spaltennamen - columns = ['ID', 'Name', 'E-Mail', 'Erstellt am', 'Testdaten', - 'Lizenzen gesamt', 'Aktive Lizenzen', 'Abgelaufene Lizenzen'] - - # Daten in DataFrame - data = cur.fetchall() - df = pd.DataFrame(data, columns=columns) - - # Datumsformatierung - df['Erstellt am'] = pd.to_datetime(df['Erstellt am']).dt.strftime('%d.%m.%Y %H:%M') - - # Testdaten formatting - df['Testdaten'] = df['Testdaten'].replace({True: 'Ja', False: 'Nein'}) - - cur.close() - conn.close() - - # Export Format - export_format = request.args.get('format', 'excel') - - # Audit-Log - log_audit('EXPORT', 'customer', - additional_info=f"Export aller Kunden als {export_format.upper()}") - filename = f'kunden_export_{datetime.now(ZoneInfo("Europe/Berlin")).strftime("%Y%m%d_%H%M%S")}' - - if export_format == 'csv': - # CSV Export - output = io.StringIO() - df.to_csv(output, index=False, encoding='utf-8-sig', sep=';') - output.seek(0) - - return send_file( - io.BytesIO(output.getvalue().encode('utf-8-sig')), - mimetype='text/csv', - as_attachment=True, - download_name=f'{filename}.csv' - ) - else: - # Excel Export - output = io.BytesIO() - with pd.ExcelWriter(output, engine='openpyxl') as writer: - df.to_excel(writer, sheet_name='Kunden', index=False) - - # Formatierung - worksheet = writer.sheets['Kunden'] - for column in worksheet.columns: - max_length = 0 - column_letter = column[0].column_letter - for cell in column: - try: - if len(str(cell.value)) > max_length: - max_length = len(str(cell.value)) - except: - pass - adjusted_width = min(max_length + 2, 50) - worksheet.column_dimensions[column_letter].width = adjusted_width - - output.seek(0) - - return send_file( - output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=f'{filename}.xlsx' - ) - -@app.route("/export/sessions") -@login_required -def export_sessions(): - conn = get_connection() - cur = conn.cursor() - - # Holen des Session-Typs (active oder ended) - session_type = request.args.get('type', 'active') - export_format = request.args.get('format', 'excel') - - # Daten je nach Typ abrufen - if session_type == 'active': - # Aktive Lizenz-Sessions - cur.execute(""" - SELECT s.id, l.license_key, c.name as customer_name, s.session_id, - s.started_at, s.last_heartbeat, - EXTRACT(EPOCH FROM (NOW() - s.started_at))::INT as duration_seconds, - s.ip_address, s.user_agent - FROM sessions s - JOIN licenses l ON s.license_id = l.id - JOIN customers c ON l.customer_id = c.id - WHERE s.is_active = true - ORDER BY s.last_heartbeat DESC - """) - sessions = cur.fetchall() - - # Daten für Export vorbereiten - data = [] - for sess in sessions: - duration = sess[6] - hours = duration // 3600 - minutes = (duration % 3600) // 60 - seconds = duration % 60 - - data.append({ - 'Session-ID': sess[0], - 'Lizenzschlüssel': sess[1], - 'Kunde': sess[2], - 'Session-ID (Tech)': sess[3], - 'Startzeit': sess[4].strftime('%d.%m.%Y %H:%M:%S'), - 'Letzte Aktivität': sess[5].strftime('%d.%m.%Y %H:%M:%S'), - 'Dauer': f"{hours}h {minutes}m {seconds}s", - 'IP-Adresse': sess[7], - 'Browser': sess[8] - }) - - sheet_name = 'Aktive Sessions' - filename_prefix = 'aktive_sessions' - else: - # Beendete Lizenz-Sessions - cur.execute(""" - SELECT s.id, l.license_key, c.name as customer_name, s.session_id, - s.started_at, s.ended_at, - EXTRACT(EPOCH FROM (s.ended_at - s.started_at))::INT as duration_seconds, - s.ip_address, s.user_agent - FROM sessions s - JOIN licenses l ON s.license_id = l.id - JOIN customers c ON l.customer_id = c.id - WHERE s.is_active = false AND s.ended_at IS NOT NULL - ORDER BY s.ended_at DESC - LIMIT 1000 - """) - sessions = cur.fetchall() - - # Daten für Export vorbereiten - data = [] - for sess in sessions: - duration = sess[6] if sess[6] else 0 - hours = duration // 3600 - minutes = (duration % 3600) // 60 - seconds = duration % 60 - - data.append({ - 'Session-ID': sess[0], - 'Lizenzschlüssel': sess[1], - 'Kunde': sess[2], - 'Session-ID (Tech)': sess[3], - 'Startzeit': sess[4].strftime('%d.%m.%Y %H:%M:%S'), - 'Endzeit': sess[5].strftime('%d.%m.%Y %H:%M:%S') if sess[5] else '', - 'Dauer': f"{hours}h {minutes}m {seconds}s", - 'IP-Adresse': sess[7], - 'Browser': sess[8] - }) - - sheet_name = 'Beendete Sessions' - filename_prefix = 'beendete_sessions' - - cur.close() - conn.close() - - # DataFrame erstellen - df = pd.DataFrame(data) - - # Timestamp für Dateiname - timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') - filename = f'{filename_prefix}_export_{timestamp}' - - # Audit Log für Export - log_audit('EXPORT', 'sessions', - additional_info=f"{export_format.upper()} Export von {session_type} Sessions mit {len(data)} Einträgen") - - if export_format == 'csv': - # CSV Export - output = io.StringIO() - # UTF-8 BOM für Excel - output.write('\ufeff') - df.to_csv(output, index=False, sep=';', encoding='utf-8') - output.seek(0) - - return send_file( - io.BytesIO(output.getvalue().encode('utf-8')), - mimetype='text/csv;charset=utf-8', - as_attachment=True, - download_name=f'{filename}.csv' - ) - else: - # Excel Export - output = BytesIO() - with pd.ExcelWriter(output, engine='openpyxl') as writer: - df.to_excel(writer, index=False, sheet_name=sheet_name) - - # Spaltenbreiten anpassen - worksheet = writer.sheets[sheet_name] - for idx, col in enumerate(df.columns): - max_length = max( - df[col].astype(str).map(len).max(), - len(col) - ) + 2 - worksheet.column_dimensions[get_column_letter(idx + 1)].width = min(max_length, 50) - - output.seek(0) - - return send_file( - output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=f'{filename}.xlsx' - ) - -@app.route("/export/resources") -@login_required -def export_resources(): - conn = get_connection() - cur = conn.cursor() - - # Holen der Filter-Parameter - filter_type = request.args.get('type', '') - filter_status = request.args.get('status', '') - search_query = request.args.get('search', '') - show_test = request.args.get('show_test', 'false').lower() == 'true' - export_format = request.args.get('format', 'excel') - - # SQL Query mit Filtern - query = """ - SELECT r.id, r.resource_type, r.resource_value, r.status, r.allocated_to_license, - r.created_at, r.status_changed_at, - l.license_key, c.name as customer_name, c.email as customer_email, - l.license_type - FROM resource_pools r - LEFT JOIN licenses l ON r.allocated_to_license = l.id - LEFT JOIN customers c ON l.customer_id = c.id - WHERE 1=1 - """ - params = [] - - # Filter für Testdaten - if not show_test: - query += " AND (r.is_test = false OR r.is_test IS NULL)" - - # Filter für Ressourcentyp - if filter_type: - query += " AND r.resource_type = %s" - params.append(filter_type) - - # Filter für Status - if filter_status: - query += " AND r.status = %s" - params.append(filter_status) - - # Suchfilter - if search_query: - query += " AND (r.resource_value ILIKE %s OR l.license_key ILIKE %s OR c.name ILIKE %s)" - params.extend([f'%{search_query}%', f'%{search_query}%', f'%{search_query}%']) - - query += " ORDER BY r.id DESC" - - cur.execute(query, params) - resources = cur.fetchall() - cur.close() - conn.close() - - # Daten für Export vorbereiten - data = [] - for res in resources: - status_text = { - 'available': 'Verfügbar', - 'allocated': 'Zugewiesen', - 'quarantine': 'Quarantäne' - }.get(res[3], res[3]) - - type_text = { - 'domain': 'Domain', - 'ipv4': 'IPv4', - 'phone': 'Telefon' - }.get(res[1], res[1]) - - data.append({ - 'ID': res[0], - 'Typ': type_text, - 'Ressource': res[2], - 'Status': status_text, - 'Lizenzschlüssel': res[7] or '', - 'Kunde': res[8] or '', - 'Kunden-Email': res[9] or '', - 'Lizenztyp': res[10] or '', - 'Erstellt am': res[5].strftime('%d.%m.%Y %H:%M:%S') if res[5] else '', - 'Zugewiesen am': res[6].strftime('%d.%m.%Y %H:%M:%S') if res[6] else '' - }) - - # DataFrame erstellen - df = pd.DataFrame(data) - - # Timestamp für Dateiname - timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') - filename = f'resources_export_{timestamp}' - - # Audit Log für Export - log_audit('EXPORT', 'resources', - additional_info=f"{export_format.upper()} Export mit {len(data)} Ressourcen") - - if export_format == 'csv': - # CSV Export - output = io.StringIO() - # UTF-8 BOM für Excel - output.write('\ufeff') - df.to_csv(output, index=False, sep=';', encoding='utf-8') - output.seek(0) - - return send_file( - io.BytesIO(output.getvalue().encode('utf-8')), - mimetype='text/csv;charset=utf-8', - as_attachment=True, - download_name=f'{filename}.csv' - ) - else: - # Excel Export - output = BytesIO() - with pd.ExcelWriter(output, engine='openpyxl') as writer: - df.to_excel(writer, index=False, sheet_name='Resources') - - # Spaltenbreiten anpassen - worksheet = writer.sheets['Resources'] - for idx, col in enumerate(df.columns): - max_length = max( - df[col].astype(str).map(len).max(), - len(col) - ) + 2 - worksheet.column_dimensions[get_column_letter(idx + 1)].width = min(max_length, 50) - - output.seek(0) - - return send_file( - output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=f'{filename}.xlsx' - ) - -@app.route("/audit") -@login_required -def audit_log(): - conn = get_connection() - cur = conn.cursor() - - # Parameter - filter_user = request.args.get('user', '').strip() - filter_action = request.args.get('action', '').strip() - filter_entity = request.args.get('entity', '').strip() - page = request.args.get('page', 1, type=int) - sort = request.args.get('sort', 'timestamp') - order = request.args.get('order', 'desc') - per_page = 50 - - # Whitelist für erlaubte Sortierfelder - allowed_sort_fields = { - 'timestamp': 'timestamp', - 'username': 'username', - 'action': 'action', - 'entity': 'entity_type', - 'ip': 'ip_address' - } - - # Validierung - if sort not in allowed_sort_fields: - sort = 'timestamp' - if order not in ['asc', 'desc']: - order = 'desc' - - sort_field = allowed_sort_fields[sort] - - # SQL Query mit optionalen Filtern - query = """ - SELECT id, timestamp, username, action, entity_type, entity_id, - old_values, new_values, ip_address, user_agent, additional_info - FROM audit_log - WHERE 1=1 - """ - - params = [] - - # Filter - if filter_user: - query += " AND LOWER(username) LIKE LOWER(%s)" - params.append(f'%{filter_user}%') - - if filter_action: - query += " AND action = %s" - params.append(filter_action) - - if filter_entity: - query += " AND entity_type = %s" - params.append(filter_entity) - - # Gesamtanzahl für Pagination - count_query = "SELECT COUNT(*) FROM (" + query + ") as count_table" - cur.execute(count_query, params) - total = cur.fetchone()[0] - - # Pagination - offset = (page - 1) * per_page - query += f" ORDER BY {sort_field} {order.upper()} LIMIT %s OFFSET %s" - params.extend([per_page, offset]) - - cur.execute(query, params) - logs = cur.fetchall() - - # JSON-Werte parsen - parsed_logs = [] - for log in logs: - parsed_log = list(log) - # old_values und new_values sind bereits Dictionaries (JSONB) - # Keine Konvertierung nötig - parsed_logs.append(parsed_log) - - # Pagination Info - total_pages = (total + per_page - 1) // per_page - - cur.close() - conn.close() - - return render_template("audit_log.html", - logs=parsed_logs, - filter_user=filter_user, - filter_action=filter_action, - filter_entity=filter_entity, - page=page, - total_pages=total_pages, - total=total, - sort=sort, - order=order, - username=session.get('username')) - -@app.route("/backups") -@login_required -def backups(): - """Zeigt die Backup-Historie an""" - conn = get_connection() - cur = conn.cursor() - - # Letztes erfolgreiches Backup für Dashboard - cur.execute(""" - SELECT created_at, filesize, duration_seconds - FROM backup_history - WHERE status = 'success' - ORDER BY created_at DESC - LIMIT 1 - """) - last_backup = cur.fetchone() - - # Alle Backups abrufen - cur.execute(""" - SELECT id, filename, filesize, backup_type, status, error_message, - created_at, created_by, tables_count, records_count, - duration_seconds, is_encrypted - FROM backup_history - ORDER BY created_at DESC - """) - backups = cur.fetchall() - - cur.close() - conn.close() - - return render_template("backups.html", - backups=backups, - last_backup=last_backup, - username=session.get('username')) - -@app.route("/backup/create", methods=["POST"]) -@login_required -def create_backup_route(): - """Erstellt ein manuelles Backup""" - username = session.get('username') - success, result = create_backup(backup_type="manual", created_by=username) - - if success: - return jsonify({ - 'success': True, - 'message': f'Backup erfolgreich erstellt: {result}' - }) - else: - return jsonify({ - 'success': False, - 'message': f'Backup fehlgeschlagen: {result}' - }), 500 - -@app.route("/backup/restore/", methods=["POST"]) -@login_required -def restore_backup_route(backup_id): - """Stellt ein Backup wieder her""" - encryption_key = request.form.get('encryption_key') - - success, message = restore_backup(backup_id, encryption_key) - - if success: - return jsonify({ - 'success': True, - 'message': message - }) - else: - return jsonify({ - 'success': False, - 'message': message - }), 500 - -@app.route("/backup/download/") -@login_required -def download_backup(backup_id): - """Lädt eine Backup-Datei herunter""" - conn = get_connection() - cur = conn.cursor() - - cur.execute(""" - SELECT filename, filepath - FROM backup_history - WHERE id = %s - """, (backup_id,)) - backup_info = cur.fetchone() - - cur.close() - conn.close() - - if not backup_info: - return "Backup nicht gefunden", 404 - - filename, filepath = backup_info - filepath = Path(filepath) - - if not filepath.exists(): - return "Backup-Datei nicht gefunden", 404 - - # Audit-Log - log_audit('DOWNLOAD', 'backup', backup_id, - additional_info=f"Backup heruntergeladen: {filename}") - - return send_file(filepath, as_attachment=True, download_name=filename) - -@app.route("/backup/delete/", methods=["DELETE"]) -@login_required -def delete_backup(backup_id): - """Löscht ein Backup""" - conn = get_connection() - cur = conn.cursor() - - try: - # Backup-Informationen abrufen - cur.execute(""" - SELECT filename, filepath - FROM backup_history - WHERE id = %s - """, (backup_id,)) - backup_info = cur.fetchone() - - if not backup_info: - return jsonify({ - 'success': False, - 'message': 'Backup nicht gefunden' - }), 404 - - filename, filepath = backup_info - filepath = Path(filepath) - - # Datei löschen, wenn sie existiert - if filepath.exists(): - filepath.unlink() - - # Aus Datenbank löschen - cur.execute(""" - DELETE FROM backup_history - WHERE id = %s - """, (backup_id,)) - - conn.commit() - - # Audit-Log - log_audit('DELETE', 'backup', backup_id, - additional_info=f"Backup gelöscht: {filename}") - - return jsonify({ - 'success': True, - 'message': f'Backup "{filename}" wurde erfolgreich gelöscht' - }) - - except Exception as e: - conn.rollback() - return jsonify({ - 'success': False, - 'message': f'Fehler beim Löschen des Backups: {str(e)}' - }), 500 - finally: - cur.close() - conn.close() - -@app.route("/security/blocked-ips") -@login_required -def blocked_ips(): - """Zeigt alle gesperrten IPs an""" - conn = get_connection() - cur = conn.cursor() - - cur.execute(""" - SELECT - ip_address, - attempt_count, - first_attempt, - last_attempt, - blocked_until, - last_username_tried, - last_error_message - FROM login_attempts - WHERE blocked_until IS NOT NULL - ORDER BY blocked_until DESC - """) - - blocked_ips_list = [] - for ip in cur.fetchall(): - blocked_ips_list.append({ - 'ip_address': ip[0], - 'attempt_count': ip[1], - 'first_attempt': ip[2].strftime('%d.%m.%Y %H:%M'), - 'last_attempt': ip[3].strftime('%d.%m.%Y %H:%M'), - 'blocked_until': ip[4].strftime('%d.%m.%Y %H:%M'), - 'is_active': ip[4] > datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None), - 'last_username': ip[5], - 'last_error': ip[6] - }) - - cur.close() - conn.close() - - return render_template("blocked_ips.html", - blocked_ips=blocked_ips_list, - username=session.get('username')) - -@app.route("/security/unblock-ip", methods=["POST"]) -@login_required -def unblock_ip(): - """Entsperrt eine IP-Adresse""" - ip_address = request.form.get('ip_address') - - if ip_address: - conn = get_connection() - cur = conn.cursor() - - cur.execute(""" - UPDATE login_attempts - SET blocked_until = NULL - WHERE ip_address = %s - """, (ip_address,)) - - conn.commit() - cur.close() - conn.close() - - # Audit-Log - log_audit('UNBLOCK_IP', 'security', - additional_info=f"IP {ip_address} manuell entsperrt") - - return redirect(url_for('blocked_ips')) - -@app.route("/security/clear-attempts", methods=["POST"]) -@login_required -def clear_attempts(): - """Löscht alle Login-Versuche für eine IP""" - ip_address = request.form.get('ip_address') - - if ip_address: - reset_login_attempts(ip_address) - - # Audit-Log - log_audit('CLEAR_ATTEMPTS', 'security', - additional_info=f"Login-Versuche für IP {ip_address} zurückgesetzt") - - return redirect(url_for('blocked_ips')) - -# API Endpoints for License Management -@app.route("/api/license//toggle", methods=["POST"]) -@login_required -def toggle_license_api(license_id): - """Toggle license active status via API""" - try: - data = request.get_json() - is_active = data.get('is_active', False) - - conn = get_connection() - cur = conn.cursor() - - # Update license status - cur.execute(""" - UPDATE licenses - SET is_active = %s - WHERE id = %s - """, (is_active, license_id)) - - conn.commit() - - # Log the action - log_audit('UPDATE', 'license', license_id, - new_values={'is_active': is_active}, - additional_info=f"Lizenz {'aktiviert' if is_active else 'deaktiviert'} via Toggle") - - cur.close() - conn.close() - - return jsonify({'success': True, 'message': 'Status erfolgreich geändert'}) - except Exception as e: - return jsonify({'success': False, 'message': str(e)}), 500 - -@app.route("/api/licenses/bulk-activate", methods=["POST"]) -@login_required -def bulk_activate_licenses(): - """Activate multiple licenses at once""" - try: - data = request.get_json() - license_ids = data.get('ids', []) - - if not license_ids: - return jsonify({'success': False, 'message': 'Keine Lizenzen ausgewählt'}), 400 - - conn = get_connection() - cur = conn.cursor() - - # Update all selected licenses (nur Live-Daten) - cur.execute(""" - UPDATE licenses - SET is_active = TRUE - WHERE id = ANY(%s) AND is_test = FALSE - """, (license_ids,)) - - affected_rows = cur.rowcount - conn.commit() - - # Log the bulk action - log_audit('BULK_UPDATE', 'licenses', None, - new_values={'is_active': True, 'count': affected_rows}, - additional_info=f"{affected_rows} Lizenzen aktiviert") - - cur.close() - conn.close() - - return jsonify({'success': True, 'message': f'{affected_rows} Lizenzen aktiviert'}) - except Exception as e: - return jsonify({'success': False, 'message': str(e)}), 500 - -@app.route("/api/licenses/bulk-deactivate", methods=["POST"]) -@login_required -def bulk_deactivate_licenses(): - """Deactivate multiple licenses at once""" - try: - data = request.get_json() - license_ids = data.get('ids', []) - - if not license_ids: - return jsonify({'success': False, 'message': 'Keine Lizenzen ausgewählt'}), 400 - - conn = get_connection() - cur = conn.cursor() - - # Update all selected licenses (nur Live-Daten) - cur.execute(""" - UPDATE licenses - SET is_active = FALSE - WHERE id = ANY(%s) AND is_test = FALSE - """, (license_ids,)) - - affected_rows = cur.rowcount - conn.commit() - - # Log the bulk action - log_audit('BULK_UPDATE', 'licenses', None, - new_values={'is_active': False, 'count': affected_rows}, - additional_info=f"{affected_rows} Lizenzen deaktiviert") - - cur.close() - conn.close() - - return jsonify({'success': True, 'message': f'{affected_rows} Lizenzen deaktiviert'}) - except Exception as e: - return jsonify({'success': False, 'message': str(e)}), 500 - -@app.route("/api/license//devices") -@login_required -def get_license_devices(license_id): - """Hole alle registrierten Geräte einer Lizenz""" - try: - conn = get_connection() - cur = conn.cursor() - - # Prüfe ob Lizenz existiert und hole device_limit - cur.execute(""" - SELECT device_limit FROM licenses WHERE id = %s - """, (license_id,)) - license_data = cur.fetchone() - - if not license_data: - return jsonify({'success': False, 'message': 'Lizenz nicht gefunden'}), 404 - - device_limit = license_data[0] - - # Hole alle Geräte für diese Lizenz - cur.execute(""" - SELECT id, hardware_id, device_name, operating_system, - first_seen, last_seen, is_active, ip_address - FROM device_registrations - WHERE license_id = %s - ORDER BY is_active DESC, last_seen DESC - """, (license_id,)) - - devices = [] - for row in cur.fetchall(): - devices.append({ - 'id': row[0], - 'hardware_id': row[1], - 'device_name': row[2] or 'Unbekanntes Gerät', - 'operating_system': row[3] or 'Unbekannt', - 'first_seen': row[4].strftime('%d.%m.%Y %H:%M') if row[4] else '', - 'last_seen': row[5].strftime('%d.%m.%Y %H:%M') if row[5] else '', - 'is_active': row[6], - 'ip_address': row[7] or '-' - }) - - cur.close() - conn.close() - - return jsonify({ - 'success': True, - 'devices': devices, - 'device_limit': device_limit, - 'active_count': sum(1 for d in devices if d['is_active']) - }) - - except Exception as e: - logging.error(f"Fehler beim Abrufen der Geräte: {str(e)}") - return jsonify({'success': False, 'message': 'Fehler beim Abrufen der Geräte'}), 500 - -@app.route("/api/license//register-device", methods=["POST"]) -def register_device(license_id): - """Registriere ein neues Gerät für eine Lizenz""" - try: - data = request.get_json() - hardware_id = data.get('hardware_id') - device_name = data.get('device_name', '') - operating_system = data.get('operating_system', '') - - if not hardware_id: - return jsonify({'success': False, 'message': 'Hardware-ID fehlt'}), 400 - - conn = get_connection() - cur = conn.cursor() - - # Prüfe ob Lizenz existiert und aktiv ist - cur.execute(""" - SELECT device_limit, is_active, valid_until - FROM licenses - WHERE id = %s - """, (license_id,)) - license_data = cur.fetchone() - - if not license_data: - return jsonify({'success': False, 'message': 'Lizenz nicht gefunden'}), 404 - - device_limit, is_active, valid_until = license_data - - # Prüfe ob Lizenz aktiv und gültig ist - if not is_active: - return jsonify({'success': False, 'message': 'Lizenz ist deaktiviert'}), 403 - - if valid_until < datetime.now(ZoneInfo("Europe/Berlin")).date(): - return jsonify({'success': False, 'message': 'Lizenz ist abgelaufen'}), 403 - - # Prüfe ob Gerät bereits registriert ist - cur.execute(""" - SELECT id, is_active FROM device_registrations - WHERE license_id = %s AND hardware_id = %s - """, (license_id, hardware_id)) - existing_device = cur.fetchone() - - if existing_device: - device_id, is_device_active = existing_device - if is_device_active: - # Gerät ist bereits aktiv, update last_seen - cur.execute(""" - UPDATE device_registrations - SET last_seen = CURRENT_TIMESTAMP, - ip_address = %s, - user_agent = %s - WHERE id = %s - """, (get_client_ip(), request.headers.get('User-Agent', ''), device_id)) - conn.commit() - return jsonify({'success': True, 'message': 'Gerät bereits registriert', 'device_id': device_id}) - else: - # Gerät war deaktiviert, prüfe ob wir es reaktivieren können - cur.execute(""" - SELECT COUNT(*) FROM device_registrations - WHERE license_id = %s AND is_active = TRUE - """, (license_id,)) - active_count = cur.fetchone()[0] - - if active_count >= device_limit: - return jsonify({'success': False, 'message': f'Gerätelimit erreicht ({device_limit} Geräte)'}), 403 - - # Reaktiviere das Gerät - cur.execute(""" - UPDATE device_registrations - SET is_active = TRUE, - last_seen = CURRENT_TIMESTAMP, - deactivated_at = NULL, - deactivated_by = NULL, - ip_address = %s, - user_agent = %s - WHERE id = %s - """, (get_client_ip(), request.headers.get('User-Agent', ''), device_id)) - conn.commit() - return jsonify({'success': True, 'message': 'Gerät reaktiviert', 'device_id': device_id}) - - # Neues Gerät - prüfe Gerätelimit - cur.execute(""" - SELECT COUNT(*) FROM device_registrations - WHERE license_id = %s AND is_active = TRUE - """, (license_id,)) - active_count = cur.fetchone()[0] - - if active_count >= device_limit: - return jsonify({'success': False, 'message': f'Gerätelimit erreicht ({device_limit} Geräte)'}), 403 - - # Registriere neues Gerät - cur.execute(""" - INSERT INTO device_registrations - (license_id, hardware_id, device_name, operating_system, ip_address, user_agent) - VALUES (%s, %s, %s, %s, %s, %s) - RETURNING id - """, (license_id, hardware_id, device_name, operating_system, - get_client_ip(), request.headers.get('User-Agent', ''))) - device_id = cur.fetchone()[0] - - conn.commit() - - # Audit Log - log_audit('DEVICE_REGISTER', 'device', device_id, - new_values={'license_id': license_id, 'hardware_id': hardware_id}) - - cur.close() - conn.close() - - return jsonify({'success': True, 'message': 'Gerät erfolgreich registriert', 'device_id': device_id}) - - except Exception as e: - logging.error(f"Fehler bei Geräte-Registrierung: {str(e)}") - return jsonify({'success': False, 'message': 'Fehler bei der Registrierung'}), 500 - -@app.route("/api/license//deactivate-device/", methods=["POST"]) -@login_required -def deactivate_device(license_id, device_id): - """Deaktiviere ein registriertes Gerät""" - try: - conn = get_connection() - cur = conn.cursor() - - # Prüfe ob das Gerät zu dieser Lizenz gehört - cur.execute(""" - SELECT id FROM device_registrations - WHERE id = %s AND license_id = %s AND is_active = TRUE - """, (device_id, license_id)) - - if not cur.fetchone(): - return jsonify({'success': False, 'message': 'Gerät nicht gefunden oder bereits deaktiviert'}), 404 - - # Deaktiviere das Gerät - cur.execute(""" - UPDATE device_registrations - SET is_active = FALSE, - deactivated_at = CURRENT_TIMESTAMP, - deactivated_by = %s - WHERE id = %s - """, (session['username'], device_id)) - - conn.commit() - - # Audit Log - log_audit('DEVICE_DEACTIVATE', 'device', device_id, - old_values={'is_active': True}, - new_values={'is_active': False}) - - cur.close() - conn.close() - - return jsonify({'success': True, 'message': 'Gerät erfolgreich deaktiviert'}) - - except Exception as e: - logging.error(f"Fehler beim Deaktivieren des Geräts: {str(e)}") - return jsonify({'success': False, 'message': 'Fehler beim Deaktivieren'}), 500 - -@app.route("/api/licenses/bulk-delete", methods=["POST"]) -@login_required -def bulk_delete_licenses(): - """Delete multiple licenses at once""" - try: - data = request.get_json() - license_ids = data.get('ids', []) - - if not license_ids: - return jsonify({'success': False, 'message': 'Keine Lizenzen ausgewählt'}), 400 - - conn = get_connection() - cur = conn.cursor() - - # Get license info for audit log (nur Live-Daten) - cur.execute(""" - SELECT license_key - FROM licenses - WHERE id = ANY(%s) AND is_test = FALSE - """, (license_ids,)) - license_keys = [row[0] for row in cur.fetchall()] - - # Delete all selected licenses (nur Live-Daten) - cur.execute(""" - DELETE FROM licenses - WHERE id = ANY(%s) AND is_test = FALSE - """, (license_ids,)) - - affected_rows = cur.rowcount - conn.commit() - - # Log the bulk action - log_audit('BULK_DELETE', 'licenses', None, - old_values={'license_keys': license_keys, 'count': affected_rows}, - additional_info=f"{affected_rows} Lizenzen gelöscht") - - cur.close() - conn.close() - - return jsonify({'success': True, 'message': f'{affected_rows} Lizenzen gelöscht'}) - except Exception as e: - return jsonify({'success': False, 'message': str(e)}), 500 - -# ===================== RESOURCE POOL MANAGEMENT ===================== - -@app.route('/resources') -@login_required -def resources(): - """Resource Pool Hauptübersicht""" - conn = get_connection() - cur = conn.cursor() - - # Prüfe ob Testdaten angezeigt werden sollen (gleiche Logik wie bei Kunden) - show_test = request.args.get('show_test', 'false').lower() == 'true' - - # Statistiken abrufen - cur.execute(""" - SELECT - resource_type, - COUNT(*) FILTER (WHERE status = 'available') as available, - COUNT(*) FILTER (WHERE status = 'allocated') as allocated, - COUNT(*) FILTER (WHERE status = 'quarantine') as quarantine, - COUNT(*) as total - FROM resource_pools - WHERE is_test = %s - GROUP BY resource_type - """, (show_test,)) - - stats = {} - for row in cur.fetchall(): - stats[row[0]] = { - 'available': row[1], - 'allocated': row[2], - 'quarantine': row[3], - 'total': row[4], - 'available_percent': round((row[1] / row[4] * 100) if row[4] > 0 else 0, 1) - } - - # Letzte Aktivitäten (gefiltert nach Test/Live) - cur.execute(""" - SELECT - rh.action, - rh.action_by, - rh.action_at, - rp.resource_type, - rp.resource_value, - rh.details - FROM resource_history rh - JOIN resource_pools rp ON rh.resource_id = rp.id - WHERE rp.is_test = %s - ORDER BY rh.action_at DESC - LIMIT 10 - """, (show_test,)) - recent_activities = cur.fetchall() - - # Ressourcen-Liste mit Pagination - page = request.args.get('page', 1, type=int) - per_page = 50 - offset = (page - 1) * per_page - - resource_type = request.args.get('type', '') - status_filter = request.args.get('status', '') - search = request.args.get('search', '') - - # Sortierung - sort_by = request.args.get('sort', 'id') - sort_order = request.args.get('order', 'desc') - - # Base Query - query = """ - SELECT - rp.id, - rp.resource_type, - rp.resource_value, - rp.status, - rp.allocated_to_license, - l.license_key, - c.name as customer_name, - rp.status_changed_at, - rp.quarantine_reason, - rp.quarantine_until, - c.id as customer_id - FROM resource_pools rp - LEFT JOIN licenses l ON rp.allocated_to_license = l.id - LEFT JOIN customers c ON l.customer_id = c.id - WHERE rp.is_test = %s - """ - params = [show_test] - - if resource_type: - query += " AND rp.resource_type = %s" - params.append(resource_type) - - if status_filter: - query += " AND rp.status = %s" - params.append(status_filter) - - if search: - query += " AND rp.resource_value ILIKE %s" - params.append(f'%{search}%') - - # Count total - count_query = f"SELECT COUNT(*) FROM ({query}) as cnt" - cur.execute(count_query, params) - total = cur.fetchone()[0] - total_pages = (total + per_page - 1) // per_page - - # Get paginated results with dynamic sorting - sort_column_map = { - 'id': 'rp.id', - 'type': 'rp.resource_type', - 'resource': 'rp.resource_value', - 'status': 'rp.status', - 'assigned': 'c.name', - 'changed': 'rp.status_changed_at' - } - - sort_column = sort_column_map.get(sort_by, 'rp.id') - sort_direction = 'ASC' if sort_order == 'asc' else 'DESC' - - query += f" ORDER BY {sort_column} {sort_direction} LIMIT %s OFFSET %s" - params.extend([per_page, offset]) - - cur.execute(query, params) - resources = cur.fetchall() - - cur.close() - conn.close() - - return render_template('resources.html', - stats=stats, - resources=resources, - recent_activities=recent_activities, - page=page, - total_pages=total_pages, - total=total, - resource_type=resource_type, - status_filter=status_filter, - search=search, - show_test=show_test, - sort_by=sort_by, - sort_order=sort_order, - datetime=datetime, - timedelta=timedelta) - -@app.route('/resources/add', methods=['GET', 'POST']) -@login_required -def add_resources(): - """Ressourcen zum Pool hinzufügen""" - # Hole show_test Parameter für die Anzeige - show_test = request.args.get('show_test', 'false').lower() == 'true' - - if request.method == 'POST': - resource_type = request.form.get('resource_type') - resources_text = request.form.get('resources_text', '') - is_test = request.form.get('is_test') == 'on' # Checkbox für Testdaten - - # Parse resources (one per line) - resources = [r.strip() for r in resources_text.split('\n') if r.strip()] - - if not resources: - flash('Keine Ressourcen angegeben', 'error') - return redirect(url_for('add_resources', show_test=show_test)) - - conn = get_connection() - cur = conn.cursor() - - added = 0 - duplicates = 0 - - for resource_value in resources: - try: - cur.execute(""" - INSERT INTO resource_pools (resource_type, resource_value, status_changed_by, is_test) - VALUES (%s, %s, %s, %s) - ON CONFLICT (resource_type, resource_value) DO NOTHING - """, (resource_type, resource_value, session['username'], is_test)) - - if cur.rowcount > 0: - added += 1 - # Get the inserted ID - cur.execute("SELECT id FROM resource_pools WHERE resource_type = %s AND resource_value = %s", - (resource_type, resource_value)) - resource_id = cur.fetchone()[0] - - # Log in history - cur.execute(""" - INSERT INTO resource_history (resource_id, action, action_by, ip_address) - VALUES (%s, 'created', %s, %s) - """, (resource_id, session['username'], get_client_ip())) - else: - duplicates += 1 - - except Exception as e: - app.logger.error(f"Error adding resource {resource_value}: {e}") - - conn.commit() - cur.close() - conn.close() - - log_audit('CREATE', 'resource_pool', None, - new_values={'type': resource_type, 'added': added, 'duplicates': duplicates, 'is_test': is_test}, - additional_info=f"{added} {'Test-' if is_test else ''}Ressourcen hinzugefügt, {duplicates} Duplikate übersprungen") - - flash(f'{added} {"Test-" if is_test else ""}Ressourcen hinzugefügt, {duplicates} Duplikate übersprungen', 'success') - return redirect(url_for('resources', show_test=show_test)) - - return render_template('add_resources.html', show_test=show_test) - -@app.route('/resources/quarantine/', methods=['POST']) -@login_required -def quarantine_resource(resource_id): - """Ressource in Quarantäne setzen""" - reason = request.form.get('reason', 'review') - until_date = request.form.get('until_date') - notes = request.form.get('notes', '') - - conn = get_connection() - cur = conn.cursor() - - # Get current resource info - cur.execute("SELECT resource_type, resource_value, status FROM resource_pools WHERE id = %s", (resource_id,)) - resource = cur.fetchone() - - if not resource: - flash('Ressource nicht gefunden', 'error') - return redirect(url_for('resources')) - - old_status = resource[2] - - # Update resource - cur.execute(""" - UPDATE resource_pools - SET status = 'quarantine', - quarantine_reason = %s, - quarantine_until = %s, - notes = %s, - status_changed_at = CURRENT_TIMESTAMP, - status_changed_by = %s - WHERE id = %s - """, (reason, until_date if until_date else None, notes, session['username'], resource_id)) - - # Log in history - cur.execute(""" - INSERT INTO resource_history (resource_id, action, action_by, ip_address, details) - VALUES (%s, 'quarantined', %s, %s, %s) - """, (resource_id, session['username'], get_client_ip(), - Json({'reason': reason, 'until': until_date, 'notes': notes, 'old_status': old_status}))) - - conn.commit() - cur.close() - conn.close() - - log_audit('UPDATE', 'resource', resource_id, - old_values={'status': old_status}, - new_values={'status': 'quarantine', 'reason': reason}, - additional_info=f"Ressource {resource[0]}: {resource[1]} in Quarantäne") - - flash('Ressource in Quarantäne gesetzt', 'success') - - # Redirect mit allen aktuellen Filtern - return redirect(url_for('resources', - show_test=request.args.get('show_test', request.form.get('show_test', 'false')), - type=request.args.get('type', request.form.get('type', '')), - status=request.args.get('status', request.form.get('status', '')), - search=request.args.get('search', request.form.get('search', '')))) - -@app.route('/resources/release', methods=['POST']) -@login_required -def release_resources(): - """Ressourcen aus Quarantäne freigeben""" - resource_ids = request.form.getlist('resource_ids') - - if not resource_ids: - flash('Keine Ressourcen ausgewählt', 'error') - return redirect(url_for('resources')) - - conn = get_connection() - cur = conn.cursor() - - released = 0 - for resource_id in resource_ids: - cur.execute(""" - UPDATE resource_pools - SET status = 'available', - quarantine_reason = NULL, - quarantine_until = NULL, - allocated_to_license = NULL, - status_changed_at = CURRENT_TIMESTAMP, - status_changed_by = %s - WHERE id = %s AND status = 'quarantine' - """, (session['username'], resource_id)) - - if cur.rowcount > 0: - released += 1 - # Log in history - cur.execute(""" - INSERT INTO resource_history (resource_id, action, action_by, ip_address) - VALUES (%s, 'released', %s, %s) - """, (resource_id, session['username'], get_client_ip())) - - conn.commit() - cur.close() - conn.close() - - log_audit('UPDATE', 'resource_pool', None, - new_values={'released': released}, - additional_info=f"{released} Ressourcen aus Quarantäne freigegeben") - - flash(f'{released} Ressourcen freigegeben', 'success') - - # Redirect mit allen aktuellen Filtern - return redirect(url_for('resources', - show_test=request.args.get('show_test', request.form.get('show_test', 'false')), - type=request.args.get('type', request.form.get('type', '')), - status=request.args.get('status', request.form.get('status', '')), - search=request.args.get('search', request.form.get('search', '')))) - -@app.route('/api/resources/allocate', methods=['POST']) -@login_required -def allocate_resources_api(): - """API für Ressourcen-Zuweisung bei Lizenzerstellung""" - data = request.json - license_id = data.get('license_id') - domain_count = data.get('domain_count', 1) - ipv4_count = data.get('ipv4_count', 1) - phone_count = data.get('phone_count', 1) - - conn = get_connection() - cur = conn.cursor() - - try: - allocated = {'domains': [], 'ipv4s': [], 'phones': []} - - # Allocate domains - if domain_count > 0: - cur.execute(""" - SELECT id, resource_value FROM resource_pools - WHERE resource_type = 'domain' AND status = 'available' - LIMIT %s FOR UPDATE - """, (domain_count,)) - domains = cur.fetchall() - - if len(domains) < domain_count: - raise ValueError(f"Nicht genügend Domains verfügbar (benötigt: {domain_count}, verfügbar: {len(domains)})") - - for domain_id, domain_value in domains: - # Update resource status - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', - allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, - status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], domain_id)) - - # Create assignment - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, domain_id, session['username'])) - - # Log history - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (domain_id, license_id, session['username'], get_client_ip())) - - allocated['domains'].append(domain_value) - - # Allocate IPv4s (similar logic) - if ipv4_count > 0: - cur.execute(""" - SELECT id, resource_value FROM resource_pools - WHERE resource_type = 'ipv4' AND status = 'available' - LIMIT %s FOR UPDATE - """, (ipv4_count,)) - ipv4s = cur.fetchall() - - if len(ipv4s) < ipv4_count: - raise ValueError(f"Nicht genügend IPv4-Adressen verfügbar") - - for ipv4_id, ipv4_value in ipv4s: - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', - allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, - status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], ipv4_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, ipv4_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (ipv4_id, license_id, session['username'], get_client_ip())) - - allocated['ipv4s'].append(ipv4_value) - - # Allocate phones (similar logic) - if phone_count > 0: - cur.execute(""" - SELECT id, resource_value FROM resource_pools - WHERE resource_type = 'phone' AND status = 'available' - LIMIT %s FOR UPDATE - """, (phone_count,)) - phones = cur.fetchall() - - if len(phones) < phone_count: - raise ValueError(f"Nicht genügend Telefonnummern verfügbar") - - for phone_id, phone_value in phones: - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', - allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, - status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], phone_id)) - - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, phone_id, session['username'])) - - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (phone_id, license_id, session['username'], get_client_ip())) - - allocated['phones'].append(phone_value) - - # Update license resource counts - cur.execute(""" - UPDATE licenses - SET domain_count = %s, - ipv4_count = %s, - phone_count = %s - WHERE id = %s - """, (domain_count, ipv4_count, phone_count, license_id)) - - conn.commit() - cur.close() - conn.close() - - return jsonify({ - 'success': True, - 'allocated': allocated - }) - - except Exception as e: - conn.rollback() - cur.close() - conn.close() - return jsonify({ - 'success': False, - 'error': str(e) - }), 400 - -@app.route('/api/resources/check-availability', methods=['GET']) -@login_required -def check_resource_availability(): - """Prüft verfügbare Ressourcen""" - resource_type = request.args.get('type', '') - count = request.args.get('count', 10, type=int) - show_test = request.args.get('show_test', 'false').lower() == 'true' - - conn = get_connection() - cur = conn.cursor() - - if resource_type: - # Spezifische Ressourcen für einen Typ - cur.execute(""" - SELECT id, resource_value - FROM resource_pools - WHERE status = 'available' - AND resource_type = %s - AND is_test = %s - ORDER BY resource_value - LIMIT %s - """, (resource_type, show_test, count)) - - resources = [] - for row in cur.fetchall(): - resources.append({ - 'id': row[0], - 'value': row[1] - }) - - cur.close() - conn.close() - - return jsonify({ - 'available': resources, - 'type': resource_type, - 'count': len(resources) - }) - else: - # Zusammenfassung aller Typen - cur.execute(""" - SELECT - resource_type, - COUNT(*) as available - FROM resource_pools - WHERE status = 'available' - AND is_test = %s - GROUP BY resource_type - """, (show_test,)) - - availability = {} - for row in cur.fetchall(): - availability[row[0]] = row[1] - - cur.close() - conn.close() - - return jsonify(availability) - -@app.route('/api/global-search', methods=['GET']) -@login_required -def global_search(): - """Global search API endpoint for searching customers and licenses""" - query = request.args.get('q', '').strip() - - if not query or len(query) < 2: - return jsonify({'customers': [], 'licenses': []}) - - conn = get_connection() - cur = conn.cursor() - - # Search pattern with wildcards - search_pattern = f'%{query}%' - - # Search customers - cur.execute(""" - SELECT id, name, email, company_name - FROM customers - WHERE (LOWER(name) LIKE LOWER(%s) - OR LOWER(email) LIKE LOWER(%s) - OR LOWER(company_name) LIKE LOWER(%s)) - AND is_test = FALSE - ORDER BY name - LIMIT 5 - """, (search_pattern, search_pattern, search_pattern)) - - customers = [] - for row in cur.fetchall(): - customers.append({ - 'id': row[0], - 'name': row[1], - 'email': row[2], - 'company_name': row[3] - }) - - # Search licenses - cur.execute(""" - SELECT l.id, l.license_key, c.name as customer_name - FROM licenses l - JOIN customers c ON l.customer_id = c.id - WHERE LOWER(l.license_key) LIKE LOWER(%s) - AND l.is_test = FALSE - ORDER BY l.created_at DESC - LIMIT 5 - """, (search_pattern,)) - - licenses = [] - for row in cur.fetchall(): - licenses.append({ - 'id': row[0], - 'license_key': row[1], - 'customer_name': row[2] - }) - - cur.close() - conn.close() - - return jsonify({ - 'customers': customers, - 'licenses': licenses - }) - -@app.route('/resources/history/') -@login_required -def resource_history(resource_id): - """Zeigt die komplette Historie einer Ressource""" - conn = get_connection() - cur = conn.cursor() - - # Get complete resource info using named columns - cur.execute(""" - SELECT id, resource_type, resource_value, status, allocated_to_license, - status_changed_at, status_changed_by, quarantine_reason, - quarantine_until, created_at, notes - FROM resource_pools - WHERE id = %s - """, (resource_id,)) - row = cur.fetchone() - - if not row: - flash('Ressource nicht gefunden', 'error') - return redirect(url_for('resources')) - - # Create resource object with named attributes - resource = { - 'id': row[0], - 'resource_type': row[1], - 'resource_value': row[2], - 'status': row[3], - 'allocated_to_license': row[4], - 'status_changed_at': row[5], - 'status_changed_by': row[6], - 'quarantine_reason': row[7], - 'quarantine_until': row[8], - 'created_at': row[9], - 'notes': row[10] - } - - # Get license info if allocated - license_info = None - if resource['allocated_to_license']: - cur.execute("SELECT license_key FROM licenses WHERE id = %s", - (resource['allocated_to_license'],)) - lic = cur.fetchone() - if lic: - license_info = {'license_key': lic[0]} - - # Get history with named columns - cur.execute(""" - SELECT - rh.action, - rh.action_by, - rh.action_at, - rh.details, - rh.license_id, - rh.ip_address - FROM resource_history rh - WHERE rh.resource_id = %s - ORDER BY rh.action_at DESC - """, (resource_id,)) - - history = [] - for row in cur.fetchall(): - history.append({ - 'action': row[0], - 'action_by': row[1], - 'action_at': row[2], - 'details': row[3], - 'license_id': row[4], - 'ip_address': row[5] - }) - - cur.close() - conn.close() - - # Convert to object-like for template - class ResourceObj: - def __init__(self, data): - for key, value in data.items(): - setattr(self, key, value) - - resource_obj = ResourceObj(resource) - history_objs = [ResourceObj(h) for h in history] - - return render_template('resource_history.html', - resource=resource_obj, - license_info=license_info, - history=history_objs) - -@app.route('/resources/metrics') -@login_required -def resources_metrics(): - """Dashboard für Resource Metrics und Reports""" - conn = get_connection() - cur = conn.cursor() - - # Overall stats with fallback values - cur.execute(""" - SELECT - COUNT(DISTINCT resource_id) as total_resources, - COALESCE(AVG(performance_score), 0) as avg_performance, - COALESCE(SUM(cost), 0) as total_cost, - COALESCE(SUM(revenue), 0) as total_revenue, - COALESCE(SUM(issues_count), 0) as total_issues - FROM resource_metrics - WHERE metric_date >= CURRENT_DATE - INTERVAL '30 days' - """) - row = cur.fetchone() - - # Calculate ROI - roi = 0 - if row[2] > 0: # if total_cost > 0 - roi = row[3] / row[2] # revenue / cost - - stats = { - 'total_resources': row[0] or 0, - 'avg_performance': row[1] or 0, - 'total_cost': row[2] or 0, - 'total_revenue': row[3] or 0, - 'total_issues': row[4] or 0, - 'roi': roi - } - - # Performance by type - cur.execute(""" - SELECT - rp.resource_type, - COALESCE(AVG(rm.performance_score), 0) as avg_score, - COUNT(DISTINCT rp.id) as resource_count - FROM resource_pools rp - LEFT JOIN resource_metrics rm ON rp.id = rm.resource_id - AND rm.metric_date >= CURRENT_DATE - INTERVAL '30 days' - GROUP BY rp.resource_type - ORDER BY rp.resource_type - """) - performance_by_type = cur.fetchall() - - # Utilization data - cur.execute(""" - SELECT - resource_type, - COUNT(*) FILTER (WHERE status = 'allocated') as allocated, - COUNT(*) as total, - ROUND(COUNT(*) FILTER (WHERE status = 'allocated') * 100.0 / COUNT(*), 1) as allocated_percent - FROM resource_pools - GROUP BY resource_type - """) - utilization_rows = cur.fetchall() - utilization_data = [ - { - 'type': row[0].upper(), - 'allocated': row[1], - 'total': row[2], - 'allocated_percent': row[3] - } - for row in utilization_rows - ] - - # Top performing resources - cur.execute(""" - SELECT - rp.id, - rp.resource_type, - rp.resource_value, - COALESCE(AVG(rm.performance_score), 0) as avg_score, - COALESCE(SUM(rm.revenue), 0) as total_revenue, - COALESCE(SUM(rm.cost), 1) as total_cost, - CASE - WHEN COALESCE(SUM(rm.cost), 0) = 0 THEN 0 - ELSE COALESCE(SUM(rm.revenue), 0) / COALESCE(SUM(rm.cost), 1) - END as roi - FROM resource_pools rp - LEFT JOIN resource_metrics rm ON rp.id = rm.resource_id - AND rm.metric_date >= CURRENT_DATE - INTERVAL '30 days' - WHERE rp.status != 'quarantine' - GROUP BY rp.id, rp.resource_type, rp.resource_value - HAVING AVG(rm.performance_score) IS NOT NULL - ORDER BY avg_score DESC - LIMIT 10 - """) - top_rows = cur.fetchall() - top_performers = [ - { - 'id': row[0], - 'resource_type': row[1], - 'resource_value': row[2], - 'avg_score': row[3], - 'roi': row[6] - } - for row in top_rows - ] - - # Resources with issues - cur.execute(""" - SELECT - rp.id, - rp.resource_type, - rp.resource_value, - rp.status, - COALESCE(SUM(rm.issues_count), 0) as total_issues - FROM resource_pools rp - LEFT JOIN resource_metrics rm ON rp.id = rm.resource_id - AND rm.metric_date >= CURRENT_DATE - INTERVAL '30 days' - WHERE rm.issues_count > 0 OR rp.status = 'quarantine' - GROUP BY rp.id, rp.resource_type, rp.resource_value, rp.status - HAVING SUM(rm.issues_count) > 0 - ORDER BY total_issues DESC - LIMIT 10 - """) - problem_rows = cur.fetchall() - problem_resources = [ - { - 'id': row[0], - 'resource_type': row[1], - 'resource_value': row[2], - 'status': row[3], - 'total_issues': row[4] - } - for row in problem_rows - ] - - # Daily metrics for trend chart (last 30 days) - cur.execute(""" - SELECT - metric_date, - COALESCE(AVG(performance_score), 0) as avg_performance, - COALESCE(SUM(issues_count), 0) as total_issues - FROM resource_metrics - WHERE metric_date >= CURRENT_DATE - INTERVAL '30 days' - GROUP BY metric_date - ORDER BY metric_date - """) - daily_rows = cur.fetchall() - daily_metrics = [ - { - 'date': row[0].strftime('%d.%m'), - 'performance': float(row[1]), - 'issues': int(row[2]) - } - for row in daily_rows - ] - - cur.close() - conn.close() - - return render_template('resource_metrics.html', - stats=stats, - performance_by_type=performance_by_type, - utilization_data=utilization_data, - top_performers=top_performers, - problem_resources=problem_resources, - daily_metrics=daily_metrics) - -@app.route('/resources/report', methods=['GET']) -@login_required -def resources_report(): - """Generiert Ressourcen-Reports oder zeigt Report-Formular""" - # Prüfe ob Download angefordert wurde - if request.args.get('download') == 'true': - report_type = request.args.get('type', 'usage') - format_type = request.args.get('format', 'excel') - date_from = request.args.get('from', (datetime.now(ZoneInfo("Europe/Berlin")) - timedelta(days=30)).strftime('%Y-%m-%d')) - date_to = request.args.get('to', datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y-%m-%d')) - - conn = get_connection() - cur = conn.cursor() - - if report_type == 'usage': - # Auslastungsreport - query = """ - SELECT - rp.resource_type, - rp.resource_value, - rp.status, - COUNT(DISTINCT rh.license_id) as unique_licenses, - COUNT(rh.id) as total_allocations, - MIN(rh.action_at) as first_used, - MAX(rh.action_at) as last_used - FROM resource_pools rp - LEFT JOIN resource_history rh ON rp.id = rh.resource_id - AND rh.action = 'allocated' - AND rh.action_at BETWEEN %s AND %s - GROUP BY rp.id, rp.resource_type, rp.resource_value, rp.status - ORDER BY rp.resource_type, total_allocations DESC - """ - cur.execute(query, (date_from, date_to)) - columns = ['Typ', 'Ressource', 'Status', 'Unique Lizenzen', 'Gesamt Zuweisungen', 'Erste Nutzung', 'Letzte Nutzung'] - - elif report_type == 'performance': - # Performance-Report - query = """ - SELECT - rp.resource_type, - rp.resource_value, - AVG(rm.performance_score) as avg_performance, - SUM(rm.usage_count) as total_usage, - SUM(rm.revenue) as total_revenue, - SUM(rm.cost) as total_cost, - SUM(rm.revenue - rm.cost) as profit, - SUM(rm.issues_count) as total_issues - FROM resource_pools rp - JOIN resource_metrics rm ON rp.id = rm.resource_id - WHERE rm.metric_date BETWEEN %s AND %s - GROUP BY rp.id, rp.resource_type, rp.resource_value - ORDER BY profit DESC - """ - cur.execute(query, (date_from, date_to)) - columns = ['Typ', 'Ressource', 'Durchschn. Performance', 'Gesamt Nutzung', 'Umsatz', 'Kosten', 'Gewinn', 'Issues'] - - elif report_type == 'compliance': - # Compliance-Report - query = """ - SELECT - rh.action_at, - rh.action, - rh.action_by, - rp.resource_type, - rp.resource_value, - l.license_key, - c.name as customer_name, - rh.ip_address - FROM resource_history rh - JOIN resource_pools rp ON rh.resource_id = rp.id - LEFT JOIN licenses l ON rh.license_id = l.id - LEFT JOIN customers c ON l.customer_id = c.id - WHERE rh.action_at BETWEEN %s AND %s - ORDER BY rh.action_at DESC - """ - cur.execute(query, (date_from, date_to)) - columns = ['Zeit', 'Aktion', 'Von', 'Typ', 'Ressource', 'Lizenz', 'Kunde', 'IP-Adresse'] - - else: # inventory report - # Inventar-Report - query = """ - SELECT - resource_type, - COUNT(*) FILTER (WHERE status = 'available') as available, - COUNT(*) FILTER (WHERE status = 'allocated') as allocated, - COUNT(*) FILTER (WHERE status = 'quarantine') as quarantine, - COUNT(*) as total - FROM resource_pools - GROUP BY resource_type - ORDER BY resource_type - """ - cur.execute(query) - columns = ['Typ', 'Verfügbar', 'Zugeteilt', 'Quarantäne', 'Gesamt'] - - # Convert to DataFrame - data = cur.fetchall() - df = pd.DataFrame(data, columns=columns) - - cur.close() - conn.close() - - # Generate file - timestamp = datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y%m%d_%H%M%S') - filename = f"resource_report_{report_type}_{timestamp}" - - if format_type == 'excel': - output = io.BytesIO() - with pd.ExcelWriter(output, engine='openpyxl') as writer: - df.to_excel(writer, sheet_name='Report', index=False) - - # Auto-adjust columns width - worksheet = writer.sheets['Report'] - for column in worksheet.columns: - max_length = 0 - column = [cell for cell in column] - for cell in column: - try: - if len(str(cell.value)) > max_length: - max_length = len(str(cell.value)) - except: - pass - adjusted_width = (max_length + 2) - worksheet.column_dimensions[column[0].column_letter].width = adjusted_width - - output.seek(0) - - log_audit('EXPORT', 'resource_report', None, - new_values={'type': report_type, 'format': 'excel', 'rows': len(df)}, - additional_info=f"Resource Report {report_type} exportiert") - - return send_file(output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=f'{filename}.xlsx') - - else: # CSV - output = io.StringIO() - df.to_csv(output, index=False, sep=';', encoding='utf-8-sig') - output.seek(0) - - log_audit('EXPORT', 'resource_report', None, - new_values={'type': report_type, 'format': 'csv', 'rows': len(df)}, - additional_info=f"Resource Report {report_type} exportiert") - - return send_file(io.BytesIO(output.getvalue().encode('utf-8-sig')), - mimetype='text/csv', - as_attachment=True, - download_name=f'{filename}.csv') - - # Wenn kein Download, zeige Report-Formular - return render_template('resource_report.html', - datetime=datetime, - timedelta=timedelta, - username=session.get('username')) - -if __name__ == "__main__": - app.run(host="0.0.0.0", port=5000) diff --git a/v2_adminpanel/cleanup_commented_routes.py b/v2_adminpanel/cleanup_commented_routes.py deleted file mode 100644 index 9422603..0000000 --- a/v2_adminpanel/cleanup_commented_routes.py +++ /dev/null @@ -1,156 +0,0 @@ -#!/usr/bin/env python3 -""" -Cleanup-Skript zum sicheren Entfernen auskommentierter Routes aus app.py -Entfernt alle auskommentierten @app.route Blöcke nach der Blueprint-Migration -""" - -import re -import sys -from datetime import datetime - -def cleanup_commented_routes(file_path): - """Entfernt auskommentierte Route-Blöcke aus app.py""" - - with open(file_path, 'r', encoding='utf-8') as f: - lines = f.readlines() - - print(f"📄 Datei geladen: {len(lines)} Zeilen") - - cleaned_lines = [] - in_commented_block = False - block_start_line = -1 - removed_blocks = [] - - i = 0 - while i < len(lines): - line = lines[i] - - # Prüfe ob eine auskommentierte Route beginnt - if re.match(r'^# @app\.route\(', line.strip()): - in_commented_block = True - block_start_line = i + 1 # 1-basiert für Anzeige - block_lines = [line] - i += 1 - - # Sammle den ganzen auskommentierten Block - while i < len(lines): - current_line = lines[i] - - # Block endet wenn: - # 1. Eine neue Funktion/Route beginnt (nicht auskommentiert) - # 2. Eine Leerzeile nach mehreren kommentierten Zeilen - # 3. Eine neue auskommentierte Route beginnt - - if re.match(r'^@', current_line.strip()) or \ - re.match(r'^def\s+\w+', current_line.strip()) or \ - re.match(r'^class\s+\w+', current_line.strip()): - # Nicht-kommentierter Code gefunden, Block endet - break - - if re.match(r'^# @app\.route\(', current_line.strip()) and len(block_lines) > 1: - # Neue auskommentierte Route, vorheriger Block endet - break - - # Prüfe ob die Zeile zum kommentierten Block gehört - if current_line.strip() == '' and i + 1 < len(lines): - # Schaue voraus - next_line = lines[i + 1] - if not (next_line.strip().startswith('#') or next_line.strip() == ''): - # Leerzeile gefolgt von nicht-kommentiertem Code - block_lines.append(current_line) - i += 1 - break - - if current_line.strip().startswith('#') or current_line.strip() == '': - block_lines.append(current_line) - i += 1 - else: - # Nicht-kommentierte Zeile gefunden, Block endet - break - - # Entferne trailing Leerzeilen vom Block - while block_lines and block_lines[-1].strip() == '': - block_lines.pop() - i -= 1 - - removed_blocks.append({ - 'start_line': block_start_line, - 'end_line': block_start_line + len(block_lines) - 1, - 'lines': len(block_lines), - 'route': extract_route_info(block_lines) - }) - - in_commented_block = False - - # Füge eine Leerzeile ein wenn nötig (um Abstand zu wahren) - if cleaned_lines and cleaned_lines[-1].strip() != '' and \ - i < len(lines) and lines[i].strip() != '': - cleaned_lines.append('\n') - - else: - cleaned_lines.append(line) - i += 1 - - return cleaned_lines, removed_blocks - -def extract_route_info(block_lines): - """Extrahiert Route-Information aus dem kommentierten Block""" - for line in block_lines: - match = re.search(r'# @app\.route\("([^"]+)"', line) - if match: - return match.group(1) - return "Unknown" - -def main(): - file_path = 'app.py' - backup_path = f'app.py.backup_before_cleanup_{datetime.now().strftime("%Y%m%d_%H%M%S")}' - - print("🧹 Starte Cleanup der auskommentierten Routes...\n") - - # Backup erstellen - print(f"💾 Erstelle Backup: {backup_path}") - with open(file_path, 'r', encoding='utf-8') as f: - content = f.read() - with open(backup_path, 'w', encoding='utf-8') as f: - f.write(content) - - # Cleanup durchführen - cleaned_lines, removed_blocks = cleanup_commented_routes(file_path) - - # Statistiken anzeigen - print(f"\n📊 Cleanup-Statistiken:") - print(f" Entfernte Blöcke: {len(removed_blocks)}") - print(f" Entfernte Zeilen: {sum(block['lines'] for block in removed_blocks)}") - print(f" Neue Dateigröße: {len(cleaned_lines)} Zeilen") - - print(f"\n🗑️ Entfernte Routes:") - for block in removed_blocks: - print(f" Zeilen {block['start_line']}-{block['end_line']}: {block['route']} ({block['lines']} Zeilen)") - - # Bestätigung abfragen - print(f"\n⚠️ Diese Operation wird {sum(block['lines'] for block in removed_blocks)} Zeilen entfernen!") - response = input("Fortfahren? (ja/nein): ") - - if response.lower() in ['ja', 'j', 'yes', 'y']: - # Bereinigte Datei schreiben - with open(file_path, 'w', encoding='utf-8') as f: - f.writelines(cleaned_lines) - print(f"\n✅ Cleanup abgeschlossen! Backup gespeichert als: {backup_path}") - - # Größenvergleich - import os - old_size = os.path.getsize(backup_path) - new_size = os.path.getsize(file_path) - reduction = old_size - new_size - reduction_percent = (reduction / old_size) * 100 - - print(f"\n📉 Dateigrößen-Reduktion:") - print(f" Vorher: {old_size:,} Bytes") - print(f" Nachher: {new_size:,} Bytes") - print(f" Reduziert um: {reduction:,} Bytes ({reduction_percent:.1f}%)") - - else: - print("\n❌ Cleanup abgebrochen. Keine Änderungen vorgenommen.") - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/v2_adminpanel/cleanup_commented_routes_auto.py b/v2_adminpanel/cleanup_commented_routes_auto.py deleted file mode 100644 index ad50790..0000000 --- a/v2_adminpanel/cleanup_commented_routes_auto.py +++ /dev/null @@ -1,153 +0,0 @@ -#!/usr/bin/env python3 -""" -Automatisches Cleanup-Skript zum Entfernen auskommentierter Routes aus app.py -Führt das Cleanup ohne Benutzerinteraktion durch -""" - -import re -import sys -from datetime import datetime -import os - -def cleanup_commented_routes(file_path): - """Entfernt auskommentierte Route-Blöcke aus app.py""" - - with open(file_path, 'r', encoding='utf-8') as f: - lines = f.readlines() - - print(f"📄 Datei geladen: {len(lines)} Zeilen") - - cleaned_lines = [] - removed_blocks = [] - - i = 0 - while i < len(lines): - line = lines[i] - - # Prüfe ob eine auskommentierte Route beginnt - if re.match(r'^# @app\.route\(', line.strip()): - block_start_line = i + 1 # 1-basiert für Anzeige - block_lines = [line] - i += 1 - - # Sammle den ganzen auskommentierten Block - while i < len(lines): - current_line = lines[i] - - # Block endet wenn: - # 1. Eine neue Funktion/Route beginnt (nicht auskommentiert) - # 2. Eine neue auskommentierte Route beginnt - # 3. Nicht-kommentierter Code gefunden wird - - if re.match(r'^@', current_line.strip()) or \ - re.match(r'^def\s+\w+', current_line.strip()) or \ - re.match(r'^class\s+\w+', current_line.strip()): - # Nicht-kommentierter Code gefunden, Block endet - break - - if re.match(r'^# @app\.route\(', current_line.strip()) and len(block_lines) > 1: - # Neue auskommentierte Route, vorheriger Block endet - break - - # Prüfe ob die Zeile zum kommentierten Block gehört - if current_line.strip() == '' and i + 1 < len(lines): - # Schaue voraus - next_line = lines[i + 1] - if not (next_line.strip().startswith('#') or next_line.strip() == ''): - # Leerzeile gefolgt von nicht-kommentiertem Code - block_lines.append(current_line) - i += 1 - break - - if current_line.strip().startswith('#') or current_line.strip() == '': - block_lines.append(current_line) - i += 1 - else: - # Nicht-kommentierte Zeile gefunden, Block endet - break - - # Entferne trailing Leerzeilen vom Block - while block_lines and block_lines[-1].strip() == '': - block_lines.pop() - i -= 1 - - removed_blocks.append({ - 'start_line': block_start_line, - 'end_line': block_start_line + len(block_lines) - 1, - 'lines': len(block_lines), - 'route': extract_route_info(block_lines) - }) - - # Füge eine Leerzeile ein wenn nötig (um Abstand zu wahren) - if cleaned_lines and cleaned_lines[-1].strip() != '' and \ - i < len(lines) and lines[i].strip() != '': - cleaned_lines.append('\n') - - else: - cleaned_lines.append(line) - i += 1 - - return cleaned_lines, removed_blocks - -def extract_route_info(block_lines): - """Extrahiert Route-Information aus dem kommentierten Block""" - for line in block_lines: - match = re.search(r'# @app\.route\("([^"]+)"', line) - if match: - return match.group(1) - return "Unknown" - -def main(): - file_path = 'app.py' - backup_path = f'app.py.backup_before_cleanup_{datetime.now().strftime("%Y%m%d_%H%M%S")}' - - print("🧹 Starte automatisches Cleanup der auskommentierten Routes...\n") - - # Backup erstellen - print(f"💾 Erstelle Backup: {backup_path}") - with open(file_path, 'r', encoding='utf-8') as f: - content = f.read() - with open(backup_path, 'w', encoding='utf-8') as f: - f.write(content) - - # Cleanup durchführen - cleaned_lines, removed_blocks = cleanup_commented_routes(file_path) - - # Statistiken anzeigen - print(f"\n📊 Cleanup-Statistiken:") - print(f" Entfernte Blöcke: {len(removed_blocks)}") - print(f" Entfernte Zeilen: {sum(block['lines'] for block in removed_blocks)}") - print(f" Neue Dateigröße: {len(cleaned_lines)} Zeilen") - - if len(removed_blocks) > 10: - print(f"\n🗑️ Erste 10 entfernte Routes:") - for block in removed_blocks[:10]: - print(f" Zeilen {block['start_line']}-{block['end_line']}: {block['route']} ({block['lines']} Zeilen)") - print(f" ... und {len(removed_blocks) - 10} weitere Routes") - else: - print(f"\n🗑️ Entfernte Routes:") - for block in removed_blocks: - print(f" Zeilen {block['start_line']}-{block['end_line']}: {block['route']} ({block['lines']} Zeilen)") - - # Bereinigte Datei schreiben - print(f"\n✍️ Schreibe bereinigte Datei...") - with open(file_path, 'w', encoding='utf-8') as f: - f.writelines(cleaned_lines) - - # Größenvergleich - old_size = os.path.getsize(backup_path) - new_size = os.path.getsize(file_path) - reduction = old_size - new_size - reduction_percent = (reduction / old_size) * 100 - - print(f"\n📉 Dateigrößen-Reduktion:") - print(f" Vorher: {old_size:,} Bytes") - print(f" Nachher: {new_size:,} Bytes") - print(f" Reduziert um: {reduction:,} Bytes ({reduction_percent:.1f}%)") - - print(f"\n✅ Cleanup erfolgreich abgeschlossen!") - print(f" Backup: {backup_path}") - print(f" Rollback möglich mit: cp {backup_path} app.py") - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/v2_adminpanel/cookies.txt b/v2_adminpanel/cookies.txt deleted file mode 100644 index bc84d1a..0000000 --- a/v2_adminpanel/cookies.txt +++ /dev/null @@ -1,5 +0,0 @@ -# Netscape HTTP Cookie File -# https://curl.se/docs/http-cookies.html -# This file was generated by libcurl! Edit at your own risk. - -#HttpOnly_localhost FALSE / FALSE 1749329847 admin_session aojqyq4GcSt5oT7NJPeg7UHPoEZUVkn-s1Kr-EAnJWM diff --git a/v2_adminpanel/create_users_table.sql b/v2_adminpanel/create_users_table.sql deleted file mode 100644 index 202f4e8..0000000 --- a/v2_adminpanel/create_users_table.sql +++ /dev/null @@ -1,20 +0,0 @@ --- Create users table if it doesn't exist -CREATE TABLE IF NOT EXISTS users ( - id SERIAL PRIMARY KEY, - username VARCHAR(50) UNIQUE NOT NULL, - password_hash VARCHAR(255) NOT NULL, - email VARCHAR(100), - totp_secret VARCHAR(32), - totp_enabled BOOLEAN DEFAULT FALSE, - backup_codes TEXT, -- JSON array of hashed backup codes - created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, - last_password_change TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, - password_reset_token VARCHAR(64), - password_reset_expires TIMESTAMP WITH TIME ZONE, - failed_2fa_attempts INTEGER DEFAULT 0, - last_failed_2fa TIMESTAMP WITH TIME ZONE -); - --- Index for faster login lookups -CREATE INDEX IF NOT EXISTS idx_users_username ON users(username); -CREATE INDEX IF NOT EXISTS idx_users_reset_token ON users(password_reset_token) WHERE password_reset_token IS NOT NULL; \ No newline at end of file diff --git a/v2_adminpanel/fix_license_keys.sql b/v2_adminpanel/fix_license_keys.sql deleted file mode 100644 index da6c431..0000000 --- a/v2_adminpanel/fix_license_keys.sql +++ /dev/null @@ -1,13 +0,0 @@ --- Fix für die fehlerhafte Migration - entfernt doppelte Bindestriche -UPDATE licenses -SET license_key = REPLACE(license_key, 'AF--', 'AF-') -WHERE license_key LIKE 'AF--%'; - -UPDATE licenses -SET license_key = REPLACE(license_key, '6--', '6-') -WHERE license_key LIKE '%6--%'; - --- Zeige die korrigierten Keys -SELECT id, license_key, license_type -FROM licenses -ORDER BY id; \ No newline at end of file diff --git a/v2_adminpanel/mark_resources_as_test.sql b/v2_adminpanel/mark_resources_as_test.sql deleted file mode 100644 index 2377e7a..0000000 --- a/v2_adminpanel/mark_resources_as_test.sql +++ /dev/null @@ -1,5 +0,0 @@ --- Markiere alle existierenden Ressourcen als Testdaten -UPDATE resource_pools SET is_test = TRUE WHERE is_test = FALSE OR is_test IS NULL; - --- Zeige Anzahl der aktualisierten Ressourcen -SELECT COUNT(*) as updated_resources FROM resource_pools WHERE is_test = TRUE; \ No newline at end of file diff --git a/v2_adminpanel/migrate_device_limit.sql b/v2_adminpanel/migrate_device_limit.sql deleted file mode 100644 index d62291f..0000000 --- a/v2_adminpanel/migrate_device_limit.sql +++ /dev/null @@ -1,13 +0,0 @@ --- Migration: Setze device_limit für bestehende Test-Lizenzen auf 3 --- Dieses Script wird nur einmal ausgeführt, um bestehende Lizenzen zu aktualisieren - --- Setze device_limit = 3 für alle bestehenden Lizenzen, die noch keinen Wert haben -UPDATE licenses -SET device_limit = 3 -WHERE device_limit IS NULL; - --- Bestätige die Änderung -SELECT COUNT(*) as updated_licenses, - COUNT(CASE WHEN is_test = TRUE THEN 1 END) as test_licenses_updated -FROM licenses -WHERE device_limit = 3; \ No newline at end of file diff --git a/v2_adminpanel/migrate_license_keys.sql b/v2_adminpanel/migrate_license_keys.sql deleted file mode 100644 index 6ff91e6..0000000 --- a/v2_adminpanel/migrate_license_keys.sql +++ /dev/null @@ -1,54 +0,0 @@ --- Migration der Lizenzschlüssel vom alten Format zum neuen Format --- Alt: AF-YYYYMMFT-XXXX-YYYY-ZZZZ --- Neu: AF-F-YYYYMM-XXXX-YYYY-ZZZZ - --- Backup der aktuellen Schlüssel erstellen (für Sicherheit) -CREATE TEMP TABLE license_backup AS -SELECT id, license_key FROM licenses; - --- Update für Fullversion Keys (F) -UPDATE licenses -SET license_key = - CONCAT( - SUBSTRING(license_key, 1, 3), -- 'AF-' - '-F-', - SUBSTRING(license_key, 4, 6), -- 'YYYYMM' - '-', - SUBSTRING(license_key, 11) -- Rest des Keys - ) -WHERE license_key LIKE 'AF-%F-%' - AND license_type = 'full' - AND license_key NOT LIKE 'AF-F-%'; -- Nicht bereits migriert - --- Update für Testversion Keys (T) -UPDATE licenses -SET license_key = - CONCAT( - SUBSTRING(license_key, 1, 3), -- 'AF-' - '-T-', - SUBSTRING(license_key, 4, 6), -- 'YYYYMM' - '-', - SUBSTRING(license_key, 11) -- Rest des Keys - ) -WHERE license_key LIKE 'AF-%T-%' - AND license_type = 'test' - AND license_key NOT LIKE 'AF-T-%'; -- Nicht bereits migriert - --- Zeige die Änderungen -SELECT - b.license_key as old_key, - l.license_key as new_key, - l.license_type -FROM licenses l -JOIN license_backup b ON l.id = b.id -WHERE b.license_key != l.license_key -ORDER BY l.id; - --- Anzahl der migrierten Keys -SELECT - COUNT(*) as total_migrated, - SUM(CASE WHEN license_type = 'full' THEN 1 ELSE 0 END) as full_licenses, - SUM(CASE WHEN license_type = 'test' THEN 1 ELSE 0 END) as test_licenses -FROM licenses l -JOIN license_backup b ON l.id = b.id -WHERE b.license_key != l.license_key; \ No newline at end of file diff --git a/v2_adminpanel/migrate_users.py b/v2_adminpanel/migrate_users.py deleted file mode 100644 index 106833d..0000000 --- a/v2_adminpanel/migrate_users.py +++ /dev/null @@ -1,78 +0,0 @@ -#!/usr/bin/env python3 -""" -Migration script to create initial users in the database from environment variables -Run this once after creating the users table -""" - -import os -import psycopg2 -import bcrypt -from dotenv import load_dotenv -from datetime import datetime - -load_dotenv() - -def get_connection(): - return psycopg2.connect( - host=os.getenv("POSTGRES_HOST", "postgres"), - port=os.getenv("POSTGRES_PORT", "5432"), - dbname=os.getenv("POSTGRES_DB"), - user=os.getenv("POSTGRES_USER"), - password=os.getenv("POSTGRES_PASSWORD"), - options='-c client_encoding=UTF8' - ) - -def hash_password(password): - return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8') - -def migrate_users(): - conn = get_connection() - cur = conn.cursor() - - try: - # Check if users already exist - cur.execute("SELECT COUNT(*) FROM users") - user_count = cur.fetchone()[0] - - if user_count > 0: - print(f"Users table already contains {user_count} users. Skipping migration.") - return - - # Get admin users from environment - admin1_user = os.getenv("ADMIN1_USERNAME") - admin1_pass = os.getenv("ADMIN1_PASSWORD") - admin2_user = os.getenv("ADMIN2_USERNAME") - admin2_pass = os.getenv("ADMIN2_PASSWORD") - - if not all([admin1_user, admin1_pass, admin2_user, admin2_pass]): - print("ERROR: Admin credentials not found in environment variables!") - return - - # Insert admin users - users = [ - (admin1_user, hash_password(admin1_pass), f"{admin1_user}@v2-admin.local"), - (admin2_user, hash_password(admin2_pass), f"{admin2_user}@v2-admin.local") - ] - - for username, password_hash, email in users: - cur.execute(""" - INSERT INTO users (username, password_hash, email, totp_enabled, created_at) - VALUES (%s, %s, %s, %s, %s) - """, (username, password_hash, email, False, datetime.now())) - print(f"Created user: {username}") - - conn.commit() - print("\nMigration completed successfully!") - print("Users can now log in with their existing credentials.") - print("They can enable 2FA from their profile page.") - - except Exception as e: - conn.rollback() - print(f"ERROR during migration: {e}") - finally: - cur.close() - conn.close() - -if __name__ == "__main__": - print("Starting user migration...") - migrate_users() \ No newline at end of file diff --git a/v2_adminpanel/remove_duplicate_routes.py b/v2_adminpanel/remove_duplicate_routes.py deleted file mode 100644 index 648aab0..0000000 --- a/v2_adminpanel/remove_duplicate_routes.py +++ /dev/null @@ -1,52 +0,0 @@ -#!/usr/bin/env python3 -""" -Remove duplicate routes that have been moved to blueprints -""" - -import re - -# Read the current app.py -with open('app.py', 'r') as f: - content = f.read() - -# List of function names that have been moved to blueprints -moved_functions = [ - # Auth routes - 'login', - 'logout', - 'verify_2fa', - 'profile', - 'change_password', - 'setup_2fa', - 'enable_2fa', - 'disable_2fa', - 'heartbeat', - # Admin routes - 'dashboard', - 'audit_log', - 'backups', - 'create_backup_route', - 'restore_backup_route', - 'download_backup', - 'delete_backup', - 'blocked_ips', - 'unblock_ip', - 'clear_attempts' -] - -# Create a pattern to match route decorators and their functions -for func_name in moved_functions: - # Pattern to match from @app.route to the end of the function - pattern = rf'@app\.route\([^)]+\)\s*(?:@login_required\s*)?def {func_name}\([^)]*\):.*?(?=\n@app\.route|\n@[a-zA-Z]|\nif __name__|$)' - - # Replace with a comment - replacement = f'# Function {func_name} moved to blueprint' - - content = re.sub(pattern, replacement, content, flags=re.DOTALL) - -# Write the modified content -with open('app_no_duplicates.py', 'w') as f: - f.write(content) - -print("Created app_no_duplicates.py with duplicate routes removed") -print("Please review the file before using it") \ No newline at end of file diff --git a/v2_adminpanel/routes/__pycache__/__init__.cpython-312.pyc b/v2_adminpanel/routes/__pycache__/__init__.cpython-312.pyc index d28b39db590c1b399a1eeafc8b4bedf67fc2d7d3..a81c845ac22c2adaa20c9c1435bfa62ed5dca730 100644 GIT binary patch delta 10 RcmdnaSUkawQD|bb2>=ss162S3 delta 78 zcmXTk&N#u&HB3J@uS7ptKeRZts94`IB{wrKv$&)vu_V7p-z7h}G&eP`q*&iQv&5q` dNx#fU7bupUS_EXpCxX-#B<7{&=ufma0RV8B9H#&P diff --git a/v2_adminpanel/routes/__pycache__/admin_routes.cpython-312.pyc b/v2_adminpanel/routes/__pycache__/admin_routes.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..90c76149b6cc6f9bea3668246a658dc8a588159d GIT binary patch literal 20902 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 EAIKFEF8}}l literal 0 HcmV?d00001 diff --git a/v2_adminpanel/routes/__pycache__/api_routes.cpython-312.pyc b/v2_adminpanel/routes/__pycache__/api_routes.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1b4b5311528704a45b68f0dfbbb26d38e91af19b GIT binary patch literal 37347 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 z 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 literal 0 HcmV?d00001 diff --git a/v2_adminpanel/routes/__pycache__/auth_routes.cpython-312.pyc b/v2_adminpanel/routes/__pycache__/auth_routes.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e6d9a8cc5532ae5676e40d46051d2d37a056aac2 GIT binary patch literal 17588 zcmeHvTWlLwnr87%ilj(M)csbrBumuQlJBytw5*#ivZPpY+-7$#n^sX0WnPr3q8+oT zM7^6uD>K1Ho^HT0+KsSh8yE#PFlt~P{E&zAB;9CW(Hk;kR!psePB6v-^CoXRFvggN z`TtX_izGU+J+r$nTc%E(I_E#9&hOy*tp7`}aL&)echD;t)$m}tPEFMd!z*7*i zdaNOv#}=}C>_kozEc6sYT0_tga(bL3%or>R6?=+9C7u%UYzn$UrJmAInWrpN?kNvd zcq&4bp2|>_r;5azgVmuLPfe)SQyZ%D)P?Fj^(5R9q(l2W`-s0FxIfh3X&`=Uurbu+ zX(E1G@Ia{9(;RB?w2)_e&>d>^v}!0Vch`SP47v)FHh2WD?V_JzU7Tp;Yl z#E^(_YX$o}h#TPs z!uSPiE-6Y0rE>ID-^{h>A`#&PCwZGHNGQ&wQiZwof^%lxKXc6+SoB^EMrN-08KD&F z2nF=c`T{{eoFTvh$-KTO6X4+i zs>yq!95nLPo8%t$L62yKA`EA)LIuKMXl86+k`V?)SuVmtT4$~jP)6S(KQr$W^u8!R zA2W85f(g3O;b}oXIWaX2!lsa)pN}xyOm1eAn-yHpjMO*3F{6Iub90)a7SvKXzclZb zC?3<0OAgcUlCGuV8fgao5OmLyc1ai4vX|pJ-Xx`3fVb_wcq|LK7&*O^&w^cwQ|=6P zUn|AOUMjzKRhF|I#>>m#fzl#g|BN zN^O>myh{pQD3g53ohi81LQ$(a_M%FzLW!4r3)PZOxieO&E=#7DNNtl-n_nWeT~2M` zYoszQ)JZ<&zElu5sw8M7M)FApKmlxo9$uq^O5+9I4m3!Ps^2)7}@q+s$_l#mJi~_eL7q>`n z`+rm`7p|cHPPJ;2Yh{vZwPa&Tm+bIY7`L(Ie1|Myj@y{BN_dpe0o%hkm+TN)j-gA1 z;nuiG)xw=pO67hXdNgkScA2;dbHn+=YB7J+2v9PTebT)Cw~w5-oz27x*&e9FK`DEt z0$?NsHLS!kslux;oN=l`sxgE=1P;0QBa)AiQH0dOQ#(?6WgW01zgqHUby=)pSy+Qq zo-m+RRSD`y3I301)`b&Njg-68`l?dYlTw_NWE7nVbMurG%FxU{W`A7sOX^qJ`wfz= z1&Ms^*C@4{bcfmaij-4a6E{iYamkTaS394%9u+MW$BWnoRk@m&157iPi)m4f(mc8X zB;3C;>?pf>4ROSss`Ru}TIG(~@eK_NR)R_{kDg^(Utp#?)%xq(^WCA_)Fpoot^Pda zet}Z&(7NAI>b84&u??pvcl-EL`*4(vEc&}n``KV1{DDR=^05G5*MPH-qGH8oC+X4Q zsmbAs7pM2p`J0P=7FQv`j4%!1nP8THYs@v*A)_zwaf^{K=bsZxyZuV6bZ}sDdhpBuJ$!Lw z;^NTo#j&%4XZB4CrW{~naoKSjSX^fW8#f=h;gw=qF~}}qOK;FW%L~QwyS!WgknuJD z%_z!AZ@Dx+`tjYd>9eEz=+gm!sx*|>ALVJliCh%0ZMbtpuuEn4A}AJY1YP^XzN>(5 z1v|o98HNhl00RP2kQbkPH+%s;5T5h)%=%&ugqxi?6bbf3OYD&aW&!tBO;gc>9L8y^TR`e8BB&K3$KjnU`WJl z^o=OX`020@M??wzn$#xr9CRD>H7^)Nl<(HDxQek|=-{g0rUgA3N^BK|Ao61oC|((%Uag! z9_W%SeaXJDr0c?pWy?{w7T-AW$I6UjDrugAca}CMVrf^;@`Z<{!k@kQ(>K@3(x&>Y zlB(N%xB50q8d4<h5(<_gHQu5q*WXsY&T z(p;6bR@@!gtT~aYIq{kGM5=%Ci;{+iRdw&X@3?>2wqB5`>RB~qORCnU{_510tK#;# zTj$oe#N|6n8CSQQttkYND64blJeW`TkfB?B??l-tt+OiwRm;vFHUXQobR1`=iDl{cKOy)#@6(;wc=|> z#aC^Ieqma3q?|3;*3NbQgI0JdOgWq3Dd*cNum6oH*>WOH_omBFCY>jtBu(yLm@>|N z+2%G7vZkDk*#jtEka9MBap+iLdcEUL$@R_AAE!otoE-5beSFd##h2XBKvGvjqA%NgINSBTPewL}d@1;K`9AAED1 zoP8@b`&PF7@K#sPM)%*%ZXTUT9i2#CoJk#>Np~?@hkG{7 zy@ok{Xxu!0DFwggOP@7!8PAV5JzUDeZ8f*9ci*4g>>N#Xj{dPD)p5@S?LU;nr}b}P%#rSE}$=F@JYIm5W#ign;6h4JIIxsu_+1SozXB_8PZY; zcx7}9P!{)%D!8nWG&w_cM?NM-Hq`)Te!DD~64E=rQ`Yke65%E+an zj$|T4F&o_Wak9h!A_-puBZsX|vpy6siUWjQfQR?skNc0{EK?61W$*pu zou91rCT^x3-OFdT^!AncHRFc+bN#6;z4d43e|mn^x;CBB@0Xqm)`l|rebSRF^EYT0lIq>tb{{Mr}zYXWJzNj|*7FT^CLpX>jM` z1{tFg4~(WVQSwbrg(uN`hoo7m+mbe}VYP8BN2}N=3sWGK2_~G(n0`kaAw(fpTxLdr zQ`POr+4@pBm&!TIY#L)DZLVRLORl(Ft|dcUzpzgVSMG6xlq;L zcvlj5WS+GU<&%BZ;*w8x=c1*s7-dYk()v8AadI&gOeIrwzgo3`=9_nU_&Q$ZzF_>; z;sA%yn9~mIpVU3~|NAr7*tr|?^8KmwTOIUU{Y&-RzlRo}zkQBLbK=ExfNCY!w?khm zwcZc4ZrD+4nN?+EPIQWDpt>nea|6yTT%k1ZZ&my5i8pjts2iF*G5z^t-kp3#__P@A zZW<@ZhnDI6OfL&kv4F$S~IAY=`cL@rwzw~^%{OB={EkOyi3 zu2V1pZ%C*Nr^vVTj0|{B4-B5aG)a(TtO^2hRlX76=hdl2XxR^i5v=O%>>?$n93d8S zDY+;Gj$Pf;Cb=DHKx`7n!9io>3vHvj+i60=DY1E4@oDGT@O57h+7y8LU}8C5tD*+p;!3l15PKh@&~y`aNA zd8~{?B#EHEK%qw2pF-?i_~XW*008{0#qXVb=j7^}8S8;9NBLb-!j`V+NIN={=FS}i zj?UrrRNvE82qI`XS+S#$`Jd$!A*_e6IlXmv67?967dF$lbTOzwtTzR|gR@+)Q zUEHx^`r1)`=f;+;C|P{`L374-daI=R?r35xUDKT|=}Fpp9y%Iw^v4&D5+c`-xSX+d z?3M!?+~!z`{$=l4(OLbX2pU%T`psZ@ptpHomsi(EK;OzwTWRrW;N^xSZTS{8*={9MwG0q2Lo8 zCf~Mw8x4|eYJX|^qx`n@mUV4neLCap*@C{nr}6cnjI(>^(?e%zvg}7m?|jA?0A3?m z)xJKPJahGPC$opMfwpv3uN_Xps}4aw6&-{p2F-BOx3(iyS49u(FSKB5^biZ$InJ^mu;4p*#Pj$A5cHN&^npVEd2x8>6&6UMe1CXSe5=B`CHG7HCjW*}GjLd=Mo ze{(w& zqak`yJb1--eEc>Z@WQPYSdEF^jzwwTHQcN5Lsp%F9`{;UT)ASlT)Pl8{(*)pdccIB zf)-Ue(btMfM=i=?bE$a{Q!W$sKSRX-!pQG|Ll|@${Tma|$n>GK_3-l07eKL)z-v(UaKT zq$dx83q1*s;2TMRbvPnom`B5 z#~9Qp;|FYP9b+J|xq8Crl+RTG8zS$h!|0UHQmGKD%xp*{m;zfhuDu4>l~w0td6}H@ z?cu|DDk$lWysa;lw`^^z_!B)}F16XcoCi1ogJSzDyaI9vabw<7Tei50#mIMJO3GVe zKxS1s08R~mD`UXRYzI@wIHVjH=l!C4da=mRQ%VmOgSDUQ+2`-U-PXdpgU_%DMsqfS zYaDjzqAchJUOufU*l?#!nrsk{TbgW04iPaFvJ$bnK`|{YNz(z24us*1C2Vc;qLJ{_ zMwr%|qKH+G0}_|SIhu(O^8vOgXXZQE2xi(onL9^HhoT%$U-bj{q|pN5>Ekr00v0x( z(Ub^A!gHOmu{}^)%A6oNf{b%`S&k6(NgmM5B10iKCI_abF2e@D7xwzcM~6jwpjIxH zn4D2rAi#5XVu z&PGrQG-l)Yh-y%T!ias{CiZ6-u!7Dz=pf=1%-FejR)BpA!RowdmXcaB*A&)P?DauC(J|(tHqh$;_@~dGlwcW+K(LemP@4 zvRmq*sbHlqZK_!7{>)TEtjgTaJ;n_;BX6f2gOOE}9MUED+d%aA7t zIX*AsVYIl!fYO#1PTav#DW-hD#~>{!(p?RFBo(@(0cuoUq8Vc!yhl-gao9n_H-n1L z$-n|BFJo}P7nZ8~W{Cs@_+$~`6WarcVl8N~f+q3>S-+3DN$1YP07=2pHzQG&_RWy9 z3Z3kK!rJ`>ID(TLj8R$hu|m?0coadjbhRvpf~MS2b+9O(iItp~Z4o-e_SBK7N0PHY zhfoe@Doi+WvRUN!u)l!tQkY>J0+Bo@>MN6VW&AT!H5tyw9~{V-2l5Q(lIC=A%kuf` z{*JdtleW5)z8=Q7qkfG~oJ-YrZB(ZVPc4rUjNX=QI+)$p^4OrYwZhOdv_FEye@VwH@J0rQ zx3up%^svPOu`M4v?qo5&kCk_6EG1u#41ZJPyJiIIG& zjS~Z7#S**_7*cw4zm%hLhs{2@-J#!_q!`A=*x_hDc^gxB-=P9d&9aOWG((Z3HKw3l zHSo8jyRnC4efRt`z|lkw92Jj?oKXn!X2aPo+B;Z2RMaB32ZSDcRaBxb7C}F`<7X-mgaAwGgdE=|wkWOu z>>Y@O&+sVjKS%(`?s{+Rov}?@P0CiYb~$l1ZF6thx>L69jfM?2ZR=eg-V(XHWZl7Z zarg51udQXfVMvlbn6BtfJ9?7l9)v*Ut3OE$XRNJ6F2}4Oo4893w);z3m&daG=l)04 z`3)umxM}F~>h5IK`J}!aT+C{R^)2BVqv3)^4Qvb+c$SdOHl?q0Zkwr+y1V@Qu{*J3 z>#1~o|3~_drhiwEEIR+#qPJfFf)3L>(P2)Hiz$N*&om^R`x9`C!Tk`9JA~F=Tjw{1 zK8R&&k0Ina@z_kDUFU-zW~}Fu`g8yMzniJ*G0l?#%m@zffD3>J^V_D-eaf{$TmSw3 z;$esW<6_$|t^2sHeb{CAq`(aR-@6RMwU*ykn$S-hhTW!^OJ1#*0EY)(_bgYqz~GYm zO%giq<(8{^kP~z(shG$rLva*(PhXrcS zLIMh2t6Df@P6EQ=WI?`te}qtsnW$|UV&&Q8-euD&_A~=$AEX=@>wTL_6)QHZohg(Q z@$=X2g>6{JI5}>rGE3cOB@0!b^~ZM+^xzD6h^dA2>^`U-J$Osbd?et_Yr<}*TfDP&@B>cxIQGgGBk5wT%wiaOPrD+f`kcOmLkRH}al z4}rVR^7rEJ#5W!FDMx*xAnj;dK9kQOF=E1@R)x@AP z8t9#RkKU{H%2)YYt*f=D=V(-O;6O4sTxcj<39rp==r^W+Tac|igj($gG2{+yT*>HP z*<;9+LRwU8G|_9|g=|Da?ssjr!5ZD~j+GBO4S!!~2LH!S!(gT5<5Cm)YYc-;rdUDO zydREdU-bhg0_`GSvK9k!eun{3=a;X5eQHdAXOhq>?pqaGm0!A>WX&LifxkPB-MNfg~aF|()z_-jakmD>Z zZB(C!^Ya}8!N7Gt-GOv>6!twiV7!4gQh%(#B9I(cc9N$BZR8qJ8*ZIogfsDclp|Zr zVwgomsNf(LcQIc@A2yI+4n>&2Yygl1o?&B=x%q%1)OF6Tle>}qYluVUg!6+326_QS zx4a*}6Tg3E!<%j&PS=lY){m#^$CIyllJ(>1`X6l8`&0G)bp71hqbn0Bea%^e*E!l-_27b=+Vg{)i$hRrtCmLw@h+i_`q9xd) z@KplW8TLcY>^1T!hhP=M$hX+yIZh*Vkw3^03@K=ts|sT#I7DIj6CCoHg<$r2@sl90 zS1@5gyjK?m1NmBjd?G-Ody=hRvfsh3VwuopC-9I(bw;|H{Wbb<@{*D8)Z7Q~A3q$2a#exZ667M)N-@EBwBsYT@^1%C|}R(vz}FPPqaFX_6dcK8Jus> z_fKX@)AA*??@Oxr&s5i!RQn?%Wp%BNter_0G%cHe9n)HVR{PW1l{a9=wk%Pw=ZfXxma3LmDM;NX^onrDB(n>dRq^V+r>Gdq{F>2vT+>q>LarCnhsg^)V3ZT zw};?=yCB6%IvUcNMk40aw8~;l+j@B1J|Igqu3ulD$1IN#+5MXHq@Lvc$G7$PynRl| vv}wb*{sxL4C*ozQ5}er9!{c_3RHb-gHt~HF?IfaBjY%#=*CRbVl1~0tvc-T6 literal 0 HcmV?d00001 diff --git a/v2_adminpanel/routes/__pycache__/batch_routes.cpython-312.pyc b/v2_adminpanel/routes/__pycache__/batch_routes.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..753606e6b55ea04886cd9f61ae9973dd52e43e5a GIT binary patch literal 17130 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 alvJVnh<8Ty{l4FWd0=F-bXwZJN2 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 HXWf4Rs~Nw2 delta 4093 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 diff --git a/v2_adminpanel/routes/__pycache__/export_routes.cpython-312.pyc b/v2_adminpanel/routes/__pycache__/export_routes.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3f0aaae40dc7cd9205145217046719d8e048d47f GIT binary patch literal 14620 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 I5eVh~1M@>ncmMzZ literal 0 HcmV?d00001 diff --git a/v2_adminpanel/routes/__pycache__/resource_routes.cpython-312.pyc b/v2_adminpanel/routes/__pycache__/resource_routes.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..34962e3042e9b6f8663e19992c283c5075d8ef2a GIT binary patch literal 29457 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 Mg4|&L9=hTG0c;ZGHUIzs literal 0 HcmV?d00001 diff --git a/v2_adminpanel/routes/__pycache__/session_routes.cpython-312.pyc b/v2_adminpanel/routes/__pycache__/session_routes.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d1abdb632eea195b71a96c39896083373609ed22 GIT binary patch literal 17659 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 L=vnwX=+gfMoOC{K literal 0 HcmV?d00001 diff --git a/v2_adminpanel/routes/api_routes.py b/v2_adminpanel/routes/api_routes.py index 46da9a6..7d1e834 100644 --- a/v2_adminpanel/routes/api_routes.py +++ b/v2_adminpanel/routes/api_routes.py @@ -65,22 +65,22 @@ def toggle_license(license_id): if not license_data: return jsonify({'error': 'Lizenz nicht gefunden'}), 404 - new_status = not license_data['active'] + new_status = not license_data['is_active'] # Update status - cur.execute("UPDATE licenses SET active = %s WHERE id = %s", (new_status, license_id)) + cur.execute("UPDATE licenses SET is_active = %s WHERE id = %s", (new_status, license_id)) conn.commit() # Log change log_audit('TOGGLE', 'license', license_id, - old_values={'active': license_data['active']}, - new_values={'active': new_status}) + old_values={'is_active': license_data['is_active']}, + new_values={'is_active': new_status}) - return jsonify({'success': True, 'active': new_status}) + return jsonify({'success': True, 'is_active': new_status}) except Exception as e: conn.rollback() - logging.error(f"Fehler beim Umschalten der Lizenz: {str(e)}") + logging.error(f"Fehler beim Umschalten der Lizenz: {str(e)}", exc_info=True) return jsonify({'error': 'Fehler beim Umschalten der Lizenz'}), 500 finally: cur.close() @@ -104,8 +104,8 @@ def bulk_activate_licenses(): # Update all selected licenses cur.execute(""" UPDATE licenses - SET active = true - WHERE id = ANY(%s) AND active = false + SET is_active = true + WHERE id = ANY(%s) AND is_active = false RETURNING id """, (license_ids,)) @@ -115,7 +115,7 @@ def bulk_activate_licenses(): # Log changes for license_id in updated_ids: log_audit('BULK_ACTIVATE', 'license', license_id, - new_values={'active': True}) + new_values={'is_active': True}) return jsonify({ 'success': True, @@ -148,8 +148,8 @@ def bulk_deactivate_licenses(): # Update all selected licenses cur.execute(""" UPDATE licenses - SET active = false - WHERE id = ANY(%s) AND active = true + SET is_active = false + WHERE id = ANY(%s) AND is_active = true RETURNING id """, (license_ids,)) @@ -159,7 +159,7 @@ def bulk_deactivate_licenses(): # Log changes for license_id in updated_ids: log_audit('BULK_DEACTIVATE', 'license', license_id, - new_values={'active': False}) + new_values={'is_active': False}) return jsonify({ 'success': True, @@ -451,10 +451,10 @@ def quick_edit_license(license_id): new_values['valid_until'] = data['valid_until'] if 'active' in data: - updates.append("active = %s") + updates.append("is_active = %s") params.append(bool(data['active'])) - old_values['active'] = current_license['active'] - new_values['active'] = bool(data['active']) + old_values['is_active'] = current_license['is_active'] + new_values['is_active'] = bool(data['active']) if not updates: return jsonify({'error': 'Keine Änderungen angegeben'}), 400 @@ -797,7 +797,7 @@ def global_search(): try: # Suche in Lizenzen cur.execute(""" - SELECT id, license_key, customer_name, active + SELECT id, license_key, customer_name, is_active FROM licenses WHERE license_key ILIKE %s OR customer_name ILIKE %s @@ -810,7 +810,7 @@ def global_search(): 'id': row[0], 'license_key': row[1], 'customer_name': row[2], - 'active': row[3] + 'is_active': row[3] }) # Suche in Kunden diff --git a/v2_adminpanel/routes/api_routes.py.backup b/v2_adminpanel/routes/api_routes.py.backup deleted file mode 100644 index 8f49f25..0000000 --- a/v2_adminpanel/routes/api_routes.py.backup +++ /dev/null @@ -1,943 +0,0 @@ -import logging -from datetime import datetime -from zoneinfo import ZoneInfo -from flask import Blueprint, request, jsonify, session - -import config -from auth.decorators import login_required -from utils.audit import log_audit -from utils.network import get_client_ip -from utils.license import generate_license_key -from db import get_connection, get_db_connection, get_db_cursor -from models import get_license_by_id, get_customers - -# Create Blueprint -api_bp = Blueprint('api', __name__, url_prefix='/api') - - -@api_bp.route("/customers", methods=["GET"]) -@login_required -def api_customers(): - """API endpoint for customer search (used by Select2)""" - search = request.args.get('q', '').strip() - page = int(request.args.get('page', 1)) - per_page = 20 - - try: - # Get all customers (with optional search) - customers = get_customers(show_test=True, search=search) - - # Pagination - start = (page - 1) * per_page - end = start + per_page - paginated_customers = customers[start:end] - - # Format for Select2 - results = [] - for customer in paginated_customers: - results.append({ - 'id': customer['id'], - 'text': f"{customer['name']} ({customer['email'] or 'keine E-Mail'})" - }) - - return jsonify({ - 'results': results, - 'pagination': { - 'more': len(customers) > end - } - }) - - except Exception as e: - logging.error(f"Error in api_customers: {str(e)}") - return jsonify({'error': 'Fehler beim Laden der Kunden'}), 500 - - -@api_bp.route("/license//toggle", methods=["POST"]) -@login_required -def toggle_license(license_id): - """Toggle license active status""" - conn = get_connection() - cur = conn.cursor() - - try: - # Get current status - license_data = get_license_by_id(license_id) - if not license_data: - return jsonify({'error': 'Lizenz nicht gefunden'}), 404 - - new_status = not license_data['active'] - - # Update status - cur.execute("UPDATE licenses SET active = %s WHERE id = %s", (new_status, license_id)) - conn.commit() - - # Log change - log_audit('TOGGLE', 'license', license_id, - old_values={'active': license_data['active']}, - new_values={'active': new_status}) - - return jsonify({'success': True, 'active': new_status}) - - except Exception as e: - conn.rollback() - logging.error(f"Fehler beim Umschalten der Lizenz: {str(e)}") - return jsonify({'error': 'Fehler beim Umschalten der Lizenz'}), 500 - finally: - cur.close() - conn.close() - - -@api_bp.route("/licenses/bulk-activate", methods=["POST"]) -@login_required -def bulk_activate_licenses(): - """Aktiviere mehrere Lizenzen gleichzeitig""" - data = request.get_json() - license_ids = data.get('license_ids', []) - - if not license_ids: - return jsonify({'error': 'Keine Lizenzen ausgewählt'}), 400 - - conn = get_connection() - cur = conn.cursor() - - try: - # Update all selected licenses - cur.execute(""" - UPDATE licenses - SET active = true - WHERE id = ANY(%s) AND active = false - RETURNING id - """, (license_ids,)) - - updated_ids = [row[0] for row in cur.fetchall()] - conn.commit() - - # Log changes - for license_id in updated_ids: - log_audit('BULK_ACTIVATE', 'license', license_id, - new_values={'active': True}) - - return jsonify({ - 'success': True, - 'updated_count': len(updated_ids) - }) - - except Exception as e: - conn.rollback() - logging.error(f"Fehler beim Bulk-Aktivieren: {str(e)}") - return jsonify({'error': 'Fehler beim Aktivieren der Lizenzen'}), 500 - finally: - cur.close() - conn.close() - - -@api_bp.route("/licenses/bulk-deactivate", methods=["POST"]) -@login_required -def bulk_deactivate_licenses(): - """Deaktiviere mehrere Lizenzen gleichzeitig""" - data = request.get_json() - license_ids = data.get('license_ids', []) - - if not license_ids: - return jsonify({'error': 'Keine Lizenzen ausgewählt'}), 400 - - conn = get_connection() - cur = conn.cursor() - - try: - # Update all selected licenses - cur.execute(""" - UPDATE licenses - SET active = false - WHERE id = ANY(%s) AND active = true - RETURNING id - """, (license_ids,)) - - updated_ids = [row[0] for row in cur.fetchall()] - conn.commit() - - # Log changes - for license_id in updated_ids: - log_audit('BULK_DEACTIVATE', 'license', license_id, - new_values={'active': False}) - - return jsonify({ - 'success': True, - 'updated_count': len(updated_ids) - }) - - except Exception as e: - conn.rollback() - logging.error(f"Fehler beim Bulk-Deaktivieren: {str(e)}") - return jsonify({'error': 'Fehler beim Deaktivieren der Lizenzen'}), 500 - finally: - cur.close() - conn.close() - - -@api_bp.route("/license//devices") -@login_required -def get_license_devices(license_id): - """Hole alle Geräte einer Lizenz""" - conn = get_connection() - cur = conn.cursor() - - try: - # Hole Lizenz-Info - license_data = get_license_by_id(license_id) - if not license_data: - return jsonify({'error': 'Lizenz nicht gefunden'}), 404 - - # Hole registrierte Geräte - cur.execute(""" - SELECT - dr.id, - dr.device_id, - dr.device_name, - dr.device_type, - dr.registration_date, - dr.last_seen, - dr.is_active, - (SELECT COUNT(*) FROM sessions s - WHERE s.license_key = dr.license_key - AND s.device_id = dr.device_id - AND s.active = true) as active_sessions - FROM device_registrations dr - WHERE dr.license_key = %s - ORDER BY dr.registration_date DESC - """, (license_data['license_key'],)) - - devices = [] - for row in cur.fetchall(): - devices.append({ - 'id': row[0], - 'device_id': row[1], - 'device_name': row[2], - 'device_type': row[3], - 'registration_date': row[4].isoformat() if row[4] else None, - 'last_seen': row[5].isoformat() if row[5] else None, - 'is_active': row[6], - 'active_sessions': row[7] - }) - - return jsonify({ - 'license_key': license_data['license_key'], - 'device_limit': license_data['device_limit'], - 'devices': devices, - 'device_count': len(devices) - }) - - except Exception as e: - logging.error(f"Fehler beim Abrufen der Geräte: {str(e)}") - return jsonify({'error': 'Fehler beim Abrufen der Geräte'}), 500 - finally: - cur.close() - conn.close() - - -@api_bp.route("/license//register-device", methods=["POST"]) -@login_required -def register_device(license_id): - """Registriere ein neues Gerät für eine Lizenz""" - data = request.get_json() - - device_id = data.get('device_id') - device_name = data.get('device_name') - device_type = data.get('device_type', 'unknown') - - if not device_id or not device_name: - return jsonify({'error': 'Geräte-ID und Name erforderlich'}), 400 - - conn = get_connection() - cur = conn.cursor() - - try: - # Hole Lizenz-Info - license_data = get_license_by_id(license_id) - if not license_data: - return jsonify({'error': 'Lizenz nicht gefunden'}), 404 - - # Prüfe Gerätelimit - cur.execute(""" - SELECT COUNT(*) FROM device_registrations - WHERE license_key = %s AND is_active = true - """, (license_data['license_key'],)) - - active_device_count = cur.fetchone()[0] - - if active_device_count >= license_data['device_limit']: - return jsonify({'error': 'Gerätelimit erreicht'}), 400 - - # Prüfe ob Gerät bereits registriert - cur.execute(""" - SELECT id, is_active FROM device_registrations - WHERE license_key = %s AND device_id = %s - """, (license_data['license_key'], device_id)) - - existing = cur.fetchone() - - if existing: - if existing[1]: # is_active - return jsonify({'error': 'Gerät bereits registriert'}), 400 - else: - # Reaktiviere Gerät - cur.execute(""" - UPDATE device_registrations - SET is_active = true, last_seen = CURRENT_TIMESTAMP - WHERE id = %s - """, (existing[0],)) - else: - # Registriere neues Gerät - cur.execute(""" - INSERT INTO device_registrations - (license_key, device_id, device_name, device_type, is_active) - VALUES (%s, %s, %s, %s, true) - """, (license_data['license_key'], device_id, device_name, device_type)) - - conn.commit() - - # Audit-Log - log_audit('DEVICE_REGISTER', 'license', license_id, - additional_info=f"Gerät {device_name} ({device_id}) registriert") - - return jsonify({'success': True}) - - except Exception as e: - conn.rollback() - logging.error(f"Fehler beim Registrieren des Geräts: {str(e)}") - return jsonify({'error': 'Fehler beim Registrieren des Geräts'}), 500 - finally: - cur.close() - conn.close() - - -@api_bp.route("/license//deactivate-device/", methods=["POST"]) -@login_required -def deactivate_device(license_id, device_id): - """Deaktiviere ein Gerät einer Lizenz""" - conn = get_connection() - cur = conn.cursor() - - try: - # Prüfe ob Gerät zur Lizenz gehört - cur.execute(""" - SELECT dr.device_name, dr.device_id, l.license_key - FROM device_registrations dr - JOIN licenses l ON dr.license_key = l.license_key - WHERE dr.id = %s AND l.id = %s - """, (device_id, license_id)) - - device = cur.fetchone() - if not device: - return jsonify({'error': 'Gerät nicht gefunden'}), 404 - - # Deaktiviere Gerät - cur.execute(""" - UPDATE device_registrations - SET is_active = false - WHERE id = %s - """, (device_id,)) - - # Beende aktive Sessions - cur.execute(""" - UPDATE sessions - SET active = false, logout_time = CURRENT_TIMESTAMP - WHERE license_key = %s AND device_id = %s AND active = true - """, (device[2], device[1])) - - conn.commit() - - # Audit-Log - log_audit('DEVICE_DEACTIVATE', 'license', license_id, - additional_info=f"Gerät {device[0]} ({device[1]}) deaktiviert") - - return jsonify({'success': True}) - - except Exception as e: - conn.rollback() - logging.error(f"Fehler beim Deaktivieren des Geräts: {str(e)}") - return jsonify({'error': 'Fehler beim Deaktivieren des Geräts'}), 500 - finally: - cur.close() - conn.close() - - -@api_bp.route("/licenses/bulk-delete", methods=["POST"]) -@login_required -def bulk_delete_licenses(): - """Lösche mehrere Lizenzen gleichzeitig""" - data = request.get_json() - license_ids = data.get('license_ids', []) - - if not license_ids: - return jsonify({'error': 'Keine Lizenzen ausgewählt'}), 400 - - conn = get_connection() - cur = conn.cursor() - - try: - deleted_count = 0 - - for license_id in license_ids: - # Hole Lizenz-Info für Audit - cur.execute("SELECT license_key FROM licenses WHERE id = %s", (license_id,)) - result = cur.fetchone() - - if result: - license_key = result[0] - - # Lösche Sessions - cur.execute("DELETE FROM sessions WHERE license_key = %s", (license_key,)) - - # Lösche Geräte-Registrierungen - cur.execute("DELETE FROM device_registrations WHERE license_key = %s", (license_key,)) - - # Lösche Lizenz - cur.execute("DELETE FROM licenses WHERE id = %s", (license_id,)) - - # Audit-Log - log_audit('BULK_DELETE', 'license', license_id, - old_values={'license_key': license_key}) - - deleted_count += 1 - - conn.commit() - - return jsonify({ - 'success': True, - 'deleted_count': deleted_count - }) - - except Exception as e: - conn.rollback() - logging.error(f"Fehler beim Bulk-Löschen: {str(e)}") - return jsonify({'error': 'Fehler beim Löschen der Lizenzen'}), 500 - finally: - cur.close() - conn.close() - - -@api_bp.route("/license//quick-edit", methods=['POST']) -@login_required -def quick_edit_license(license_id): - """Schnellbearbeitung einer Lizenz""" - data = request.get_json() - - conn = get_connection() - cur = conn.cursor() - - try: - # Hole aktuelle Lizenz für Vergleich - current_license = get_license_by_id(license_id) - if not current_license: - return jsonify({'error': 'Lizenz nicht gefunden'}), 404 - - # Update nur die übergebenen Felder - updates = [] - params = [] - old_values = {} - new_values = {} - - if 'device_limit' in data: - updates.append("device_limit = %s") - params.append(int(data['device_limit'])) - old_values['device_limit'] = current_license['device_limit'] - new_values['device_limit'] = int(data['device_limit']) - - if 'valid_until' in data: - updates.append("valid_until = %s") - params.append(data['valid_until']) - old_values['valid_until'] = str(current_license['valid_until']) - new_values['valid_until'] = data['valid_until'] - - if 'active' in data: - updates.append("active = %s") - params.append(bool(data['active'])) - old_values['active'] = current_license['active'] - new_values['active'] = bool(data['active']) - - if not updates: - return jsonify({'error': 'Keine Änderungen angegeben'}), 400 - - # Führe Update aus - params.append(license_id) - cur.execute(f""" - UPDATE licenses - SET {', '.join(updates)} - WHERE id = %s - """, params) - - conn.commit() - - # Audit-Log - log_audit('QUICK_EDIT', 'license', license_id, - old_values=old_values, - new_values=new_values) - - return jsonify({'success': True}) - - except Exception as e: - conn.rollback() - logging.error(f"Fehler bei Schnellbearbeitung: {str(e)}") - return jsonify({'error': 'Fehler bei der Bearbeitung'}), 500 - finally: - cur.close() - conn.close() - - -@api_bp.route("/license//resources") -@login_required -def get_license_resources(license_id): - """Hole alle Ressourcen einer Lizenz""" - conn = get_connection() - cur = conn.cursor() - - try: - # Hole Lizenz-Info - license_data = get_license_by_id(license_id) - if not license_data: - return jsonify({'error': 'Lizenz nicht gefunden'}), 404 - - # Hole zugewiesene Ressourcen - cur.execute(""" - SELECT - rp.id, - rp.resource_type, - rp.resource_value, - rp.is_test, - rp.status_changed_at, - lr.assigned_at, - lr.assigned_by - FROM resource_pools rp - JOIN license_resources lr ON rp.id = lr.resource_id - WHERE lr.license_id = %s - ORDER BY rp.resource_type, rp.resource_value - """, (license_id,)) - - resources = [] - for row in cur.fetchall(): - resources.append({ - 'id': row[0], - 'type': row[1], - 'value': row[2], - 'is_test': row[3], - 'status_changed_at': row[4].isoformat() if row[4] else None, - 'assigned_at': row[5].isoformat() if row[5] else None, - 'assigned_by': row[6] - }) - - # Gruppiere nach Typ - grouped = {} - for resource in resources: - res_type = resource['type'] - if res_type not in grouped: - grouped[res_type] = [] - grouped[res_type].append(resource) - - return jsonify({ - 'license_key': license_data['license_key'], - 'resources': resources, - 'grouped': grouped, - 'total_count': len(resources) - }) - - except Exception as e: - logging.error(f"Fehler beim Abrufen der Ressourcen: {str(e)}") - return jsonify({'error': 'Fehler beim Abrufen der Ressourcen'}), 500 - finally: - cur.close() - conn.close() - - -@api_bp.route("/resources/allocate", methods=['POST']) -@login_required -def allocate_resources(): - """Weise Ressourcen einer Lizenz zu""" - data = request.get_json() - - license_id = data.get('license_id') - resource_ids = data.get('resource_ids', []) - - if not license_id or not resource_ids: - return jsonify({'error': 'Lizenz-ID und Ressourcen erforderlich'}), 400 - - conn = get_connection() - cur = conn.cursor() - - try: - # Prüfe Lizenz - license_data = get_license_by_id(license_id) - if not license_data: - return jsonify({'error': 'Lizenz nicht gefunden'}), 404 - - allocated_count = 0 - errors = [] - - for resource_id in resource_ids: - try: - # Prüfe ob Ressource verfügbar ist - cur.execute(""" - SELECT resource_value, status, is_test - FROM resource_pools - WHERE id = %s - """, (resource_id,)) - - resource = cur.fetchone() - if not resource: - errors.append(f"Ressource {resource_id} nicht gefunden") - continue - - if resource[1] != 'available': - errors.append(f"Ressource {resource[0]} ist nicht verfügbar") - continue - - # Prüfe Test/Produktion Kompatibilität - if resource[2] != license_data['is_test']: - errors.append(f"Ressource {resource[0]} ist {'Test' if resource[2] else 'Produktion'}, Lizenz ist {'Test' if license_data['is_test'] else 'Produktion'}") - continue - - # Weise Ressource zu - cur.execute(""" - UPDATE resource_pools - SET status = 'allocated', - allocated_to_license = %s, - status_changed_at = CURRENT_TIMESTAMP, - status_changed_by = %s - WHERE id = %s - """, (license_id, session['username'], resource_id)) - - # Erstelle Verknüpfung - cur.execute(""" - INSERT INTO license_resources (license_id, resource_id, assigned_by) - VALUES (%s, %s, %s) - """, (license_id, resource_id, session['username'])) - - # History-Eintrag - cur.execute(""" - INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address) - VALUES (%s, %s, 'allocated', %s, %s) - """, (resource_id, license_id, session['username'], get_client_ip())) - - allocated_count += 1 - - except Exception as e: - errors.append(f"Fehler bei Ressource {resource_id}: {str(e)}") - - conn.commit() - - # Audit-Log - if allocated_count > 0: - log_audit('RESOURCE_ALLOCATE', 'license', license_id, - additional_info=f"{allocated_count} Ressourcen zugewiesen") - - return jsonify({ - 'success': True, - 'allocated_count': allocated_count, - 'errors': errors - }) - - except Exception as e: - conn.rollback() - logging.error(f"Fehler beim Zuweisen der Ressourcen: {str(e)}") - return jsonify({'error': 'Fehler beim Zuweisen der Ressourcen'}), 500 - finally: - cur.close() - conn.close() - - -@api_bp.route("/resources/check-availability", methods=['GET']) -@login_required -def check_resource_availability(): - """Prüfe Verfügbarkeit von Ressourcen""" - resource_type = request.args.get('type') - count = int(request.args.get('count', 1)) - is_test = request.args.get('is_test', 'false') == 'true' - - if not resource_type: - return jsonify({'error': 'Ressourcen-Typ erforderlich'}), 400 - - conn = get_connection() - cur = conn.cursor() - - try: - # Zähle verfügbare Ressourcen - cur.execute(""" - SELECT COUNT(*) - FROM resource_pools - WHERE resource_type = %s - AND status = 'available' - AND is_test = %s - """, (resource_type, is_test)) - - available_count = cur.fetchone()[0] - - return jsonify({ - 'resource_type': resource_type, - 'requested': count, - 'available': available_count, - 'sufficient': available_count >= count, - 'is_test': is_test - }) - - except Exception as e: - logging.error(f"Fehler beim Prüfen der Verfügbarkeit: {str(e)}") - return jsonify({'error': 'Fehler beim Prüfen der Verfügbarkeit'}), 500 - finally: - cur.close() - conn.close() - - -@api_bp.route("/global-search", methods=['GET']) -@login_required -def global_search(): - """Globale Suche über alle Entitäten""" - query = request.args.get('q', '').strip() - - if not query or len(query) < 3: - return jsonify({'error': 'Suchbegriff muss mindestens 3 Zeichen haben'}), 400 - - conn = get_connection() - cur = conn.cursor() - - results = { - 'licenses': [], - 'customers': [], - 'resources': [], - 'sessions': [] - } - - try: - # Suche in Lizenzen - cur.execute(""" - SELECT id, license_key, customer_name, active - FROM licenses - WHERE license_key ILIKE %s - OR customer_name ILIKE %s - OR customer_email ILIKE %s - LIMIT 10 - """, (f'%{query}%', f'%{query}%', f'%{query}%')) - - for row in cur.fetchall(): - results['licenses'].append({ - 'id': row[0], - 'license_key': row[1], - 'customer_name': row[2], - 'active': row[3] - }) - - # Suche in Kunden - cur.execute(""" - SELECT id, name, email - FROM customers - WHERE name ILIKE %s OR email ILIKE %s - LIMIT 10 - """, (f'%{query}%', f'%{query}%')) - - for row in cur.fetchall(): - results['customers'].append({ - 'id': row[0], - 'name': row[1], - 'email': row[2] - }) - - # Suche in Ressourcen - cur.execute(""" - SELECT id, resource_type, resource_value, status - FROM resource_pools - WHERE resource_value ILIKE %s - LIMIT 10 - """, (f'%{query}%',)) - - for row in cur.fetchall(): - results['resources'].append({ - 'id': row[0], - 'type': row[1], - 'value': row[2], - 'status': row[3] - }) - - # Suche in Sessions - cur.execute(""" - SELECT id, license_key, username, device_id, active - FROM sessions - WHERE username ILIKE %s OR device_id ILIKE %s - ORDER BY login_time DESC - LIMIT 10 - """, (f'%{query}%', f'%{query}%')) - - for row in cur.fetchall(): - results['sessions'].append({ - 'id': row[0], - 'license_key': row[1], - 'username': row[2], - 'device_id': row[3], - 'active': row[4] - }) - - return jsonify(results) - - except Exception as e: - logging.error(f"Fehler bei der globalen Suche: {str(e)}") - return jsonify({'error': 'Fehler bei der Suche'}), 500 - finally: - cur.close() - conn.close() - - -@api_bp.route("/generate-license-key", methods=['POST']) -@login_required -def api_generate_key(): - """API Endpoint zur Generierung eines neuen Lizenzschlüssels""" - try: - # Lizenztyp aus Request holen (default: full) - data = request.get_json() or {} - license_type = data.get('type', 'full') - - # Key generieren - key = generate_license_key(license_type) - - # Prüfen ob Key bereits existiert (sehr unwahrscheinlich aber sicher ist sicher) - conn = get_connection() - cur = conn.cursor() - - # Wiederhole bis eindeutiger Key gefunden - attempts = 0 - while attempts < 10: # Max 10 Versuche - cur.execute("SELECT 1 FROM licenses WHERE license_key = %s", (key,)) - if not cur.fetchone(): - break # Key ist eindeutig - key = generate_license_key(license_type) - attempts += 1 - - cur.close() - conn.close() - - # Log für Audit - log_audit('GENERATE_KEY', 'license', - additional_info={'type': license_type, 'key': key}) - - return jsonify({ - 'success': True, - 'key': key, - 'type': license_type - }) - - except Exception as e: - logging.error(f"Fehler bei Key-Generierung: {str(e)}") - return jsonify({ - 'success': False, - 'error': 'Fehler bei der Key-Generierung' - }), 500 - - -@api_bp.route("/customers", methods=['GET']) -@login_required -def api_customers(): - """API Endpoint für die Kundensuche mit Select2""" - try: - # Suchparameter - search = request.args.get('q', '').strip() - page = request.args.get('page', 1, type=int) - per_page = 20 - customer_id = request.args.get('id', type=int) - - conn = get_connection() - cur = conn.cursor() - - # Einzelnen Kunden per ID abrufen - if customer_id: - cur.execute(""" - SELECT c.id, c.name, c.email, - COUNT(l.id) as license_count - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - WHERE c.id = %s - GROUP BY c.id, c.name, c.email - """, (customer_id,)) - - customer = cur.fetchone() - results = [] - if customer: - results.append({ - 'id': customer[0], - 'text': f"{customer[1]} ({customer[2]})", - 'name': customer[1], - 'email': customer[2], - 'license_count': customer[3] - }) - - cur.close() - conn.close() - - return jsonify({ - 'results': results, - 'pagination': {'more': False} - }) - - # SQL Query mit optionaler Suche - elif search: - cur.execute(""" - SELECT c.id, c.name, c.email, - COUNT(l.id) as license_count - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - WHERE LOWER(c.name) LIKE LOWER(%s) - OR LOWER(c.email) LIKE LOWER(%s) - GROUP BY c.id, c.name, c.email - ORDER BY c.name - LIMIT %s OFFSET %s - """, (f'%{search}%', f'%{search}%', per_page, (page - 1) * per_page)) - else: - cur.execute(""" - SELECT c.id, c.name, c.email, - COUNT(l.id) as license_count - FROM customers c - LEFT JOIN licenses l ON c.id = l.customer_id - GROUP BY c.id, c.name, c.email - ORDER BY c.name - LIMIT %s OFFSET %s - """, (per_page, (page - 1) * per_page)) - - customers = cur.fetchall() - - # Format für Select2 - results = [] - for customer in customers: - results.append({ - 'id': customer[0], - 'text': f"{customer[1]} - {customer[2]} ({customer[3]} Lizenzen)", - 'name': customer[1], - 'email': customer[2], - 'license_count': customer[3] - }) - - # Gesamtanzahl für Pagination - if search: - cur.execute(""" - SELECT COUNT(*) FROM customers - WHERE LOWER(name) LIKE LOWER(%s) - OR LOWER(email) LIKE LOWER(%s) - """, (f'%{search}%', f'%{search}%')) - else: - cur.execute("SELECT COUNT(*) FROM customers") - - total_count = cur.fetchone()[0] - - cur.close() - conn.close() - - # Select2 Response Format - return jsonify({ - 'results': results, - 'pagination': { - 'more': (page * per_page) < total_count - } - }) - - except Exception as e: - logging.error(f"Fehler bei Kundensuche: {str(e)}") - return jsonify({ - 'results': [], - 'pagination': {'more': False}, - 'error': str(e) - }), 500 \ No newline at end of file diff --git a/v2_adminpanel/routes/customer_routes.py b/v2_adminpanel/routes/customer_routes.py index 78db397..8648f37 100644 --- a/v2_adminpanel/routes/customer_routes.py +++ b/v2_adminpanel/routes/customer_routes.py @@ -24,12 +24,39 @@ def test_customers(): def customers(): show_test = request.args.get('show_test', 'false').lower() == 'true' search = request.args.get('search', '').strip() + page = request.args.get('page', 1, type=int) + per_page = 20 + sort = request.args.get('sort', 'name') + order = request.args.get('order', 'asc') customers_list = get_customers(show_test=show_test, search=search) + + # Sortierung + if sort == 'name': + customers_list.sort(key=lambda x: x['name'].lower(), reverse=(order == 'desc')) + elif sort == 'email': + customers_list.sort(key=lambda x: x['email'].lower(), reverse=(order == 'desc')) + elif sort == 'created_at': + customers_list.sort(key=lambda x: x['created_at'], reverse=(order == 'desc')) + + # Paginierung + total_customers = len(customers_list) + total_pages = (total_customers + per_page - 1) // per_page + start = (page - 1) * per_page + end = start + per_page + paginated_customers = customers_list[start:end] + return render_template("customers.html", - customers=customers_list, + customers=paginated_customers, show_test=show_test, - search=search) + search=search, + page=page, + per_page=per_page, + total_pages=total_pages, + total_customers=total_customers, + sort=sort, + order=order, + current_order=order) @customer_bp.route("/customer/edit/", methods=["GET", "POST"]) diff --git a/v2_adminpanel/templates/base.html b/v2_adminpanel/templates/base.html index 469a253..b2ed743 100644 --- a/v2_adminpanel/templates/base.html +++ b/v2_adminpanel/templates/base.html @@ -363,12 +363,18 @@