Compare commits
642 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| be00256618 | |||
| 7f693f2b58 | |||
| c3bf0dc0fd | |||
| d651a6c02f | |||
| d7039d75a4 | |||
| 6409b0c023 | |||
| af31c551d2 | |||
| a38dfcb75a | |||
| b7b83abb72 | |||
| f076269c32 | |||
| 8d16935c1c | |||
| 49d8de76a2 | |||
| b4d8cabbd8 | |||
| a7f3d652c5 | |||
| 38f3a9a16a | |||
| f7d33a7d2b | |||
| bd11801467 | |||
| e512e5fe6e | |||
| 799c83dd84 | |||
| 4cc18cbc9a | |||
| 4645be53cb | |||
| 79ea398d1d | |||
| 30b1402b54 | |||
| 15d848c1ca | |||
| c9877a3cbe | |||
| 68499ee043 | |||
| bdb61613de | |||
| 59383bdbd8 | |||
| 5d4a94b8ce | |||
| 0c07812ecc | |||
| eb9dba3f47 | |||
| 6c4e035146 | |||
| e78b1040a5 | |||
| b6c7f91a7c | |||
| e998059d81 | |||
| 764e063bfe | |||
| 784616b349 | |||
| b4b10c998c | |||
| 366df18c54 | |||
| 7883efc3d6 | |||
| 6881d97bbb | |||
| 90278f1f5f | |||
| df5b1a072d | |||
| 10e231e52e | |||
| 7c20fde4e8 | |||
| fcf3a2c806 | |||
| 88997492c3 | |||
| 327d4f4d15 | |||
| cf7cc5a84e | |||
| 28f524b762 | |||
| c4dc43e054 | |||
| 767e965028 | |||
| d39063b22f | |||
| ea5d932476 | |||
| acc1323a54 | |||
| 58bfaeaddb | |||
| df5415f30b | |||
| 330d670f3f | |||
| 41cb122a2f | |||
| 1b3e6f2f11 | |||
| 6fa49485d9 | |||
| 71aa023133 | |||
| e567c02658 | |||
| f393dc0840 | |||
| 5dc4b0ec15 | |||
| e68760cc66 | |||
| d9552799c1 | |||
| c75ec0b22a | |||
| 27309f53d9 | |||
| 4193f717ee | |||
| d297985b0b | |||
| 6dcf3a9f0d | |||
| 094609005a | |||
| eb18994b7d | |||
| ff21b27b98 | |||
| 4247981c98 | |||
| c30972f6a7 | |||
| 7628fb9f6e | |||
| 4c48eadebc | |||
| 6ea3860a2f | |||
| 1b8ef69a1b | |||
| 9d710050ad | |||
| 491a7e1c42 | |||
| 7eb8d4e153 | |||
| f63720467a | |||
| 516be6e600 | |||
| 0807a9ff19 | |||
| a93a32f3a7 | |||
| 68de66f526 | |||
| 15aba2b3db | |||
| fa78fa6d09 | |||
| 11f8af003f | |||
| 11ef714e0d | |||
| 909a4039c4 | |||
| bf646112df | |||
| 8a6650e57c | |||
| 25e8c9f8fc | |||
| 1f0fef114b | |||
| be6036e879 | |||
| bba85a309a | |||
| 448be6e512 | |||
| 871ceb866c | |||
| 8e61fe4774 | |||
| d4dfa235e5 | |||
| 97ec39aa80 | |||
| 61a97220d3 | |||
| 2d04f2aa28 | |||
| d6e79161e7 | |||
| 45fd543206 | |||
| ba0a5e9abd | |||
| d712ab8301 | |||
| 1e2a7e3096 | |||
| 64a684cd2f | |||
| e4c207ecff | |||
| 80914c1af7 | |||
| e1822800ab | |||
| 68773b7e77 | |||
| 10538e7351 | |||
| a9687912df | |||
| 46c1a69456 | |||
| bf2b2f1c56 | |||
| 9ac6da490d | |||
| ad87c5fb5e | |||
| 7ac73e5745 | |||
| 61be89743d | |||
| d174d9a927 | |||
| 4048aebb9f | |||
| bfd66c42c1 | |||
| 351c95bac1 | |||
| 1d43ae5e25 | |||
| 476b89fe8e | |||
| 14e13177b5 | |||
| 760c833b74 | |||
| 1a476c49c1 | |||
| 454b83138e | |||
| 2de438426a | |||
| 6039db997c | |||
| 940959c951 | |||
| e688110129 | |||
| 57dc1ff47f | |||
| fc42916b10 | |||
| 5893874776 | |||
| 60305d8d5b | |||
| 07ef3b46d9 | |||
| f9d58e964c | |||
| d3c14ac303 | |||
| fda9f3beec | |||
| 27365b3c75 | |||
| 3dac332a9f | |||
| c5124b97b6 | |||
| ee2e79db31 | |||
| d05ff678ab | |||
| fcaa9dfb27 | |||
| c30c2feee1 | |||
| 640da34736 | |||
| 93bb8e0eb4 | |||
| 7d9ced8f4f | |||
| b5998ea9d2 | |||
| aabadf1ffd | |||
| e5e5bb3354 | |||
| ea41b0af7a | |||
| 3193ffbd1b | |||
| 5da01cc6df | |||
| 887ea9cf5a | |||
| bee4aa4284 | |||
| b167ed3dd1 | |||
| 6cec024e46 | |||
| 2094886990 | |||
| b0fd38d60c | |||
| 3a680b41b0 | |||
| 02e40bd6f7 | |||
| 19dabf3851 | |||
| eaf3a07579 | |||
| be1da976e1 | |||
| a1ffd5bda6 | |||
| d58a80f3ea | |||
| 45c7afe34c | |||
| e43a8d6ce4 | |||
| e94b93d0c1 | |||
| 2ad26a9156 | |||
| 3383b9c790 | |||
| 6acaf18203 | |||
| 019fbc12b6 | |||
| 5156d635f0 | |||
| f77922522a | |||
| 0db00a8f90 | |||
| fe54640885 | |||
| ec8eaf52bf | |||
| fe5f5ded96 | |||
| 2cb6d731e9 | |||
| 184c52d4ea | |||
| 8e85e244cb | |||
| 43a326a98c | |||
| d9de15a5a0 | |||
| 17ce64037f | |||
| 79793c47bb | |||
| 7091eb9c14 | |||
| dc1e96bbe9 | |||
| 1e37fd9dd2 | |||
| 9aa22af339 | |||
| 26637f0ae5 | |||
| 6d67cad3e7 | |||
| 90d9df8f0e | |||
| ef654a09bb | |||
| 74f3b388d8 | |||
| 5af1f358b2 | |||
| 994325360c | |||
| f0e7f8e3be | |||
| 40d8a6dc8b | |||
| 6488dcfcb2 | |||
| 9ec5f7f59e | |||
| 0621a3febc | |||
| ebd6bee05e | |||
| 119f463c36 | |||
| a3903a252e | |||
| 65ba50c2cf | |||
| cc54a7ad2f | |||
| 7b0403a307 | |||
| 142ff14a50 | |||
| 29a3b395b6 | |||
| 49871f82f3 | |||
| efa3a5d457 | |||
| 0b86f379b4 | |||
| 1cae7130bc | |||
| 45410168af | |||
| 5482e609f8 | |||
| e79f598d17 | |||
| 056070b6a4 | |||
| 8b53c73ce8 | |||
| e6ebf81752 | |||
| 11b8ab1959 | |||
| 17e9259eb1 | |||
| 02c30f8e7e | |||
| 15c7cc2a58 | |||
| f2dbab6171 | |||
| 17af51ce0b | |||
| 8f7fa76e6a | |||
| 4648a82da9 | |||
| 5be82f2d78 | |||
| 9e3aa23f69 | |||
| 46da31d89c | |||
| 5f219c33e6 | |||
| 94fcf20d17 | |||
| 9d39901f78 | |||
| 5cf3f2f76e | |||
| 2f90f9134d | |||
| c57de1081a | |||
| 33d5155a8d | |||
| 9828ff0845 | |||
| da6f746b09 | |||
| 165965d8ea | |||
| e7e01ce9c8 | |||
| c4c49571ec | |||
| 4a63124818 | |||
| d6fa86b870 | |||
| 55d281b0e3 | |||
| a42bc9b281 | |||
| 5bc5567ab1 | |||
| d88ca980ec | |||
| 0ef4f55304 | |||
| 5070bb34e8 | |||
| 02d38d7891 | |||
| c46cc85144 | |||
| 071c23dd52 | |||
| 5f3e426750 | |||
| a9eee19c95 | |||
| 552a8ff8d8 | |||
| bec78beb25 | |||
| a6561a4788 | |||
| c49acc956f | |||
| 4c40b5b370 | |||
| 7c1a142653 | |||
| a161e2f904 | |||
| ad260d9fe0 | |||
| 3fd3bce2cf | |||
| ad6522f547 | |||
| 9ea533a864 | |||
| 61bb8fcafd | |||
| ff5bba3fc5 | |||
| cc1c8bacb4 | |||
| b707b7995b | |||
| a076dfb550 | |||
| 91ac90c2ae | |||
| b12544d3ea | |||
| 38018514dc | |||
| 924d6bc09c | |||
| 1021c2cdcd | |||
| fa01819674 | |||
| eeed2d6369 | |||
| a194be0843 | |||
| aa1cd3829c | |||
| 6c2c25d2cc | |||
| 0b61c7fe48 | |||
| b6745508da | |||
| f5391c3159 | |||
| 9a81932d2b | |||
| 8b50fde9aa | |||
| 8b924df64f | |||
| 7102c50f52 | |||
| e4c65ca220 | |||
| 557dbf3ebe | |||
| 462f7a99b9 | |||
| c68c4e5984 | |||
| f00a051bb3 | |||
| b94e3edf80 | |||
| 51ca3e42b7 | |||
| 943e9a86aa | |||
| c4da591731 | |||
| 287a43ba65 | |||
| 070321230b | |||
| 8886d0dea2 | |||
| d3819c492f | |||
| 3cbb9ef024 | |||
| 41633af763 | |||
| 916ad9d567 | |||
| ccd9ab02c5 | |||
| d0a9cfe6a9 | |||
| 333a3c5901 | |||
| 0de6418bca | |||
| e9b98b837a | |||
| 97be4afbff | |||
| 9ca24efe43 | |||
| 116c8453bb | |||
| 4e7438b9f9 | |||
| c4794db1dd | |||
| 1ffb8a5b85 | |||
| 7ab1a7b88d | |||
| e3f932afe4 | |||
| 5ca2f62854 | |||
| 8afe613451 | |||
| 8de9c6e532 | |||
| b69fcbde11 | |||
| 44d6b6b266 | |||
| 4234769e92 | |||
| c2cabbee09 | |||
| 067b1b6716 | |||
| 5b64046137 | |||
| 085a61721a | |||
| 4c2e11e64b | |||
| 7c5f35b1cf | |||
| 561708b777 | |||
| 806a959662 | |||
| 966a599c2c | |||
| 0ed98c39b6 | |||
| 095bc00b33 | |||
| 43762ce8f4 | |||
| 51752713b3 | |||
| b6fc63e33c | |||
| 488d5c99f7 | |||
| 9151882245 | |||
| 02eca28bc0 | |||
| 3f7ca7be3a | |||
| f7abe03dba | |||
| 992a5cb97f | |||
| 3d94e63c3c | |||
| 79569510d2 | |||
| b9352a5bd5 | |||
| 13ebfdb5c4 | |||
| e8a413c3c3 | |||
| aa1d3289e1 | |||
| 94a00ab269 | |||
| b8682adbbf | |||
| cf10eeda53 | |||
| 3cfac7e2dc | |||
| 39e111c91b | |||
| ba42517808 | |||
| 13846d621a | |||
| e612e2f383 | |||
| 1f524ccd80 | |||
| 37bde2985e | |||
| c6a5e0d4be | |||
| 3985053636 | |||
| 4cfb1dd38f | |||
| 117a417ce0 | |||
| a2e0474572 | |||
| e992891691 | |||
| 57cf8687d4 | |||
| 27870bbdcb | |||
| 21d48078ed | |||
| afb4f162d0 | |||
| 96d479780f | |||
| 2e0cd5d54c | |||
| c4374ce483 | |||
| bd1a0d4a4e | |||
| 97f4cc0f7c | |||
| 3ebbb42096 | |||
| 07ae7c2a7f | |||
| 5c3505aec9 | |||
| fffa231b7e | |||
| f8db099d0f | |||
| 9483517258 | |||
| 0c0ab58236 | |||
| 92ec1219fa | |||
| bf72e81d55 | |||
| 5a008e59a2 | |||
| 61b8d586d3 | |||
| df62e2bd96 | |||
| 07e8990ddb | |||
| 40fc4d7267 | |||
| 5ffd3093ea | |||
| 01ac2c7ed0 | |||
| 937a25aafd | |||
| aeee018a53 | |||
| 5cef11f32b | |||
| 467f67907f | |||
| 6d38531f42 | |||
| 2dd057a911 | |||
| 4178e4b024 | |||
| 21bcde26e5 | |||
| 878606d745 | |||
| 45a90876da | |||
| bb4f496b21 | |||
| 47c1de1cc8 | |||
| f69156d7ee | |||
| c9d559e3f2 | |||
| e28a48853d | |||
| b13da1f2e8 | |||
| ba1bf677d6 | |||
| ccdff05dca | |||
| 03875b593e | |||
| 06224d798d | |||
| 966f436723 | |||
| 4941fe9469 | |||
| fe56078481 | |||
| 39742499b8 | |||
| d8207fa1f0 | |||
| 7eb302aab0 | |||
| 8cb5da4f74 | |||
| cad786f6ee | |||
| 0adc7145a1 | |||
| d35f54a4db | |||
| 000d670869 | |||
| 0c9b69deb0 | |||
| 63e31e84fc | |||
| 1bd301f21a | |||
| 3dbaf99733 | |||
| 4380611bee | |||
| 5aca466d72 | |||
| 86970f0398 | |||
| 733be09658 | |||
| 5e75210c8b | |||
| cb0ccc9c77 | |||
| 078eebfbb1 | |||
| 4049e0d9ff | |||
| 7bf7b942ba | |||
| 4f1c14fda0 | |||
| 7bd6ae3c26 | |||
| caeafc7abd | |||
| 4d249a3bc9 | |||
| 1f26b522b4 | |||
| d1ed6c1b18 | |||
| d4d89ec20d | |||
| 6a2fd5a7de | |||
| f41c5a10b6 | |||
| 3e873a05a4 | |||
| c22f41cbf2 | |||
| f3851b9ecc | |||
| 9a258744f2 | |||
| 8780ae9407 | |||
| 3f886df44f | |||
| e3c6d4a5a1 | |||
| c3215a1e54 | |||
| fdd5a8aa6e | |||
| c1792f487a | |||
| f27f0f8bca | |||
| c70ddfe157 | |||
| 554d776b77 | |||
| 34ac9eb6ed | |||
| 6cc48d2872 | |||
| 9fc31c25b2 | |||
| 73585ebc82 | |||
| e9d3e9a2be | |||
| 3e6e2f577c | |||
| 39c644b505 | |||
| 62500a6369 | |||
| 9e16b56d9a | |||
| 9dfadcda72 | |||
| e2afdc28f3 | |||
| 86bd24edd4 | |||
| 2bda556c4b | |||
| 60724a0354 | |||
| 250edec20e | |||
| 6b1e78f990 | |||
| 56f784c8bf | |||
| ce822ead2b | |||
| e606c1d4d5 | |||
| c204194b8b | |||
| 5a878239f3 | |||
| 08aa8dabfd | |||
| 20ed6071e2 | |||
| 8d34a4b3d2 | |||
| 94a9528397 | |||
| 403052a840 | |||
| bb8e5bb6ca | |||
| 11ee8764a6 | |||
| f9be0e6315 | |||
| 9e56c56528 | |||
| 85cf96b813 | |||
| c16eee4ef2 | |||
| b6bd2625c2 | |||
| 020343cdf8 | |||
| c07fffd809 | |||
| afaeeaf7f1 | |||
| e946cc7354 | |||
| bf6a966668 | |||
| 7f12fa7003 | |||
| 5f6ef5993c | |||
| f1ac67e511 | |||
| 48f9f606aa | |||
| 7d9d1b0a6f | |||
| afadf7160c | |||
| b546fbd1cc | |||
| b8f1785783 | |||
| 7ee8c8ff4d | |||
| 551112d2d9 | |||
| e9dc9630b2 | |||
| b068c1fb12 | |||
| 9f66e176f7 | |||
| c66bad99f0 | |||
| f6a29760a9 | |||
| d7e1b8fd5d | |||
| 54e9175bdf | |||
| f581b10e19 | |||
| 1ae6ea18b7 | |||
| bea6c02d89 | |||
| 153083d791 | |||
| 80538b39ac | |||
| 205f9e8e3c | |||
| 97ccfd3af0 | |||
| 6288d3fb7f | |||
| d611eecece | |||
| 779cccffe6 | |||
| 04144dc7a5 | |||
| b7bfdbb485 | |||
| 21b7c039b7 | |||
| dc7d2eb02a | |||
| 6c26d5c7f8 | |||
| 06e99d5a43 | |||
| a7712c7151 | |||
| 418d329b75 | |||
| a4d5b8067c | |||
| f61ee0f64a | |||
| 841b1e4c52 | |||
| 927ec91e0e | |||
| 9cc2a285dc | |||
| f11173d5aa | |||
| 7bd5d13cc8 | |||
| c30e22e1b1 | |||
| ffff91466e | |||
| a7669d0f35 | |||
| 1371f21d17 | |||
| ec553d3334 | |||
| 638644d1e9 | |||
| 8ae6ef5cb8 | |||
| 1092301767 | |||
| 365f75447f | |||
| 2e3dbaa18c | |||
| 68df186f72 | |||
| 8d3bedefb0 | |||
| 701a4d51de | |||
| fbb45c10c1 | |||
| e4f182ffc6 | |||
| fa94042284 | |||
| b258715cc1 | |||
| 4e9f01d439 | |||
| 5dad41953a | |||
| dd9b33af83 | |||
| fbf2325a42 | |||
| 11eb27437b | |||
| 1bb5ef85aa | |||
| 35c5d78963 | |||
| 38ff15e794 | |||
| 2992dfbada | |||
| a1e5a71ad3 | |||
| 28dbe3dca7 | |||
| 4f77ece187 | |||
| 56a69fa1ba | |||
| 5c94bda60a | |||
| b8d0e6eefb | |||
| 49b48164fd | |||
| f688c74aee | |||
| fe42548dd7 | |||
| 7000cd5bc2 | |||
| e3901c8d39 | |||
| 23f72dc4ec | |||
| 55d817bcad | |||
| 8a9994e539 | |||
| f050a62bc6 | |||
| e26464ddad | |||
| 3263606702 | |||
| 86350424d5 | |||
| d2ade1d9ed | |||
| cc47cea268 | |||
| 6f420264f8 | |||
| 5949460572 | |||
| 4330775d01 | |||
| fa5c217dd0 | |||
| aa270c9ab4 | |||
| b7af25644a | |||
| 8b742082a4 | |||
| 8078a4e64d | |||
| 79f2cc0da5 | |||
| 0c769ac16d | |||
| 25aba75413 | |||
| 9afe370a98 | |||
| 6202801793 | |||
| 4d48418b2c | |||
| a21dfa75f0 | |||
| e09d9b6607 | |||
| e312767247 | |||
| 1dfa243736 | |||
| e0d667df28 | |||
| 68a734051d | |||
| 3f43371e60 | |||
| cb8d233a16 | |||
| d95aa7ca5c | |||
| f7fc7d5cb0 | |||
| 5d6d3b962b | |||
| 6bf8f85c80 | |||
| 0a254b1903 | |||
| 9f33331618 | |||
| 595ecff396 | |||
| ad96e8951d | |||
| 385e1b4287 | |||
| 6551ac1fe9 | |||
| 327ba81d25 | |||
| bf65e16d4d | |||
| 9cf24deb93 | |||
| 3791f1db5a | |||
| 0c3070a0cc | |||
| a37ec7a308 | |||
| b161a8604e | |||
| 1e93f24f78 | |||
| f8cd3076e9 | |||
| e557504309 | |||
| 4602f72030 | |||
| d811321808 | |||
| dffb637e8d | |||
| 32129bebc0 | |||
| dd5ee45ab5 | |||
| 95b7152c05 | |||
| aa093a074a |
+63
-14
@@ -51,6 +51,8 @@ TEST_EMAIL_PASSWORD=
|
||||
CABINET_EMAIL_VERIFICATION_EXPIRE_HOURS=24
|
||||
# Время жизни токена сброса пароля в часах
|
||||
CABINET_PASSWORD_RESET_EXPIRE_HOURS=1
|
||||
# Время жизни кода подтверждения смены email в минутах
|
||||
CABINET_EMAIL_CHANGE_CODE_EXPIRE_MINUTES=15
|
||||
|
||||
# ===== SMTP НАСТРОЙКИ (для email в личном кабинете) =====
|
||||
# SMTP сервер (например: smtp.gmail.com, smtp.yandex.ru)
|
||||
@@ -150,7 +152,7 @@ REMNAWAVE_API_KEY=your_api_key_here
|
||||
|
||||
# Тип авторизации: "api_key", "basic_auth", "caddy"
|
||||
REMNAWAVE_AUTH_TYPE=api_key
|
||||
REMNAWAVE_CADDY_TOKEN=YWRtaW46cGFzc3dvcmQ=
|
||||
REMNAWAVE_CADDY_TOKEN=
|
||||
|
||||
# Для панелей с Basic Auth (опционально)
|
||||
REMNAWAVE_USERNAME=
|
||||
@@ -185,6 +187,42 @@ REMNAWAVE_AUTO_SYNC_ENABLED=false
|
||||
# Времена синхронизации (через запятую, формат HH:MM по МСК)
|
||||
REMNAWAVE_AUTO_SYNC_TIMES=03:00
|
||||
|
||||
# ===== REMNAWAVE WEBHOOKS (входящие события из панели) =====
|
||||
# Включить приём вебхуков от панели Remnawave (real-time события)
|
||||
REMNAWAVE_WEBHOOK_ENABLED=false
|
||||
# Путь для приёма вебхуков (должен совпадать с настройкой в панели)
|
||||
REMNAWAVE_WEBHOOK_PATH=/remnawave-webhook
|
||||
# Общий секрет для подписи HMAC-SHA256 (минимум 32 символа)
|
||||
# Сгенерируйте: openssl rand -hex 32
|
||||
# ВАЖНО: этот же секрет указывается в панели Remnawave при создании вебхука
|
||||
REMNAWAVE_WEBHOOK_SECRET=
|
||||
|
||||
# ===== УВЕДОМЛЕНИЯ ОТ ВЕБХУКОВ (что получают пользователи) =====
|
||||
# Глобальный переключатель уведомлений пользователям от вебхуков
|
||||
WEBHOOK_NOTIFY_USER_ENABLED=true
|
||||
# Отключение/активация подписки администратором
|
||||
WEBHOOK_NOTIFY_SUB_STATUS=true
|
||||
# Истечение подписки
|
||||
WEBHOOK_NOTIFY_SUB_EXPIRED=true
|
||||
# Предупреждения о скором истечении (72ч, 48ч, 24ч)
|
||||
WEBHOOK_NOTIFY_SUB_EXPIRING=true
|
||||
# Достижение лимита трафика
|
||||
WEBHOOK_NOTIFY_SUB_LIMITED=true
|
||||
# Сброс счётчика трафика
|
||||
WEBHOOK_NOTIFY_TRAFFIC_RESET=true
|
||||
# Удаление пользователя из панели
|
||||
WEBHOOK_NOTIFY_SUB_DELETED=true
|
||||
# Обновление ключей подписки (revoke)
|
||||
WEBHOOK_NOTIFY_SUB_REVOKED=true
|
||||
# Первое подключение к VPN
|
||||
WEBHOOK_NOTIFY_FIRST_CONNECTED=true
|
||||
# Напоминание о неподключении
|
||||
WEBHOOK_NOTIFY_NOT_CONNECTED=true
|
||||
# Предупреждение о приближении к лимиту трафика
|
||||
WEBHOOK_NOTIFY_BANDWIDTH_THRESHOLD=true
|
||||
# Подключение и отключение устройств
|
||||
WEBHOOK_NOTIFY_DEVICES=true
|
||||
|
||||
# Теги пользователей в Remnawave (A-Z, 0-9, _, макс. 16 символов)
|
||||
# Тег для пробных пользователей (опционально)
|
||||
# TRIAL_USER_TAG=TRIAL
|
||||
@@ -333,7 +371,8 @@ REFERRAL_MINIMUM_TOPUP_KOPEKS=10000
|
||||
REFERRAL_FIRST_TOPUP_BONUS_KOPEKS=10000
|
||||
REFERRAL_INVITER_BONUS_KOPEKS=10000
|
||||
REFERRAL_COMMISSION_PERCENT=25
|
||||
|
||||
# Показывать раздел партнёрки в кабинете
|
||||
REFERRAL_PARTNER_SECTION_VISIBLE=true
|
||||
|
||||
# Уведомления
|
||||
REFERRAL_NOTIFICATIONS_ENABLED=true
|
||||
@@ -346,6 +385,8 @@ REFERRAL_WITHDRAWAL_ENABLED=false
|
||||
REFERRAL_WITHDRAWAL_MIN_AMOUNT_KOPEKS=50000
|
||||
# Интервал между запросами на вывод (дни)
|
||||
REFERRAL_WITHDRAWAL_COOLDOWN_DAYS=30
|
||||
# Текст-подсказка для поля реквизитов при выводе (пустая строка = стандартный текст)
|
||||
REFERRAL_WITHDRAWAL_REQUISITES_TEXT=
|
||||
# Выводить только реферальный баланс (true) или весь баланс (false)
|
||||
REFERRAL_WITHDRAWAL_ONLY_REFERRAL_BALANCE=true
|
||||
# ID топика для уведомлений о заявках на вывод (0 = основной чат)
|
||||
@@ -534,6 +575,8 @@ MULENPAY_MIN_AMOUNT_KOPEKS=10000
|
||||
MULENPAY_MAX_AMOUNT_KOPEKS=10000000
|
||||
# Ожидаемый origin для iframe (опционально, для безопасности)
|
||||
# MULENPAY_IFRAME_EXPECTED_ORIGIN=https://mulenpay.ru
|
||||
# URL для редиректа после оплаты (по умолчанию WEBHOOK_URL)
|
||||
# MULENPAY_WEBSITE_URL=https://your-cabinet-url.com
|
||||
|
||||
# PAYPALYCH / PAL24
|
||||
PAL24_ENABLED=false
|
||||
@@ -542,7 +585,6 @@ PAL24_SHOP_ID=
|
||||
PAL24_SIGNATURE_TOKEN=
|
||||
PAL24_BASE_URL=https://pal24.pro/api/v1/
|
||||
PAL24_WEBHOOK_PATH=/pal24-webhook
|
||||
PAL24_WEBHOOK_PORT=8084
|
||||
PAL24_PAYMENT_DESCRIPTION="Пополнение баланса"
|
||||
PAL24_MIN_AMOUNT_KOPEKS=10000
|
||||
PAL24_MAX_AMOUNT_KOPEKS=100000000
|
||||
@@ -658,8 +700,19 @@ CLOUDPAYMENTS_TEST_MODE=false
|
||||
ENABLE_LOGO_MODE=true
|
||||
LOGO_FILE=vpn_logo.png
|
||||
|
||||
# Режим главного меню (default - классический режим работы бота, text - режим работы с активным ЛК MiniApp, отключает покупку/управление подпиской в меню, заменяет все кнопками открытия в MiniApp ЛК)
|
||||
# Режим главного меню:
|
||||
# default - классический режим работы бота (все кнопки внутри Telegram)
|
||||
# cabinet - режим Cabinet с активным ЛК MiniApp, кнопки ведут на конкретные
|
||||
# разделы кабинета (/balance, /subscription, /referral и т.д.)
|
||||
# Требует MINIAPP_CUSTOM_URL
|
||||
# Алиасы для обратной совместимости: text, text_only, minimal
|
||||
MAIN_MENU_MODE=default
|
||||
# Стиль кнопок в режиме Cabinet (Bot API 9.4):
|
||||
# primary - синий
|
||||
# success - зелёный
|
||||
# danger - красный
|
||||
# (пустое) - цвета по умолчанию для каждой секции
|
||||
CABINET_BUTTON_STYLE=
|
||||
# Включить управление меню через API (позволяет динамически менять структуру кнопок)
|
||||
MENU_LAYOUT_ENABLED=false
|
||||
|
||||
@@ -672,7 +725,7 @@ HIDE_SUBSCRIPTION_LINK=false
|
||||
# miniapp_custom - открывает заданную ссылку в мини-приложении (режим 3)
|
||||
# link - Открывает ссылку напрямую в браузере (режим 4)
|
||||
# happ_cryptolink - Вывод cryptoLink ссылки на подписку Happ (режим 5)
|
||||
CONNECT_BUTTON_MODE=guide
|
||||
CONNECT_BUTTON_MODE=miniapp_subscription
|
||||
|
||||
# URL для режима miniapp_custom (обязателен при CONNECT_BUTTON_MODE=miniapp_custom)
|
||||
MINIAPP_CUSTOM_URL=
|
||||
@@ -739,7 +792,7 @@ MAINTENANCE_MESSAGE=Ведутся технические работы. Серв
|
||||
# ===== ЛОКАЛИЗАЦИЯ =====
|
||||
# Укажите язык из AVAILABLE_LANGUAGES. При некорректном значении используется ru.
|
||||
DEFAULT_LANGUAGE=ru
|
||||
AVAILABLE_LANGUAGES=ru,en,ua,zh
|
||||
AVAILABLE_LANGUAGES=ru,en,ua,zh,fa
|
||||
# Включить выбор языка при старте и отображение кнопки в меню
|
||||
LANGUAGE_SELECTION_ENABLED=true
|
||||
|
||||
@@ -795,6 +848,8 @@ VERSION_CHECK_INTERVAL_HOURS=1
|
||||
# ===== ЛОГИРОВАНИЕ =====
|
||||
LOG_LEVEL=INFO
|
||||
LOG_FILE=logs/bot.log
|
||||
# ANSI-цвета в консоли (true — цветной вывод с Rich, false — plain-text)
|
||||
LOG_COLORS=true
|
||||
|
||||
# === Ротация логов ===
|
||||
# Включить новую систему ротации (по умолчанию старое поведение)
|
||||
@@ -828,7 +883,7 @@ WEBHOOK_MAX_QUEUE_SIZE=1024
|
||||
WEBHOOK_WORKERS=4
|
||||
WEBHOOK_ENQUEUE_TIMEOUT=0.1
|
||||
WEBHOOK_WORKER_SHUTDOWN_TIMEOUT=30.0
|
||||
BOT_RUN_MODE=polling # polling, webhook или both
|
||||
BOT_RUN_MODE=polling # polling или webhook
|
||||
|
||||
# ===== КОНКУРСНАЯ СИСТЕМА =====
|
||||
CONTESTS_ENABLED=false
|
||||
@@ -836,15 +891,9 @@ CONTESTS_BUTTON_VISIBLE=false
|
||||
# Реферальные конкурсы (турниры среди рефералов)
|
||||
REFERRAL_CONTESTS_ENABLED=false
|
||||
|
||||
# ===== АВТОАКТИВАЦИЯ ПОСЛЕ ПОПОЛНЕНИЯ =====
|
||||
# ===== АВТОПОКУПКА ПОСЛЕ ПОПОЛНЕНИЯ =====
|
||||
# Автоматическая покупка из сохранённой корзины после пополнения баланса
|
||||
AUTO_PURCHASE_AFTER_TOPUP_ENABLED=false
|
||||
# Умная автоактивация: система сама решает — продлить или создать подписку
|
||||
# Работает даже без сохранённой корзины. Выбирает максимальный период <= баланса
|
||||
AUTO_ACTIVATE_AFTER_TOPUP_ENABLED=false
|
||||
# Показывать предупреждение об активации подписки после пополнения баланса
|
||||
# Если true - после пополнения показывает сообщение с кнопками: "Активировать", "Продлить", "Добавить устройства"
|
||||
SHOW_ACTIVATION_PROMPT_AFTER_TOPUP=false
|
||||
|
||||
# ===== КНОПКА АКТИВАЦИИ =====
|
||||
ACTIVATE_BUTTON_VISIBLE=false
|
||||
|
||||
@@ -36,15 +36,15 @@ jobs:
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||
echo "🏷️ Собираем релизную версию: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/main ]]; then
|
||||
VERSION="v3.2.0-$(git rev-parse --short HEAD)"
|
||||
VERSION="v3.7.0-$(git rev-parse --short HEAD)" # x-release-please-version
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||
echo "🚀 Собираем версию из main: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
|
||||
VERSION="v3.2.0-dev-$(git rev-parse --short HEAD)"
|
||||
VERSION="v3.7.0-dev-$(git rev-parse --short HEAD)" # x-release-please-version
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:dev,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||
echo "🧪 Собираем dev версию: $VERSION"
|
||||
else
|
||||
VERSION="v3.2.0-pr-$(git rev-parse --short HEAD)"
|
||||
VERSION="v3.7.0-pr-$(git rev-parse --short HEAD)" # x-release-please-version
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:pr-$(git rev-parse --short HEAD)"
|
||||
echo "🔀 Собираем PR версию: $VERSION"
|
||||
fi
|
||||
|
||||
@@ -14,7 +14,7 @@ on:
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: fr1ngg/remnawave-bedolaga-telegram-bot
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
@@ -49,13 +49,13 @@ jobs:
|
||||
VERSION=${GITHUB_REF#refs/tags/}
|
||||
echo "🏷️ Building release version: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/main ]]; then
|
||||
VERSION="v3.2.0-$(git rev-parse --short HEAD)"
|
||||
VERSION="v3.7.0-$(git rev-parse --short HEAD)" # x-release-please-version
|
||||
echo "🚀 Building main version: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
|
||||
VERSION="v3.2.0-dev-$(git rev-parse --short HEAD)"
|
||||
VERSION="v3.7.0-dev-$(git rev-parse --short HEAD)" # x-release-please-version
|
||||
echo "🧪 Building dev version: $VERSION"
|
||||
else
|
||||
VERSION="v3.2.0-pr-$(git rev-parse --short HEAD)"
|
||||
VERSION="v3.7.0-pr-$(git rev-parse --short HEAD)" # x-release-please-version
|
||||
echo "🔀 Building PR version: $VERSION"
|
||||
fi
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
name: Release Please
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
release-please:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
release_created: ${{ steps.release.outputs.release_created }}
|
||||
tag_name: ${{ steps.release.outputs.tag_name }}
|
||||
version: ${{ steps.release.outputs.version }}
|
||||
steps:
|
||||
- uses: googleapis/release-please-action@v4
|
||||
id: release
|
||||
with:
|
||||
config-file: release-please-config.json
|
||||
manifest-file: .release-please-manifest.json
|
||||
@@ -0,0 +1,168 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
uses: ./.github/workflows/lint.yml
|
||||
|
||||
release:
|
||||
needs: lint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Get previous tag
|
||||
id: prev_tag
|
||||
run: |
|
||||
PREV_TAG=$(git describe --tags --abbrev=0 ${{ github.ref_name }}^ 2>/dev/null || echo "")
|
||||
echo "tag=$PREV_TAG" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Generate changelog
|
||||
id: changelog
|
||||
run: |
|
||||
TAG="${{ github.ref_name }}"
|
||||
PREV_TAG="${{ steps.prev_tag.outputs.tag }}"
|
||||
|
||||
if [ -z "$PREV_TAG" ]; then
|
||||
RANGE="$TAG"
|
||||
else
|
||||
RANGE="${PREV_TAG}..${TAG}"
|
||||
fi
|
||||
|
||||
# Collect commits by category
|
||||
FEATURES=$(git log $RANGE --pretty=format:"%s|%an|%h" --no-merges | grep -iE "^feat" || true)
|
||||
FIXES=$(git log $RANGE --pretty=format:"%s|%an|%h" --no-merges | grep -iE "^fix" || true)
|
||||
PERF=$(git log $RANGE --pretty=format:"%s|%an|%h" --no-merges | grep -iE "^perf|^refactor" || true)
|
||||
DOCS=$(git log $RANGE --pretty=format:"%s|%an|%h" --no-merges | grep -iE "^docs|^style" || true)
|
||||
CHORE=$(git log $RANGE --pretty=format:"%s|%an|%h" --no-merges | grep -iE "^chore|^ci|^build|^test" || true)
|
||||
OTHER=$(git log $RANGE --pretty=format:"%s|%an|%h" --no-merges | grep -ivE "^(feat|fix|perf|refactor|docs|style|chore|ci|build|test)" || true)
|
||||
|
||||
# Collect unique contributors
|
||||
CONTRIBUTORS=$(git log $RANGE --pretty=format:"%an" --no-merges | sort -u)
|
||||
|
||||
# Stats
|
||||
TOTAL_COMMITS=$(git log $RANGE --oneline --no-merges | wc -l | tr -d ' ')
|
||||
FILES_CHANGED=$(git diff --stat $RANGE 2>/dev/null | tail -1 || echo "N/A")
|
||||
|
||||
# Format function
|
||||
format_section() {
|
||||
local commits="$1"
|
||||
if [ -n "$commits" ]; then
|
||||
echo "$commits" | while IFS='|' read -r msg author hash; do
|
||||
# Clean conventional commit prefix
|
||||
clean_msg=$(echo "$msg" | sed -E 's/^(feat|fix|perf|refactor|docs|style|chore|ci|build|test)(\([^)]*\))?:\s*//')
|
||||
echo "- ${clean_msg} (\`${hash}\`) — @${author}"
|
||||
done
|
||||
fi
|
||||
}
|
||||
|
||||
# Build changelog
|
||||
{
|
||||
echo "changelog<<CHANGELOG_EOF"
|
||||
|
||||
if [ -n "$FEATURES" ]; then
|
||||
echo "### New Features"
|
||||
echo ""
|
||||
format_section "$FEATURES"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
if [ -n "$FIXES" ]; then
|
||||
echo "### Bug Fixes"
|
||||
echo ""
|
||||
format_section "$FIXES"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
if [ -n "$PERF" ]; then
|
||||
echo "### Performance & Refactoring"
|
||||
echo ""
|
||||
format_section "$PERF"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
if [ -n "$DOCS" ]; then
|
||||
echo "### Documentation & Style"
|
||||
echo ""
|
||||
format_section "$DOCS"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
if [ -n "$CHORE" ]; then
|
||||
echo "### Maintenance"
|
||||
echo ""
|
||||
format_section "$CHORE"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
if [ -n "$OTHER" ]; then
|
||||
echo "### Other Changes"
|
||||
echo ""
|
||||
format_section "$OTHER"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
echo "---"
|
||||
echo ""
|
||||
echo "### Contributors"
|
||||
echo ""
|
||||
if [ -n "$CONTRIBUTORS" ]; then
|
||||
echo "$CONTRIBUTORS" | while read -r name; do
|
||||
echo "- @${name}"
|
||||
done
|
||||
fi
|
||||
echo ""
|
||||
echo "### Stats"
|
||||
echo ""
|
||||
echo "- **Commits:** ${TOTAL_COMMITS}"
|
||||
echo "- **Changes:** ${FILES_CHANGED}"
|
||||
if [ -n "$PREV_TAG" ]; then
|
||||
echo "- **Full diff:** [\`${PREV_TAG}...${TAG}\`](https://github.com/${{ github.repository }}/compare/${PREV_TAG}...${TAG})"
|
||||
fi
|
||||
|
||||
echo "CHANGELOG_EOF"
|
||||
} >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
name: ${{ github.ref_name }}
|
||||
body: |
|
||||
## What's Changed
|
||||
|
||||
${{ steps.changelog.outputs.changelog }}
|
||||
|
||||
---
|
||||
|
||||
### Docker
|
||||
|
||||
```bash
|
||||
# Docker Hub
|
||||
docker pull fr1ngg/remnawave-bedolaga-telegram-bot:${{ github.ref_name }}
|
||||
|
||||
# GitHub Container Registry
|
||||
docker pull ghcr.io/${{ github.repository }}:${{ github.ref_name }}
|
||||
```
|
||||
|
||||
### Update
|
||||
|
||||
```bash
|
||||
# Docker Compose
|
||||
docker compose pull && docker compose up -d
|
||||
|
||||
# Or with Make
|
||||
make reload
|
||||
```
|
||||
draft: false
|
||||
prerelease: ${{ contains(github.ref_name, 'beta') || contains(github.ref_name, 'alpha') || contains(github.ref_name, 'rc') || contains(github.ref_name, 'dev') }}
|
||||
generate_release_notes: false
|
||||
+85
-32
@@ -1,49 +1,102 @@
|
||||
# Игнорируем все файлы и папки по умолчанию
|
||||
*
|
||||
docker-compose.override.yml
|
||||
|
||||
# Исключения: разрешаем только нужные файлы
|
||||
# ========== WHITELIST: разрешённые файлы ==========
|
||||
|
||||
# Конфигурация проекта
|
||||
!.dockerignore
|
||||
!.env.example
|
||||
!install_bot.sh
|
||||
!.gitignore
|
||||
!.python-version
|
||||
!Dockerfile
|
||||
!app-config.json
|
||||
!main.py
|
||||
!requirements.txt
|
||||
!docker-compose.yml
|
||||
!docker-compose.local.yml
|
||||
!Makefile
|
||||
!pyproject.toml
|
||||
!uv.lock
|
||||
!.python-version
|
||||
!docs/
|
||||
!docs/**
|
||||
!migrations/
|
||||
!migrations/**
|
||||
!requirements.txt
|
||||
!alembic.ini
|
||||
!app-config.json
|
||||
!release-please-config.json
|
||||
!.release-please-manifest.json
|
||||
|
||||
# Документация
|
||||
!README.md
|
||||
!LICENSE
|
||||
!CONTRIBUTING.md
|
||||
!SECURITY.md
|
||||
|
||||
# Скрипты
|
||||
!install_bot.sh
|
||||
!main.py
|
||||
|
||||
# Статические файлы
|
||||
!vpn_logo.png
|
||||
|
||||
# ========== WHITELIST: разрешённые папки ==========
|
||||
|
||||
# Разрешаем папку app/ и все её содержимое рекурсивно
|
||||
!app/
|
||||
!app/**
|
||||
!tests/
|
||||
!tests/**
|
||||
|
||||
# Дополнительно разрешаем README и лицензию (опционально)
|
||||
!README.md
|
||||
!LICENSE
|
||||
|
||||
# Разрешаем .gitignore чтобы он попал в репозиторий
|
||||
!.gitignore
|
||||
|
||||
# Разрешаем .github/ (workflows, pre-commit и т.д.)
|
||||
!migrations/
|
||||
!migrations/**
|
||||
!docs/
|
||||
!docs/**
|
||||
!assets/
|
||||
!assets/**
|
||||
!locales/
|
||||
!locales/**
|
||||
!.github/
|
||||
!.github/**
|
||||
|
||||
# Разрешаем Makefile
|
||||
!Makefile
|
||||
# ========== BLACKLIST: игнорируемые внутри папок ==========
|
||||
|
||||
# Внутри разрешенных папок игнорируем служебные файлы
|
||||
app/__pycache__/
|
||||
app/**/__pycache__/
|
||||
app/**/*.pyc
|
||||
app/**/*.pyo
|
||||
app/**/*.pyd
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
# Python
|
||||
__pycache__/
|
||||
**/__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
|
||||
# Virtual environments
|
||||
.venv/
|
||||
venv/
|
||||
ENV/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Build/dist
|
||||
build/
|
||||
dist/
|
||||
*.egg-info/
|
||||
.eggs/
|
||||
|
||||
# Testing/coverage
|
||||
.coverage
|
||||
htmlcov/
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
|
||||
# Local overrides (не коммитить!)
|
||||
docker-compose.override.yml
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Runtime data
|
||||
logs/
|
||||
data/
|
||||
*.log
|
||||
*.db
|
||||
*.sqlite3
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
".": "3.16.3"
|
||||
}
|
||||
+462
@@ -0,0 +1,462 @@
|
||||
# Changelog
|
||||
|
||||
## [3.16.3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.16.2...v3.16.3) (2026-02-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* 3 user deletion bugs — type cast, inner savepoint, lazy load ([af31c55](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/af31c551d2f23ef01425bdb2db8f255dbc3047e2))
|
||||
* auth middleware catches all commit errors, not just connection errors ([6409b0c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6409b0c023cd7957c43d5c1c3d83e671ccaf959c))
|
||||
* connected_squads stores UUIDs, not int IDs — use get_server_ids_by_uuids ([d7039d7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d7039d75a47fbf67436a9d39f2cd9f65f2646544))
|
||||
* deadlock on user deletion + robust migration 0002 ([b7b83ab](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b7b83abb723913b3167e7462ff592a374c3f421b))
|
||||
* eliminate deadlock by matching lock order with webhook ([d651a6c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d651a6c02f501b7a0ded570f2db6addcc16173a9))
|
||||
* make migration 0002 robust with table existence checks ([f076269](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f076269c323726c683a38db092d907591a26e647))
|
||||
* wrap user deletion steps in savepoints to prevent transaction cascade abort ([a38dfcb](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a38dfcb75a47a185d979a8202f637d8b79812e67))
|
||||
|
||||
## [3.16.2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.16.1...v3.16.2) (2026-02-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* auto-convert naive datetimes to UTC-aware on model load ([f7d33a7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f7d33a7d2b31145a839ee54676816aa657ac90da))
|
||||
* extend naive datetime guard to all model properties ([bd11801](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bd11801467e917d76005d1a782c71f5ae4ffee6e))
|
||||
* handle naive datetime in raw SQL row comparison (payment/common) ([38f3a9a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/38f3a9a16a24e85adf473f2150aad31574a87060))
|
||||
* handle naive datetimes in Subscription properties ([e512e5f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e512e5fe6e9009992b5bc8b9be7f53e0612f234a))
|
||||
* use AwareDateTime TypeDecorator for all datetime columns ([a7f3d65](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a7f3d652c51ecd653900a530b7d38feaf603ecf1))
|
||||
|
||||
## [3.16.1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.16.0...v3.16.1) (2026-02-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add migration for partner system tables and columns ([4645be5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4645be53cbb3799aa6b2b6a623af30460357a554))
|
||||
* add migration for partner system tables and columns ([79ea398](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/79ea398d1db436a7812a799bf01b2c1c3b1b73be))
|
||||
|
||||
## [3.16.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.15.1...v3.16.0) (2026-02-18)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* add admin notifications for partner applications and withdrawals ([cf7cc5a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/cf7cc5a84e295608009f255fcd0dcedb5a2a04a3))
|
||||
* add admin partner settings API (withdrawal toggle, requisites text, partner visibility) ([6881d97](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6881d97bbb1f6cd8ca3609c2d9286a6e4fb24fc3))
|
||||
* add campaign_id to ReferralEarning for campaign attribution ([0c07812](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0c07812ecc9502f54a7745a77b086fc52bdc0e34))
|
||||
* add partner system and withdrawal management to cabinet ([58bfaea](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/58bfaeaddbcbb98cb67dbd507847a0e5c8d07809))
|
||||
* attribute campaign registrations to partner for referral earnings ([767e965](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/767e9650285adc72b067b2c0b8a4d1ac5c5bba57))
|
||||
* blocked user detection during broadcasts, filter blocked from all notifications ([10e231e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/10e231e52e0dbabd9195a2df373b3c95129a5e4f))
|
||||
* enforce 1-to-1 partner-campaign binding with partner info in campaigns ([366df18](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/366df18c547047a7c69192c768970ebc6ee426fc))
|
||||
* expose traffic_reset_mode in subscription response ([59383bd](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/59383bdbd8c72428d151cb24d132452414b14fa3))
|
||||
* expose traffic_reset_mode in tariff API response ([5d4a94b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5d4a94b8cea8f16f0b4c31e24a4695bee4c67af7))
|
||||
* include partner campaigns in /partner/status response ([ea5d932](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ea5d932476553ad1750da3bebbd4b8f055478040))
|
||||
* link campaign registrations to partner for referral earnings ([c4dc43e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c4dc43e054e9faec2f9614fe51a64635f80c1796))
|
||||
* notify users on partner/withdrawal approve/reject ([327d4f4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/327d4f4d1559e37dc591adbfd0c839d986d1068d))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add blocked_count column migration to universal_migration.py ([b4b10c9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b4b10c998cadbb879540e56dbd0e362b5497ee57))
|
||||
* add missing payment providers to payment_utils and fix {total_amount} formatting ([bdb6161](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bdb61613de378efab4de6de98fde2de3b554c548))
|
||||
* add selectinload for subscription in campaign user list ([eb9dba3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/eb9dba3f4728b478f2206ff992700a9677f879c7))
|
||||
* campaign web link uses ?campaign= param, not ?start= ([28f524b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/28f524b7622ed975d2fece66edc94d9713354738))
|
||||
* correct subscription_service import in broadcast cleanup ([6c4e035](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6c4e035146934dffb576477cc75f7365b2f27b99))
|
||||
* critical security and data integrity fixes for partner system ([8899749](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/88997492c3534ea2f6e194c0382c77302557c2f3))
|
||||
* handle YooKassa NotFoundError gracefully in get_payment_info ([df5b1a0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/df5b1a072d99ff8aee0c94304b2a0214f0fcffe7))
|
||||
* medium-priority fixes for partner system ([7c20fde](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7c20fde4e887749d72280a8804467645e5bab416))
|
||||
* move PartnerStatus enum before User class to fix NameError ([acc1323](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/acc1323a542b8e92433cabf1334d2d98bfa21e21))
|
||||
* prevent fileConfig from destroying structlog handlers ([e78b104](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e78b1040a50ac14759bceab396d0c3e34dd79cdd))
|
||||
* reorder button_click_logs migration to nullify before ALTER TYPE ([df5415f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/df5415f30b2aae4412ff5fbd3cac8076128b818c))
|
||||
* resolve HIGH-priority performance and security issues in partner system ([fcf3a2c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fcf3a2c8062752b2b1dc06b5993ac2d8ae80ee85))
|
||||
* return zeroed stats dict when withdrawal is disabled ([7883efc](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7883efc3d6e6d8bedf8e4b7d72634cbab6e2f3d7))
|
||||
* unassign all campaigns when revoking partner status ([d39063b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d39063b22ffb6442e275db39704361cdb9251793))
|
||||
|
||||
|
||||
### Refactoring
|
||||
|
||||
* replace universal_migration.py with Alembic ([b6c7f91](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b6c7f91a7c79d108820c9f89c9070fde4843316c))
|
||||
* replace universal_migration.py with Alembic ([784616b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/784616b349ef12b35ee021dd7a7b2a2ef9fc57f6))
|
||||
|
||||
## [3.15.1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.15.0...v3.15.1) (2026-02-17)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add naive datetime guards to fromisoformat() in Redis cache readers ([1b3e6f2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1b3e6f2f11c20aa240da1beb11dd7dfb20dbe6e8))
|
||||
* add naive datetime guards to fromisoformat() in Redis cache readers ([6fa4948](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6fa49485d9f1cd678cb5f9fa7d0375fd47643239))
|
||||
|
||||
## [3.15.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.14.1...v3.15.0) (2026-02-17)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* add LOG_COLORS env setting to toggle console ANSI colors ([27309f5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/27309f53d9fa0ba9a2ca07a65feed96bf38f470c))
|
||||
* add web campaign links with bonus processing in auth flow ([d955279](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d9552799c17a76e2cc2118699528c5b591bd97fb))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* AttributeError in withdrawal admin notification (send_to_admins → send_admin_notification) ([c75ec0b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c75ec0b22a3f674d3e1a24b9d546eca1998701b3))
|
||||
* remove local UTC re-imports shadowing module-level import in purchase.py ([e68760c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e68760cc668016209f4f19a2e08af8680343d6ed))
|
||||
|
||||
## [3.14.1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.14.0...v3.14.1) (2026-02-17)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add naive datetime guards to parsers and fix test datetime literals ([0946090](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/094609005af7358bf5d34d252fc66685bd25751c))
|
||||
* address remaining abs() issues from review ([ff21b27](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ff21b27b98bb5a7517e06057eb319c9f3ebb74c7))
|
||||
* complete datetime.utcnow() → datetime.now(UTC) migration ([eb18994](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/eb18994b7d34d777ca39d3278d509e41359e2a85))
|
||||
* normalize transaction amount signs across all aggregations ([4247981](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4247981c98111af388c98628c1e61f0517c57417))
|
||||
* prevent negative amounts in spent display and balance history ([c30972f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c30972f6a7911a89a6c3f2080019ff465d11b597))
|
||||
|
||||
## [3.14.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.13.0...v3.14.0) (2026-02-16)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* show all active webhook endpoints in startup log ([9d71005](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9d710050ad40ba76a14aa6ace8e8a47f25cdde94))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* force basicConfig to replace pre-existing handlers ([7eb8d4e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7eb8d4e153bab640a5829f75bfa6f70df5763284))
|
||||
* NameError in set_user_devices_button — undefined action_text ([1b8ef69](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1b8ef69a1bbb7d8d86827cf7aaa4f05cbf480d75))
|
||||
* remove unused PaymentService from MonitoringService init ([491a7e1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/491a7e1c425a355e55b3020e2bcc7b96047bdf5e))
|
||||
* resolve MissingGreenlet error when accessing subscription.tariff ([a93a32f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a93a32f3a7d1b259a2e24954ae5d2b7c966c5639))
|
||||
* sync support mode from cabinet admin to SupportSettingsService ([516be6e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/516be6e600a08ad700d83b793dc64b2ca07bdf44))
|
||||
* sync SUPPORT_SYSTEM_MODE between SystemSettings and SupportSettings ([0807a9f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0807a9ff19d1eb4f1204f7cbeb1da1c1cfefe83a))
|
||||
|
||||
|
||||
### Refactoring
|
||||
|
||||
* improve log formatting — logger name prefix and table alignment ([f637204](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f63720467a935bdaaa58bb34d588d65e46698f26))
|
||||
|
||||
## [3.13.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.12.1...v3.13.0) (2026-02-16)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* colored console logs via structlog + rich + FORCE_COLOR ([bf64611](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bf646112df02aa7aa7918d0513cb6968ceb7f378))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* limit Rich traceback output to prevent console flood ([11ef714](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/11ef714e0dde25a08711c0daeee943b6e71e20b7))
|
||||
* resolve exc_info for admin notifications, clean log formatting ([11f8af0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/11f8af003fc60384abafa2b670b89d6ad3ac57a4))
|
||||
* suppress startup log noise (~350 lines → ~30) ([8a6650e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8a6650e57cd8ea396d9b057a7753469947f38d29))
|
||||
* traceback in Telegram notifications + reduce log padding ([909a403](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/909a4039c43b910761bd05c36e79c8e6773199db))
|
||||
* use sync context manager for structlog bound_contextvars ([25e8c9f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/25e8c9f8fc4d2c66d5a1407d3de5c7402dc596da))
|
||||
|
||||
|
||||
### Refactoring
|
||||
|
||||
* complete structlog migration with contextvars, kwargs, and logging hardening ([1f0fef1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1f0fef114bd979b2b0d2bd38dde6ce05e7bba07b))
|
||||
|
||||
## [3.12.1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.12.0...v3.12.1) (2026-02-16)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add /start burst rate-limit to prevent spam abuse ([61a9722](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/61a97220d30031816ab23e33a46717e4895c0758))
|
||||
* add promo code anti-abuse protections ([97ec39a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/97ec39aa803f0e3f03fdcd482df0cbcb86fd1efd))
|
||||
* handle TelegramBadRequest in ticket edit_message_text calls ([8e61fe4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8e61fe47746da2ac09c3ea8c4dbfc6be198e49e3))
|
||||
* replace deprecated Query(regex=) with pattern= ([871ceb8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/871ceb866ccf1f3a770c7ef33406e1a43d0a7ff7))
|
||||
|
||||
## [3.12.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.11.0...v3.12.0) (2026-02-15)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* add 'default' (no color) option for button styles ([10538e7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/10538e735149bf3f3f2029ff44b94d11d48c478e))
|
||||
* add button style and emoji support for cabinet mode (Bot API 9.4) ([bf2b2f1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bf2b2f1c5650e527fcac0fb3e72b4e6e19bef406))
|
||||
* add per-button enable/disable toggle and custom labels per locale ([68773b7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/68773b7e77aa344d18b0f304fa561c91d7631c05))
|
||||
* add per-section button style and emoji customization via admin API ([a968791](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a9687912dfe756e7d772d96cc253f78f2e97185c))
|
||||
* add web admin button for admins in cabinet mode ([9ac6da4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9ac6da490dffa03ce823009c6b4e5014b7d2bdfb))
|
||||
* rename MAIN_MENU_MODE=text to cabinet with deep-linking to frontend sections ([ad87c5f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ad87c5fb5e1a4dd0ef7691f12764d3df1530f643))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* daily tariff subscriptions stuck in expired/disabled with no resume path ([80914c1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/80914c1af739aa0ee1ea75b0e5871bf391b9020d))
|
||||
* filter out traffic packages with zero price from purchase options ([64a684c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/64a684cd2ff51e663a1f70e61c07ca6b4f6bfc91))
|
||||
* handle photo message in ticket creation flow ([e182280](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e1822800aba3ea5eee721846b1e0d8df0a9398d1))
|
||||
* handle tariff_extend callback without period (back button crash) ([ba0a5e9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ba0a5e9abd9bd582968d69a5c6e57f336094c782))
|
||||
* pre-validate CABINET_BUTTON_STYLE to prevent invalid values from suppressing per-section defaults ([46c1a69](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/46c1a69456036cb1be784b8d952f27110e9124eb))
|
||||
* remove redundant trial inactivity monitoring checks ([d712ab8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d712ab830166cab61ce38dd32498a8a9e3e602b0))
|
||||
* webhook notification 'My Subscription' button uses unregistered callback_data ([1e2a7e3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1e2a7e3096af11540184d60885b8c08d73506c4a))
|
||||
|
||||
## [3.11.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.10.3...v3.11.0) (2026-02-12)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* add cabinet admin API for pinned messages management ([1a476c4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1a476c49c19d1ec2ab2cda1c2ffb5fd242288bb6))
|
||||
* add startup warnings for missing HAPP_CRYPTOLINK_REDIRECT_TEMPLATE and MINIAPP_CUSTOM_URL ([476b89f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/476b89fe8e613c505acfc58a9554d31ccf92718a))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add passive_deletes to Subscription relationships to prevent NOT NULL violation on cascade delete ([bfd66c4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bfd66c42c1fba3763f41d641cea1bd101ec8c10c))
|
||||
* add startup warning for missing HAPP_CRYPTOLINK_REDIRECT_TEMPLATE in guide mode ([1d43ae5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1d43ae5e25ffcf0e4fe6fec13319d393717e1e50))
|
||||
* flood control handling in pinned messages and XSS hardening in HTML sanitizer ([454b831](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/454b83138e4db8dc4f07171ee6fe262d2cd6d311))
|
||||
* suppress expired callback query error in AuthMiddleware ([2de4384](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2de438426a647e2bcae9b4d99eef4093ff8b5429))
|
||||
* ticket creation crash and webhook PendingRollbackError ([760c833](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/760c833b7402541d3c7cf2ed7fc0418119e75042))
|
||||
|
||||
## [3.10.3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.10.2...v3.10.3) (2026-02-12)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* handle unique constraint conflicts during backup restore without clear_existing ([5893874](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/589387477624691e0026086800428e7e52e06128))
|
||||
* harden backup create/restore against serialization and constraint errors ([fc42916](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fc42916b10bb698895eb75c0e2568747647555d3))
|
||||
* resolve deadlock on server_squads counter updates and add webhook notification toggles ([57dc1ff](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/57dc1ff47f2f6183351db7594544a07ca6f27250))
|
||||
|
||||
## [3.10.2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.10.1...v3.10.2) (2026-02-12)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* allow email change for unverified emails ([93bb8e0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/93bb8e0eb492ca59e29da86594e84e9c486fea65))
|
||||
* clean stale squad UUIDs from tariffs during server sync ([fcaa9df](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fcaa9dfb27350ceda3765c6980ad67f671477caf))
|
||||
* delete subscription_servers before subscription to prevent FK violation ([7d9ced8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7d9ced8f4f71b43ed4ac798e6ff904a086e1ac4a))
|
||||
* handle StaleDataError in webhook user.deleted server counter decrement ([c30c2fe](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c30c2feee1db03f0a359b291117da88002dd0fe0))
|
||||
* handle time/date types in backup JSON serialization ([27365b3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/27365b3c7518c09229afcd928f505d0f3f66213f))
|
||||
* HTML parse fallback, email change race condition, username length limit ([d05ff67](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d05ff678abfacaa7e55ad3e55f226d706d32a7b7))
|
||||
* payment race conditions, balance atomicity, renewal rollback safety ([c5124b9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c5124b97b63eda59b52d2cbf9e2dcdaa6141ed6e))
|
||||
* remove DisplayNameRestrictionMiddleware ([640da34](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/640da3473662cfdcceaa4346729467600ac3b14f))
|
||||
* suppress bot-blocked-by-user error in AuthMiddleware ([fda9f3b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fda9f3beecbfcca4d7abc16cf661d5ad5e3b5141))
|
||||
* UnboundLocalError for get_logo_media in required_sub_channel_check ([d3c14ac](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d3c14ac30363839d1340129f279a7a7b4b021ed1))
|
||||
* use traffic topup config and add WATA 429 retry ([b5998ea](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b5998ea9d22644ed2914b0e829b3a76a32a69ddf))
|
||||
|
||||
|
||||
### Refactoring
|
||||
|
||||
* remove modem functionality from classic subscriptions ([ee2e79d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ee2e79db3114fe7a9852d2cd33c4b4fbbde311ea))
|
||||
|
||||
## [3.10.1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.10.0...v3.10.1) (2026-02-11)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* address review issues in backup, updates, and webhook handlers ([2094886](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/20948869902dc570681b05709ac8d51996330a6e))
|
||||
* allow purchase when recalculated price is lower than cached ([19dabf3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/19dabf38512ae0c2121108d0b92fc8f384292484))
|
||||
* change CryptoBot URL priority to bot_invoice_url for Telegram opening ([3193ffb](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3193ffbd1bee07cb79824d87cb0f77b473b22989))
|
||||
* clear subscription data when user deleted from Remnawave panel ([b0fd38d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b0fd38d60c22247a0086c570665b92c73a060f2f))
|
||||
* downgrade Telegram timeout errors to warning in monitoring service ([e43a8d6](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e43a8d6ce4c40a7212bf90644f82da109717bdcb))
|
||||
* expand backup coverage to all 68 models and harden restore ([02e40bd](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/02e40bd6f7ef8e653cae53ccd127f2f79009e0d4))
|
||||
* handle nullable traffic_limit_gb and end_date in subscription model ([e94b93d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e94b93d0c10b4e61d7750ca47e1b2f888f5873ed))
|
||||
* handle StaleDataError in webhook when user already deleted ([d58a80f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d58a80f3eaa64a6fc899e10b3b14584fb7fc18a9))
|
||||
* ignore 'message is not modified' on privacy policy decline ([be1da97](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/be1da976e14a35e6cca01a7fca7529c55c1a208b))
|
||||
* preserve purchased traffic when extending same tariff ([b167ed3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b167ed3dd1c6e6239db2bdbb8424bcb1fb7715d9))
|
||||
* prevent cascading greenlet errors after sync rollback ([a1ffd5b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a1ffd5bda6b63145104ce750835d8e6492d781dc))
|
||||
* protect server counter callers and fix tariff change detection ([bee4aa4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bee4aa42842b8b6611c7c268bcfced408a227bc0))
|
||||
* suppress 'message is not modified' error in updates panel ([3a680b4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3a680b41b0124848572809d187cab720e1db8506))
|
||||
* use callback fallback when MINIAPP_CUSTOM_URL is not set ([eaf3a07](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/eaf3a07579729031030308d77f61a5227b796c02))
|
||||
* use flush instead of commit in server counter functions ([6cec024](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6cec024e46ef9177cb59aa81590953c9a75d81bb))
|
||||
|
||||
## [3.10.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.9.1...v3.10.0) (2026-02-10)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* add all remaining RemnaWave webhook events (node, service, crm, device) ([1e37fd9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1e37fd9dd271814e644af591343cada6ab12d612))
|
||||
* add close button to all webhook notifications ([d9de15a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d9de15a5a06aec3901415bdfd25b55d2ca01d28c))
|
||||
* add MULENPAY_WEBSITE_URL setting for post-payment redirect ([fe5f5de](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fe5f5ded965e36300e1c73f25f16de22f84651ad))
|
||||
* add RemnaWave incoming webhooks for real-time subscription events ([6d67cad](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6d67cad3e7aa07b8490d88b73c38c4aca6b9e315))
|
||||
* handle errors.bandwidth_usage_threshold_reached_max_notifications webhook ([8e85e24](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8e85e244cb786fb4c06162f2b98d01202e893315))
|
||||
* handle service.subpage_config_changed webhook event ([43a326a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/43a326a98ccc3351de04d9b2d660d3e7e0cb0efc))
|
||||
* unified notification delivery for webhook events (email + WS support) ([26637f0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/26637f0ae5c7264c0430487d942744fd034e78e8))
|
||||
* webhook protection — prevent sync/monitoring from overwriting webhook data ([184c52d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/184c52d4ea3ce02d40cf8a5ab42be855c7c7ae23))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add action buttons to webhook notifications and fix empty device names ([7091eb9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7091eb9c148aaf913c4699fc86fef5b548002668))
|
||||
* add missing placeholders to Arabic SUBSCRIPTION_INFO template ([fe54640](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fe546408857128649930de9473c7cde1f7cc450a))
|
||||
* allow non-HTTP deep links in crypto link webhook updates ([f779225](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f77922522a85b3017be44b5fc71da9c95ec16379))
|
||||
* build composite device name from platform + hwid short suffix ([17ce640](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/17ce64037f198837c8f2aa7bf863871f60bdf547))
|
||||
* downgrade transient API errors (502/503/504) to warning level ([ec8eaf5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ec8eaf52bfdc2bde612e4fc0324575ba7dc6b2e1))
|
||||
* extract device name from nested hwidUserDevice object ([79793c4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/79793c47bbbdae8b0f285448d5f70e90c9d4f4b0))
|
||||
* preserve payment initiation time in transaction created_at ([90d9df8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/90d9df8f0e949913f09c4ebed8fe5280453ab3ab))
|
||||
* security and architecture fixes for webhook handlers ([dc1e96b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/dc1e96bbe9b4496e91e9dea591c7fc0ef4cc245b))
|
||||
* stop CryptoBot webhook retry loop and save cabinet payments to DB ([2cb6d73](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2cb6d731e96cbfc305b098d8424b84bfd6826fb4))
|
||||
* sync subscription status from panel in user.modified webhook ([5156d63](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5156d635f0b5bc0493e8f18ce9710cca6ff4ffc8))
|
||||
* use event field directly as event_name (already includes scope prefix) ([9aa22af](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9aa22af3390a249d1b500d75a7d7189daaed265e))
|
||||
* webhook:close button not working due to channel check timeout ([019fbc1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/019fbc12b6cf61d374bbed4bce3823afc60445c9))
|
||||
|
||||
## [3.9.1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.9.0...v3.9.1) (2026-02-10)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* don't delete Heleket invoice message on status check ([9943253](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/994325360ca7665800177bfad8f831154f4d733f))
|
||||
* safe HTML preview truncation and lazy-load subscription fallback ([40d8a6d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/40d8a6dc8baf3f0f7c30b0883898b4655a907eb5))
|
||||
* use actual DB columns for subscription fallback query ([f0e7f8e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f0e7f8e3bec27d97a3f22445948b8dde37a92438))
|
||||
|
||||
## [3.9.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.8.0...v3.9.0) (2026-02-09)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* add lite mode functionality with endpoints for retrieval and update ([7b0403a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7b0403a307702c24efefc5c14af8cb2fb7525671))
|
||||
* add Persian (fa) locale with complete translations ([29a3b39](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/29a3b395b6e67e4ce2437b75120b78c76b69ff4f))
|
||||
* allow tariff deletion with active subscriptions ([ebd6bee](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ebd6bee05ed7d9187de9394c64dfd745bb06b65a))
|
||||
* **localization:** add Persian (fa) locale support and wire it across app flows ([cc54a7a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/cc54a7ad2fb98fe6e662e1923027f4989ae72868))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* nullify payment FK references before deleting transactions in user restoration ([0b86f37](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0b86f379b4e55e499ca3d189137e2aed865774b5))
|
||||
* prevent sync from overwriting end_date for non-ACTIVE panel users ([49871f8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/49871f82f37d84979ea9ec91055e3f046d5854be))
|
||||
* promo code max_uses=0 conversion and trial UX after promo activation ([1cae713](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1cae7130bc87493ab8c7691b3c22ead8189dab55))
|
||||
* skip users with active subscriptions in admin inactive cleanup ([e79f598](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e79f598d17ffa76372e6f88d2a498accf8175c76))
|
||||
* use selection.period.days instead of selection.period_days ([4541016](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/45410168afe683675003a1c41c17074a54ce04f1))
|
||||
|
||||
|
||||
### Performance
|
||||
|
||||
* cache logo file_id to avoid re-uploading on every message ([142ff14](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/142ff14a502e629446be7d67fab880d12bee149d))
|
||||
|
||||
|
||||
### Refactoring
|
||||
|
||||
* remove "both" mode from BOT_RUN_MODE, keep only polling and webhook ([efa3a5d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/efa3a5d4579f24dabeeba01a4f2e981144dd6022))
|
||||
* remove Flask, use FastAPI exclusively for all webhooks ([119f463](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/119f463c36a95685c3bc6cdf704e746b0ba20d56))
|
||||
* remove smart auto-activation & activation prompt, fix production bugs ([a3903a2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a3903a252efdd0db4b42ca3fd6771f1627050a7f))
|
||||
|
||||
## [3.8.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.7.2...v3.8.0) (2026-02-08)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* add admin device management endpoints ([c57de10](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c57de1081a9e905ba191f64c37221c36713c82a6))
|
||||
* add admin traffic packages and device limit management ([2f90f91](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2f90f9134df58b8c0a329c20060efcf07d5d92f9))
|
||||
* add admin updates endpoint for bot and cabinet releases ([11b8ab1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/11b8ab1959e83fafe405be0b76dfa3dd1580a68b))
|
||||
* add endpoint for updating user referral commission percent ([da6f746](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/da6f746b093be8cdbf4e2889c50b35087fbc90de))
|
||||
* add enrichment data to CSV export ([f2dbab6](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f2dbab617155cdc41573d885f0e55222e5b9825b))
|
||||
* add server-side sorting for enrichment columns ([15c7cc2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/15c7cc2a58e1f1935d10712a981466629db251d1))
|
||||
* add system info endpoint for admin dashboard ([02c30f8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/02c30f8e7eb6ba90ed8983cfd82199a22b473bbf))
|
||||
* add traffic usage enrichment endpoint with devices, spending, dates, last node ([5cf3f2f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5cf3f2f76eb2cd93282f845ea0850f6707bfcc09))
|
||||
* admin panel enhancements & bug fixes ([e6ebf81](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e6ebf81752499df8eb0a710072785e3d603dba33))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add debug logging for bulk device response structure ([46da31d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/46da31d89c55c225dec9136d225f2db967cf8961))
|
||||
* add email field to traffic table for OAuth/email users ([94fcf20](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/94fcf20d17c54efd67fa7bd47eff1afdd1507e08))
|
||||
* add email/UUID fallback for OAuth user panel sync ([165965d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/165965d8ea60a002c061fd75f88b759f2da66d7d))
|
||||
* add enrichment device mapping debug logs ([5be82f2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5be82f2d78aed9b54d74e86f261baa5655e5dcd9))
|
||||
* include additional devices in tariff renewal price and display ([17e9259](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/17e9259eb1d41dbf1d313b6a7d500f6458359393))
|
||||
* paginate bulk device endpoint to fetch all HWID devices ([4648a82](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4648a82da959410603c92055bcde7f96131e0c29))
|
||||
* read bot version from pyproject.toml when VERSION env is not set ([9828ff0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9828ff0845ec1d199a6fa63fe490ad3570cf9c8f))
|
||||
* revert device pagination, add raw user data field discovery ([8f7fa76](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8f7fa76e6ab34a3ad2f61f4e1f06026fd3fbf4e3))
|
||||
* use bulk device endpoint instead of per-user calls ([5f219c3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5f219c33e6d49b0e3e4405a57f8344a4237f1002))
|
||||
* use correct pagination params (start/size) for bulk HWID devices ([17af51c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/17af51ce0bdfa45197384988d56960a1918ab709))
|
||||
* use per-user panel endpoints for reliable device counts and last node data ([9d39901](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9d39901f78ece55c740a5df2603601e5d0b1caca))
|
||||
|
||||
## [3.7.2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.7.1...v3.7.2) (2026-02-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* handle FK violation in create_yookassa_payment when user is deleted ([55d281b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/55d281b0e37a6e8977ceff792cccb8669560945b))
|
||||
* remove dots from Remnawave username sanitization ([d6fa86b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d6fa86b870eccbf22327cd205539dd2084f0014e))
|
||||
|
||||
## [3.7.1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.7.0...v3.7.1) (2026-02-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* release-please config — remove blocked workflow files ([d88ca98](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d88ca980ec67e303e37f0094a2912471929b4cef))
|
||||
* remove workflow files and pyproject.toml from release-please extra-files ([5070bb3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5070bb34e8a09b2641783f5e818bb624469ad610))
|
||||
* resolve HWID reset and webhook FK violation ([5f3e426](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5f3e426750c2adcb097b92f1a9e7725b1c5c5eba))
|
||||
* resolve HWID reset context manager bug and webhook FK violation ([a9eee19](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a9eee19c95efdc38ecf5fa28f7402a2bbba7dd07))
|
||||
* resolve merge conflict in release-please config ([0ef4f55](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0ef4f55304751571754f2027105af3e507f75dfd))
|
||||
* resolve multiple production errors and performance issues ([071c23d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/071c23dd5297c20527442cb5d348d498ebf20af4))
|
||||
|
||||
## [3.7.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.6.0...v3.7.0) (2026-02-07)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add admin traffic usage API ([aa1cd38](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/aa1cd3829c5c3671e220d49dd7ec2d83563e2cf9))
|
||||
* add admin traffic usage API with per-node statistics ([6c2c25d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6c2c25d2ccb27446c822e4ed94d9351bfeaf4549))
|
||||
* add node/status filters and custom date range to traffic page ([ad260d9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ad260d9fe0b232c9d65176502476212902909660))
|
||||
* add node/status filters, custom date range, connected devices to traffic page ([9ea533a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9ea533a864e345647754f316bd27971fba1420af))
|
||||
* add node/status filters, date range, devices to traffic page ([ad6522f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ad6522f547e68ef5965e70d395ca381b0a032093))
|
||||
* add risk columns to traffic CSV export ([7c1a142](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7c1a1426537e43d14eff0a1c3faeca484611b58b))
|
||||
* add tariff filter, fix traffic data aggregation ([fa01819](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fa01819674b2d2abb0d05b470559b09eb43abef8))
|
||||
* node/status filters + custom date range for traffic page ([a161e2f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a161e2f904732b459fef98a67abfaae1214ecfd4))
|
||||
* tariff filter + fix traffic data aggregation ([1021c2c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1021c2cdcd07cf2194e59af7b59491108339e61f))
|
||||
* traffic filters, date range & risk columns in CSV export ([4c40b5b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4c40b5b370616a9ab40cbf0cccdbc0ac4a3f8278))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* close unclosed HTML tags in version notification ([0b61c7f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0b61c7fe482e7bbfbb3421307a96d54addfd91ee))
|
||||
* close unclosed HTML tags when truncating version notification ([b674550](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b6745508da861af9b2ff05d89b4ac9a3933da510))
|
||||
* correct response parsing for non-legacy node-users endpoint ([a076dfb](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a076dfb5503a349450b5aa8aac3c6f40070b715d))
|
||||
* correct response parsing for non-legacy node-users endpoint ([91ac90c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/91ac90c2aecfb990679b3d0c835314dde448886a))
|
||||
* handle mixed types in traffic sort ([eeed2d6](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/eeed2d6369b07860505c59bcff391e7b17e0ffb7))
|
||||
* handle mixed types in traffic sort for string fields ([a194be0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a194be0843856b3376167d9ba8a8ef737280998c))
|
||||
* resolve 429 rate limiting on traffic page ([b12544d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b12544d3ea8f4bbd2d8c941f83ee3ac412157adb))
|
||||
* resolve 429 rate limiting on traffic page ([924d6bc](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/924d6bc09c815c1d188ea1d0e7974f7e803c1d3f))
|
||||
* use legacy per-node endpoint for traffic aggregation ([cc1c8ba](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/cc1c8bacb42a9089021b7ae0fecd1f2717953efb))
|
||||
* use legacy per-node endpoint with correct response format ([b707b79](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b707b7995b90c6465910a35e9a4403e1408c6568))
|
||||
* use PaymentService for cabinet YooKassa payments ([61bb8fc](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/61bb8fcafd94509568f134ccdba7769b66cc7d5d))
|
||||
* use PaymentService for cabinet YooKassa payments to save local DB record ([ff5bba3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ff5bba3fc5d1e1b08d008b64215e487a9eb70960))
|
||||
|
||||
## [3.6.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.5.0...v3.6.0) (2026-02-07)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add OAuth 2.0 authorization (Google, Yandex, Discord, VK) ([97be4af](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/97be4afbffd809fe2786a6d248fc4d3f770cb8cf))
|
||||
* add panel info, node usage endpoints and campaign to user detail ([287a43b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/287a43ba6527ff3464a527821d746a68e5371bbe))
|
||||
* add panel info, node usage endpoints and campaign to user detail ([0703212](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/070321230bcb868e4bc7a39c287ed3431a4aef4a))
|
||||
* add TRIAL_DISABLED_FOR setting to disable trial by user type ([c4794db](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c4794db1dd78f7c48b5da896bdb2f000e493e079))
|
||||
* add user_id filter to admin tickets endpoint ([8886d0d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8886d0dea20aa5a31c6b6f0c3391b3c012b4b34d))
|
||||
* add user_id filter to admin tickets endpoint ([d3819c4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d3819c492f88794e4466c2da986fd3a928d7f3df))
|
||||
* block registration with disposable email addresses ([9ca24ef](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9ca24efe434278925c0c1f8d2f2d644a67985c89))
|
||||
* block registration with disposable email addresses ([116c845](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/116c8453bb371b5eacf5c9d07f497eb449a355cc))
|
||||
* disable trial by user type (email/telegram/all) ([4e7438b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4e7438b9f9c01e30c48fcf2bbe191e9b11598185))
|
||||
* migrate OAuth state storage from in-memory to Redis ([e9b98b8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e9b98b837a8552360ef4c41f6cd7a5779aa8b0a7))
|
||||
* OAuth 2.0 authorization (Google, Yandex, Discord, VK) ([3cbb9ef](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3cbb9ef024695352959ef9a82bf8b81f0ba1d940))
|
||||
* return 30-day daily breakdown for node usage ([7102c50](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7102c50f52d583add863331e96f3a9de189f581a))
|
||||
* return 30-day daily breakdown for node usage ([e4c65ca](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e4c65ca220994cf08ed3510f51d9e2808bb2d154))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* increase OAuth HTTP timeout to 30s ([333a3c5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/333a3c590120a64f6b2963efab1edd861274840c))
|
||||
* parse bandwidth stats series format for node usage ([557dbf3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/557dbf3ebe777d2137e0e28303dc2a803b15c1c6))
|
||||
* parse bandwidth stats series format for node usage ([462f7a9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/462f7a99b9d5c0b7436dbc3d6ab5db6c6cfa3118))
|
||||
* pass tariff object instead of tariff_id to set_tariff_promo_groups ([1ffb8a5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1ffb8a5b85455396006e1fcddd48f4c9a2ca2700))
|
||||
* query per-node legacy endpoint for user traffic breakdown ([b94e3ed](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b94e3edf80e747077992c03882119c7559ad1c31))
|
||||
* query per-node legacy endpoint for user traffic breakdown ([51ca3e4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/51ca3e42b75c1870c76a1b25f667629855cfe886))
|
||||
* reduce node usage to 2 API calls to avoid 429 rate limit ([c68c4e5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c68c4e59846abba9c7c78ae91ec18e2e0e329e3c))
|
||||
* reduce node usage to 2 API calls to avoid 429 rate limit ([f00a051](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f00a051bb323e5ba94a3c38939870986726ed58e))
|
||||
* use accessible nodes API and fix date format for node usage ([943e9a8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/943e9a86aaa449cd3154b0919cfdc52d2a35b509))
|
||||
* use accessible nodes API and fix date format for node usage ([c4da591](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c4da59173155e2eeb69eca21416f816fcbd1fa9c))
|
||||
|
||||
## [3.5.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.4.0...v3.5.0) (2026-02-06)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add tariff reorder API endpoint ([4c2e11e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4c2e11e64bed41592f5a12061dcca74ce43e0806))
|
||||
* pass platform-level fields from RemnaWave config to frontend ([095bc00](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/095bc00b33d7082558a8b7252906db2850dce9da))
|
||||
* serve original RemnaWave config from app-config endpoint ([43762ce](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/43762ce8f4fa7142a1ca62a92b97a027dab2564d))
|
||||
* tariff reorder API endpoint ([085a617](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/085a61721a8175b3f4fd744614c446d73346f2b7))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* enforce blacklist via middleware ([561708b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/561708b7772ec5b84d6ee049aeba26dc70675583))
|
||||
* enforce blacklist via middleware instead of per-handler checks ([966a599](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/966a599c2c778dce9eea3c61adf6067fb33119f6))
|
||||
* exclude signature field from Telegram initData HMAC validation ([5b64046](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5b6404613772610c595e55bde1249cdf6ec3269d))
|
||||
* improve button URL resolution and pass uiConfig to frontend ([0ed98c3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0ed98c39b6c95911a38a26a32d0ffbcf9cfd7c80))
|
||||
* restore unquote for user data parsing in telegram auth ([c2cabbe](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c2cabbee097a41a95d16c34d43ab7e70d076c4dc))
|
||||
|
||||
|
||||
### Reverts
|
||||
|
||||
* remove signature pop from HMAC validation ([4234769](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4234769e92104a6c4f8f1d522e1fca25bc7b20d0))
|
||||
+1
-1
@@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
|
||||
|
||||
FROM python:3.13-slim
|
||||
|
||||
ARG VERSION="v3.2.0"
|
||||
ARG VERSION="v3.16.3" # x-release-please-version
|
||||
ARG BUILD_DATE
|
||||
ARG VCS_REF
|
||||
|
||||
|
||||
@@ -40,6 +40,22 @@ fix: ## Исправить код (ruff check --fix + format)
|
||||
uv run ruff check . --fix
|
||||
uv run ruff format .
|
||||
|
||||
.PHONY: migrate
|
||||
migrate: ## Применить миграции (alembic upgrade head)
|
||||
uv run alembic upgrade head
|
||||
|
||||
.PHONY: migration
|
||||
migration: ## Создать миграцию (usage: make migration m="description")
|
||||
uv run alembic revision --autogenerate -m "$(m)"
|
||||
|
||||
.PHONY: migrate-stamp
|
||||
migrate-stamp: ## Пометить БД как актуальную (для существующих БД)
|
||||
uv run alembic stamp head
|
||||
|
||||
.PHONY: migrate-history
|
||||
migrate-history: ## Показать историю миграций
|
||||
uv run alembic history --verbose
|
||||
|
||||
.PHONY: help
|
||||
help: ## Показать список доступных команд
|
||||
@echo ""
|
||||
|
||||
@@ -37,7 +37,7 @@ _Полнофункциональное решение с управлением
|
||||
|
||||
---
|
||||
|
||||
## 🧪 [Тестирование бота](https://t.me/FringVPN_bot)
|
||||
## 🧪 [Тестирование бота](https://t.me/zero_ping_vpn_bot?start=Git)
|
||||
|
||||
## 💬 **[Bedolaga Chat](https://t.me/+wTdMtSWq8YdmZmVi)** - Для общения, вопросов, предложений
|
||||
|
||||
@@ -160,7 +160,6 @@ docker compose logs
|
||||
| -------------- | --------------------------------------------------------------------------- | ------------------------------------------------ |
|
||||
| `polling` | Бот опрашивает Telegram через long polling. HTTP-сервер можно не поднимать. | Локальная отладка или отсутствие внешнего HTTPS. |
|
||||
| `webhook` | Aiogram получает апдейты только через вебхук. | Продакшн и серверы за HTTPS-прокси. |
|
||||
| `both` | Одновременно работают polling и webhook. | Тестирование или повышенная отказоустойчивость. |
|
||||
|
||||
### 2. Минимальные настройки для webhook
|
||||
|
||||
@@ -611,6 +610,16 @@ hooks.domain.com {
|
||||
}
|
||||
}
|
||||
|
||||
handle /remnawave-webhook {
|
||||
reverse_proxy remnawave_bot:8080 {
|
||||
header_up Host {host}
|
||||
header_up X-Real-IP {remote_host}
|
||||
transport http {
|
||||
read_buffer 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# app-config.json с CORS
|
||||
handle /app-config.json {
|
||||
header Access-Control-Allow-Origin "*"
|
||||
@@ -819,6 +828,18 @@ http {
|
||||
proxy_request_buffering off;
|
||||
}
|
||||
|
||||
location = /remnawave-webhook {
|
||||
proxy_pass http://remnawave_bot_unified;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 120s;
|
||||
proxy_send_timeout 120s;
|
||||
proxy_buffering off;
|
||||
proxy_request_buffering off;
|
||||
}
|
||||
|
||||
# app-config.json с CORS
|
||||
location = /app-config.json {
|
||||
add_header Access-Control-Allow-Origin "*";
|
||||
@@ -1012,7 +1033,7 @@ curl -I https://miniapp.domain.com
|
||||
| ---------------- | ----------------------------------------------------------------------------------------------------------------- | ------------------------ |
|
||||
| 🤖 **BOT_TOKEN** | [@BotFather](https://t.me/BotFather) | `1234567890:AABBCCdd...` |
|
||||
| 👑 **ADMIN_IDS** | Твой Telegram ID | `123456789,987654321` |
|
||||
| **BOT_RUN_MODE** | определяет способ приёма обновлений: `polling`, `webhook` или `both`, чтобы одновременно использовать оба режима. |
|
||||
| **BOT_RUN_MODE** | определяет способ приёма обновлений: `polling` или `webhook`. |
|
||||
|
||||
[Полный список доступных параметров:](.env.example)
|
||||
|
||||
@@ -1022,7 +1043,7 @@ curl -I https://miniapp.domain.com
|
||||
|
||||
### 🤖 Режимы запуска бота
|
||||
|
||||
- `BOT_RUN_MODE` — определяет способ приёма обновлений: `polling`, `webhook` или `both`, чтобы одновременно использовать оба режима.
|
||||
- `BOT_RUN_MODE` — определяет способ приёма обновлений: `polling` или `webhook`.
|
||||
- `WEBHOOK_SECRET_TOKEN` — секрет для проверки заголовка `X-Telegram-Bot-Api-Secret-Token` при работе через вебхуки.
|
||||
- `WEBHOOK_DROP_PENDING_UPDATES` — управляет очисткой очереди сообщений при установке вебхука.
|
||||
- `WEBHOOK_MAX_QUEUE_SIZE` — ограничивает длину очереди входящих обновлений, чтобы защащаться от перегрузок.
|
||||
@@ -1056,6 +1077,102 @@ REMNAWAVE_SECRET_KEY=XXXXXXX:DDDDDDDD
|
||||
REMNAWAVE_SECRET_KEY=secret_key_name
|
||||
```
|
||||
|
||||
### 📡 Вебхуки Remnawave (real-time события)
|
||||
|
||||
Бот может принимать входящие вебхуки от панели Remnawave для мгновенной реакции на события подписок. Это значительно улучшает скорость обновления данных по сравнению с периодической синхронизацией.
|
||||
|
||||
#### Поддерживаемые события
|
||||
|
||||
| Событие | Описание |
|
||||
|---------|----------|
|
||||
| `user.expired` | Подписка истекла |
|
||||
| `user.disabled` | Подписка деактивирована |
|
||||
| `user.enabled` | Подписка активирована |
|
||||
| `user.limited` | Превышен лимит трафика |
|
||||
| `user.traffic_reset` | Трафик сброшен |
|
||||
| `user.modified` | Данные подписки изменены (трафик, дата, URL) |
|
||||
| `user.deleted` | Пользователь удалён |
|
||||
| `user.revoked` | Ключи подписки отозваны |
|
||||
| `user.created` | Пользователь создан |
|
||||
| `user.expires_in_*` | Предупреждения об истечении (72ч, 48ч, 24ч) |
|
||||
| `user.first_connected` | Первое подключение |
|
||||
| `user.bandwidth_usage_threshold_reached` | Порог трафика достигнут |
|
||||
| `user_hwid_devices.*` | Устройство добавлено/удалено |
|
||||
| `node.*`, `service.*` | Административные события (ноды, сервис) |
|
||||
|
||||
#### Настройка
|
||||
|
||||
**1. Переменные окружения в `.env`:**
|
||||
|
||||
```env
|
||||
REMNAWAVE_WEBHOOK_ENABLED=true
|
||||
REMNAWAVE_WEBHOOK_PATH=/remnawave-webhook
|
||||
REMNAWAVE_WEBHOOK_SECRET=your_secret_min_32_chars_here
|
||||
```
|
||||
|
||||
Сгенерируйте секрет:
|
||||
|
||||
```bash
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
**2. Настройка в панели Remnawave:**
|
||||
|
||||
В env панели Remnawave заполните:
|
||||
|
||||
- **URL**: `https://hooks.domain.com/remnawave-webhook`
|
||||
- **Secret**: тот же секрет, что и в `REMNAWAVE_WEBHOOK_SECRET`
|
||||
|
||||
**3. Настройка прокси:**
|
||||
|
||||
Добавьте путь `/remnawave-webhook` в конфигурацию обратного прокси.
|
||||
|
||||
**Caddy:**
|
||||
|
||||
```caddy
|
||||
handle /remnawave-webhook {
|
||||
reverse_proxy remnawave_bot:8080 {
|
||||
header_up Host {host}
|
||||
header_up X-Real-IP {remote_host}
|
||||
transport http {
|
||||
read_buffer 0
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Nginx:**
|
||||
|
||||
```nginx
|
||||
location = /remnawave-webhook {
|
||||
proxy_pass http://remnawave_bot_unified;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 120s;
|
||||
proxy_send_timeout 120s;
|
||||
proxy_buffering off;
|
||||
proxy_request_buffering off;
|
||||
}
|
||||
```
|
||||
|
||||
**4. Проверка работоспособности:**
|
||||
|
||||
```bash
|
||||
# Health-check (GET запрос)
|
||||
curl -s https://hooks.domain.com/remnawave-webhook | jq
|
||||
|
||||
# Ожидаемый ответ:
|
||||
# {"status": "ok", "service": "remnawave_webhook", "enabled": true}
|
||||
```
|
||||
|
||||
**Важно:**
|
||||
- Секрет должен быть не менее 32 символов
|
||||
- Бот верифицирует подпись `X-Remnawave-Signature` (HMAC-SHA256) для каждого запроса
|
||||
- При включённых вебхуках бот автоматически защищает подписки от перезаписи данными из периодической синхронизации в течение 60 секунд после получения события
|
||||
- Если бот и панель на одном сервере, URL вебхука может быть `http://remnawave_bot:8080/remnawave-webhook` (внутри Docker-сети)
|
||||
|
||||
### 💳 Freekassa
|
||||
|
||||
Платёжный провайдер [Freekassa](https://freekassa.ru) поддерживает NSPK СБП и банковские карты.
|
||||
@@ -1343,7 +1460,6 @@ CONTEST_BUTTON_VISIBLE=true
|
||||
- 🔄 Автоплатёж с настройкой дня списания
|
||||
- 🎁 Реферальные и промо-бонусы
|
||||
- ⚡ **Быстрое пополнение** с кнопками быстрых сумм
|
||||
- 🔄 **Умная автоактивация** подписки после пополнения баланса
|
||||
|
||||
📱 **Управление подписками**
|
||||
|
||||
@@ -1530,7 +1646,6 @@ CONTEST_BUTTON_VISIBLE=true
|
||||
- 🔄 **Миграция сквадов** - массовый перенос пользователей между сквадами
|
||||
- 🧾 **История операций** - хранение всех транзакций и действий для аудита
|
||||
- 💸 **Сервис автопроверки транзакций** - автоматическая проверка транзакций в статусе "В ожидании оплаты" за последние 24ч
|
||||
- 🔄 **Умная автоактивация** - автоматическая активация подписки после пополнения баланса
|
||||
- 📝 **Ротация логов** - автоматическая очистка и архивация старых логов
|
||||
- 🎮 **Система конкурсов** - ежедневные игры и реферальные конкурсы с призами
|
||||
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
script_location = migrations/alembic
|
||||
prepend_sys_path = .
|
||||
version_path_separator = os
|
||||
sqlalchemy.url = postgresql+asyncpg://vpn_user:your_password@localhost:5432/vpn_bot
|
||||
sqlalchemy.url = driver://user:pass@localhost/dbname
|
||||
|
||||
[post_write_hooks]
|
||||
|
||||
|
||||
+58
-16
@@ -1,6 +1,5 @@
|
||||
import logging
|
||||
|
||||
import redis.asyncio as redis
|
||||
import structlog
|
||||
from aiogram import Bot, Dispatcher, types
|
||||
from aiogram.fsm.storage.memory import MemoryStorage
|
||||
from aiogram.fsm.storage.redis import RedisStorage
|
||||
@@ -24,6 +23,7 @@ from app.handlers import (
|
||||
from app.handlers.admin import (
|
||||
backup as admin_backup,
|
||||
blacklist as admin_blacklist,
|
||||
blocked_users as admin_blocked_users,
|
||||
bot_configuration as admin_bot_configuration,
|
||||
bulk_ban as admin_bulk_ban,
|
||||
campaigns as admin_campaigns,
|
||||
@@ -60,8 +60,9 @@ from app.handlers.admin import (
|
||||
)
|
||||
from app.handlers.stars_payments import register_stars_handlers
|
||||
from app.middlewares.auth import AuthMiddleware
|
||||
from app.middlewares.blacklist import BlacklistMiddleware
|
||||
from app.middlewares.button_stats import ButtonStatsMiddleware
|
||||
from app.middlewares.display_name_restriction import DisplayNameRestrictionMiddleware
|
||||
from app.middlewares.context_binding import ContextVarsMiddleware
|
||||
from app.middlewares.global_error import GlobalErrorMiddleware
|
||||
from app.middlewares.logging import LoggingMiddleware
|
||||
from app.middlewares.maintenance import MaintenanceMiddleware
|
||||
@@ -74,14 +75,14 @@ from app.utils.message_patch import patch_message_methods
|
||||
|
||||
patch_message_methods()
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def debug_callback_handler(callback: types.CallbackQuery):
|
||||
logger.info('🔍 DEBUG CALLBACK:')
|
||||
logger.info(f' - Data: {callback.data}')
|
||||
logger.info(f' - User: {callback.from_user.id}')
|
||||
logger.info(f' - Username: {callback.from_user.username}')
|
||||
logger.info('Data', callback_data=callback.data)
|
||||
logger.info('User', from_user_id=callback.from_user.id)
|
||||
logger.info('Username', username=callback.from_user.username)
|
||||
|
||||
|
||||
async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
@@ -89,7 +90,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
await cache.connect()
|
||||
logger.info('Кеш инициализирован')
|
||||
except Exception as e:
|
||||
logger.warning(f'Кеш не инициализирован: {e}')
|
||||
logger.warning('Кеш не инициализирован', error=e)
|
||||
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
@@ -105,12 +106,15 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
storage = RedisStorage(redis_client)
|
||||
logger.info('Подключено к Redis для FSM storage')
|
||||
except Exception as e:
|
||||
logger.warning(f'Не удалось подключиться к Redis: {e}')
|
||||
logger.warning('Не удалось подключиться к Redis', error=e)
|
||||
logger.info('Используется MemoryStorage для FSM')
|
||||
storage = MemoryStorage()
|
||||
|
||||
dp = Dispatcher(storage=storage)
|
||||
|
||||
dp.message.middleware(ContextVarsMiddleware())
|
||||
dp.callback_query.middleware(ContextVarsMiddleware())
|
||||
dp.pre_checkout_query.middleware(ContextVarsMiddleware())
|
||||
dp.message.middleware(GlobalErrorMiddleware())
|
||||
dp.callback_query.middleware(GlobalErrorMiddleware())
|
||||
dp.pre_checkout_query.middleware(GlobalErrorMiddleware())
|
||||
@@ -118,10 +122,10 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
dp.callback_query.middleware(LoggingMiddleware())
|
||||
dp.message.middleware(MaintenanceMiddleware())
|
||||
dp.callback_query.middleware(MaintenanceMiddleware())
|
||||
display_name_middleware = DisplayNameRestrictionMiddleware()
|
||||
dp.message.middleware(display_name_middleware)
|
||||
dp.callback_query.middleware(display_name_middleware)
|
||||
dp.pre_checkout_query.middleware(display_name_middleware)
|
||||
blacklist_middleware = BlacklistMiddleware()
|
||||
dp.message.middleware(blacklist_middleware)
|
||||
dp.callback_query.middleware(blacklist_middleware)
|
||||
dp.pre_checkout_query.middleware(blacklist_middleware)
|
||||
dp.message.middleware(ThrottlingMiddleware())
|
||||
dp.callback_query.middleware(ThrottlingMiddleware())
|
||||
|
||||
@@ -189,6 +193,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
admin_tariffs.register_handlers(dp)
|
||||
admin_bulk_ban.register_bulk_ban_handlers(dp)
|
||||
admin_blacklist.register_blacklist_handlers(dp)
|
||||
admin_blocked_users.register_handlers(dp)
|
||||
common.register_handlers(dp)
|
||||
register_stars_handlers(dp)
|
||||
user_contests.register_handlers(dp)
|
||||
@@ -203,11 +208,48 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
await maintenance_service.start_monitoring()
|
||||
logger.info('Мониторинг техработ запущен')
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка запуска мониторинга техработ: {e}')
|
||||
logger.error('Ошибка запуска мониторинга техработ', error=e)
|
||||
else:
|
||||
logger.info('Мониторинг техработ отключен настройками')
|
||||
|
||||
logger.info('🛡️ GlobalErrorMiddleware активирован - бот защищен от устаревших callback queries')
|
||||
|
||||
# Validate CONNECT_BUTTON_MODE dependencies
|
||||
if not settings.get_happ_cryptolink_redirect_template():
|
||||
if settings.CONNECT_BUTTON_MODE == 'happ_cryptolink':
|
||||
logger.warning(
|
||||
'⚠️ CONNECT_BUTTON_MODE=happ_cryptolink, но HAPP_CRYPTOLINK_REDIRECT_TEMPLATE не задан! '
|
||||
'Кнопка "Подключиться" не будет отображаться.'
|
||||
)
|
||||
elif settings.CONNECT_BUTTON_MODE == 'guide':
|
||||
logger.warning(
|
||||
'⚠️ CONNECT_BUTTON_MODE=guide, но HAPP_CRYPTOLINK_REDIRECT_TEMPLATE не задан! '
|
||||
'Кнопка "Подключиться" в гайдах не будет работать — Telegram не поддерживает '
|
||||
'кастомные схемы (happ://, v2ray://) в inline-кнопках без HTTPS-редиректа.'
|
||||
)
|
||||
if settings.CONNECT_BUTTON_MODE == 'miniapp_custom' and not settings.MINIAPP_CUSTOM_URL:
|
||||
logger.warning(
|
||||
'⚠️ CONNECT_BUTTON_MODE=miniapp_custom, но MINIAPP_CUSTOM_URL не задан! '
|
||||
'Кнопка "Подключиться" не будет работать.'
|
||||
)
|
||||
if settings.is_cabinet_mode() and not settings.MINIAPP_CUSTOM_URL:
|
||||
logger.warning(
|
||||
'⚠️ MAIN_MENU_MODE=cabinet, но MINIAPP_CUSTOM_URL не задан! '
|
||||
'Кнопки кабинета не смогут открывать разделы MiniApp. '
|
||||
'Установите MINIAPP_CUSTOM_URL.'
|
||||
)
|
||||
elif settings.is_cabinet_mode():
|
||||
logger.info('🏠 Режим Cabinet активен, базовый URL', MINIAPP_CUSTOM_URL=settings.MINIAPP_CUSTOM_URL)
|
||||
|
||||
# Load per-section button styles cache
|
||||
if settings.is_cabinet_mode():
|
||||
try:
|
||||
from app.utils.button_styles_cache import load_button_styles_cache
|
||||
|
||||
await load_button_styles_cache()
|
||||
except Exception as e:
|
||||
logger.warning('Failed to load button styles cache', error=e)
|
||||
|
||||
logger.info('Бот успешно настроен')
|
||||
|
||||
return bot, dp
|
||||
@@ -218,10 +260,10 @@ async def shutdown_bot():
|
||||
await maintenance_service.stop_monitoring()
|
||||
logger.info('Мониторинг техработ остановлен')
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка остановки мониторинга: {e}')
|
||||
logger.error('Ошибка остановки мониторинга', error=e)
|
||||
|
||||
try:
|
||||
await cache.close()
|
||||
logger.info('Соединения с кешем закрыты')
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка закрытия кеша: {e}')
|
||||
logger.error('Ошибка закрытия кеша', error=e)
|
||||
|
||||
@@ -1,11 +1,32 @@
|
||||
"""Email verification token generation and validation."""
|
||||
|
||||
import secrets
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
def generate_email_change_code() -> str:
|
||||
"""
|
||||
Generate a 6-digit verification code for email change.
|
||||
|
||||
Returns:
|
||||
6-digit numeric string
|
||||
"""
|
||||
return str(secrets.randbelow(900000) + 100000)
|
||||
|
||||
|
||||
def get_email_change_expires_at() -> datetime:
|
||||
"""
|
||||
Get the expiration datetime for an email change code.
|
||||
|
||||
Returns:
|
||||
Datetime when the email change code expires
|
||||
"""
|
||||
minutes = settings.get_cabinet_email_change_code_expire_minutes()
|
||||
return datetime.now(UTC) + timedelta(minutes=minutes)
|
||||
|
||||
|
||||
def generate_verification_token() -> str:
|
||||
"""
|
||||
Generate a secure random verification token.
|
||||
@@ -34,7 +55,7 @@ def get_verification_expires_at() -> datetime:
|
||||
Datetime when the verification token expires
|
||||
"""
|
||||
hours = settings.get_cabinet_email_verification_expire_hours()
|
||||
return datetime.utcnow() + timedelta(hours=hours)
|
||||
return datetime.now(UTC) + timedelta(hours=hours)
|
||||
|
||||
|
||||
def get_password_reset_expires_at() -> datetime:
|
||||
@@ -45,7 +66,7 @@ def get_password_reset_expires_at() -> datetime:
|
||||
Datetime when the password reset token expires
|
||||
"""
|
||||
hours = settings.get_cabinet_password_reset_expire_hours()
|
||||
return datetime.utcnow() + timedelta(hours=hours)
|
||||
return datetime.now(UTC) + timedelta(hours=hours)
|
||||
|
||||
|
||||
def is_token_expired(expires_at: datetime | None) -> bool:
|
||||
@@ -60,4 +81,4 @@ def is_token_expired(expires_at: datetime | None) -> bool:
|
||||
"""
|
||||
if expires_at is None:
|
||||
return True
|
||||
return datetime.utcnow() > expires_at
|
||||
return datetime.now(UTC) > expires_at
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""JWT token handling for cabinet authentication."""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import jwt
|
||||
@@ -23,13 +23,13 @@ def create_access_token(user_id: int, telegram_id: int | None = None) -> str:
|
||||
Encoded JWT access token
|
||||
"""
|
||||
expire_minutes = settings.get_cabinet_access_token_expire_minutes()
|
||||
expires = datetime.utcnow() + timedelta(minutes=expire_minutes)
|
||||
expires = datetime.now(UTC) + timedelta(minutes=expire_minutes)
|
||||
|
||||
payload = {
|
||||
'sub': str(user_id),
|
||||
'type': 'access',
|
||||
'exp': expires,
|
||||
'iat': datetime.utcnow(),
|
||||
'iat': datetime.now(UTC),
|
||||
}
|
||||
|
||||
# Добавляем telegram_id только если он есть
|
||||
@@ -51,13 +51,13 @@ def create_refresh_token(user_id: int) -> str:
|
||||
Encoded JWT refresh token
|
||||
"""
|
||||
expire_days = settings.get_cabinet_refresh_token_expire_days()
|
||||
expires = datetime.utcnow() + timedelta(days=expire_days)
|
||||
expires = datetime.now(UTC) + timedelta(days=expire_days)
|
||||
|
||||
payload = {
|
||||
'sub': str(user_id),
|
||||
'type': 'refresh',
|
||||
'exp': expires,
|
||||
'iat': datetime.utcnow(),
|
||||
'iat': datetime.now(UTC),
|
||||
}
|
||||
|
||||
secret = settings.get_cabinet_jwt_secret()
|
||||
@@ -108,4 +108,4 @@ def get_token_payload(token: str, expected_type: str = 'access') -> dict[str, An
|
||||
def get_refresh_token_expires_at() -> datetime:
|
||||
"""Get the expiration datetime for a new refresh token."""
|
||||
expire_days = settings.get_cabinet_refresh_token_expire_days()
|
||||
return datetime.utcnow() + timedelta(days=expire_days)
|
||||
return datetime.now(UTC) + timedelta(days=expire_days)
|
||||
|
||||
@@ -0,0 +1,429 @@
|
||||
"""OAuth 2.0 provider implementations for cabinet authentication."""
|
||||
|
||||
import secrets
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any, TypedDict
|
||||
|
||||
import httpx
|
||||
import structlog
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.config import settings
|
||||
from app.utils.cache import cache, cache_key
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
STATE_TTL_SECONDS = 600 # 10 minutes
|
||||
|
||||
|
||||
# --- Typed dicts for provider API responses ---
|
||||
|
||||
|
||||
class OAuthProviderConfig(TypedDict):
|
||||
client_id: str
|
||||
client_secret: str
|
||||
enabled: bool
|
||||
display_name: str
|
||||
|
||||
|
||||
class OAuthTokenResponse(TypedDict, total=False):
|
||||
access_token: str
|
||||
token_type: str
|
||||
expires_in: int
|
||||
refresh_token: str
|
||||
scope: str
|
||||
# VK-specific: email and user_id come in token response
|
||||
email: str
|
||||
user_id: int
|
||||
|
||||
|
||||
class GoogleUserInfoResponse(TypedDict, total=False):
|
||||
sub: str
|
||||
email: str
|
||||
email_verified: bool
|
||||
given_name: str
|
||||
family_name: str
|
||||
picture: str
|
||||
name: str
|
||||
|
||||
|
||||
class YandexUserInfoResponse(TypedDict, total=False):
|
||||
id: str
|
||||
login: str
|
||||
default_email: str
|
||||
emails: list[str]
|
||||
first_name: str
|
||||
last_name: str
|
||||
default_avatar_id: str
|
||||
|
||||
|
||||
class DiscordUserInfoResponse(TypedDict, total=False):
|
||||
id: str
|
||||
username: str
|
||||
global_name: str
|
||||
email: str
|
||||
verified: bool
|
||||
avatar: str
|
||||
|
||||
|
||||
class VKUserInfoItem(TypedDict, total=False):
|
||||
id: int
|
||||
first_name: str
|
||||
last_name: str
|
||||
photo_200: str
|
||||
|
||||
|
||||
class VKUserInfoResponse(TypedDict, total=False):
|
||||
response: list[VKUserInfoItem]
|
||||
|
||||
|
||||
# --- Models ---
|
||||
|
||||
|
||||
class OAuthUserInfo(BaseModel):
|
||||
"""Normalized user info from OAuth provider."""
|
||||
|
||||
provider: str
|
||||
provider_id: str
|
||||
email: str | None = None
|
||||
email_verified: bool = False
|
||||
first_name: str | None = None
|
||||
last_name: str | None = None
|
||||
username: str | None = None
|
||||
avatar_url: str | None = None
|
||||
|
||||
|
||||
# --- CSRF state management (Redis) ---
|
||||
|
||||
|
||||
async def generate_oauth_state(provider: str) -> str:
|
||||
"""Generate a CSRF state token for OAuth flow. Stored in Redis with TTL."""
|
||||
state = secrets.token_urlsafe(32)
|
||||
await cache.set(cache_key('oauth_state', state), provider, expire=STATE_TTL_SECONDS)
|
||||
return state
|
||||
|
||||
|
||||
async def validate_oauth_state(state: str, provider: str) -> bool:
|
||||
"""Validate and consume a CSRF state token from Redis."""
|
||||
key = cache_key('oauth_state', state)
|
||||
stored_provider: str | None = await cache.get(key)
|
||||
if stored_provider is None:
|
||||
return False
|
||||
await cache.delete(key)
|
||||
if stored_provider != provider:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
# --- Provider implementations ---
|
||||
|
||||
|
||||
class OAuthProvider(ABC):
|
||||
"""Base class for OAuth 2.0 providers."""
|
||||
|
||||
name: str
|
||||
display_name: str
|
||||
|
||||
def __init__(self, client_id: str, client_secret: str, redirect_uri: str) -> None:
|
||||
self.client_id = client_id
|
||||
self.client_secret = client_secret
|
||||
self.redirect_uri = redirect_uri
|
||||
|
||||
@abstractmethod
|
||||
def get_authorization_url(self, state: str) -> str:
|
||||
"""Build the authorization URL for the provider."""
|
||||
|
||||
@abstractmethod
|
||||
async def exchange_code(self, code: str) -> OAuthTokenResponse:
|
||||
"""Exchange authorization code for tokens."""
|
||||
|
||||
@abstractmethod
|
||||
async def get_user_info(self, token_data: OAuthTokenResponse) -> OAuthUserInfo:
|
||||
"""Fetch user info from the provider."""
|
||||
|
||||
|
||||
class GoogleProvider(OAuthProvider):
|
||||
name = 'google'
|
||||
display_name = 'Google'
|
||||
|
||||
AUTHORIZE_URL = 'https://accounts.google.com/o/oauth2/v2/auth'
|
||||
TOKEN_URL = 'https://oauth2.googleapis.com/token'
|
||||
USERINFO_URL = 'https://www.googleapis.com/oauth2/v3/userinfo'
|
||||
|
||||
def get_authorization_url(self, state: str) -> str:
|
||||
params: dict[str, str] = {
|
||||
'client_id': self.client_id,
|
||||
'redirect_uri': self.redirect_uri,
|
||||
'response_type': 'code',
|
||||
'scope': 'openid email profile',
|
||||
'state': state,
|
||||
'access_type': 'offline',
|
||||
'prompt': 'select_account',
|
||||
}
|
||||
request = httpx.Request('GET', self.AUTHORIZE_URL, params=params)
|
||||
return str(request.url)
|
||||
|
||||
async def exchange_code(self, code: str) -> OAuthTokenResponse:
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
response = await client.post(
|
||||
self.TOKEN_URL,
|
||||
json={
|
||||
'client_id': self.client_id,
|
||||
'client_secret': self.client_secret,
|
||||
'code': code,
|
||||
'grant_type': 'authorization_code',
|
||||
'redirect_uri': self.redirect_uri,
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data: OAuthTokenResponse = response.json()
|
||||
return data
|
||||
|
||||
async def get_user_info(self, token_data: OAuthTokenResponse) -> OAuthUserInfo:
|
||||
access_token = token_data['access_token']
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
response = await client.get(
|
||||
self.USERINFO_URL,
|
||||
headers={'Authorization': f'Bearer {access_token}'},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data: GoogleUserInfoResponse = response.json()
|
||||
|
||||
return OAuthUserInfo(
|
||||
provider='google',
|
||||
provider_id=str(data['sub']),
|
||||
email=data.get('email'),
|
||||
email_verified=data.get('email_verified', False),
|
||||
first_name=data.get('given_name'),
|
||||
last_name=data.get('family_name'),
|
||||
avatar_url=data.get('picture'),
|
||||
)
|
||||
|
||||
|
||||
class YandexProvider(OAuthProvider):
|
||||
name = 'yandex'
|
||||
display_name = 'Yandex'
|
||||
|
||||
AUTHORIZE_URL = 'https://oauth.yandex.com/authorize'
|
||||
TOKEN_URL = 'https://oauth.yandex.com/token'
|
||||
USERINFO_URL = 'https://login.yandex.ru/info'
|
||||
|
||||
def get_authorization_url(self, state: str) -> str:
|
||||
params: dict[str, str] = {
|
||||
'client_id': self.client_id,
|
||||
'redirect_uri': self.redirect_uri,
|
||||
'response_type': 'code',
|
||||
'scope': 'login:info login:email',
|
||||
'state': state,
|
||||
'force_confirm': 'yes',
|
||||
}
|
||||
request = httpx.Request('GET', self.AUTHORIZE_URL, params=params)
|
||||
return str(request.url)
|
||||
|
||||
async def exchange_code(self, code: str) -> OAuthTokenResponse:
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
response = await client.post(
|
||||
self.TOKEN_URL,
|
||||
data={
|
||||
'client_id': self.client_id,
|
||||
'client_secret': self.client_secret,
|
||||
'code': code,
|
||||
'grant_type': 'authorization_code',
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data: OAuthTokenResponse = response.json()
|
||||
return data
|
||||
|
||||
async def get_user_info(self, token_data: OAuthTokenResponse) -> OAuthUserInfo:
|
||||
access_token = token_data['access_token']
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
response = await client.get(
|
||||
self.USERINFO_URL,
|
||||
params={'format': 'json'},
|
||||
headers={'Authorization': f'OAuth {access_token}'},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data: YandexUserInfoResponse = response.json()
|
||||
|
||||
default_email = data.get('default_email')
|
||||
emails = data.get('emails', [])
|
||||
email = default_email or (emails[0] if emails else None)
|
||||
|
||||
return OAuthUserInfo(
|
||||
provider='yandex',
|
||||
provider_id=str(data['id']),
|
||||
email=email,
|
||||
email_verified=bool(email),
|
||||
first_name=data.get('first_name'),
|
||||
last_name=data.get('last_name'),
|
||||
username=data.get('login'),
|
||||
avatar_url=(
|
||||
f'https://avatars.yandex.net/get-yapic/{data["default_avatar_id"]}/islands-200'
|
||||
if data.get('default_avatar_id')
|
||||
else None
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class DiscordProvider(OAuthProvider):
|
||||
name = 'discord'
|
||||
display_name = 'Discord'
|
||||
|
||||
AUTHORIZE_URL = 'https://discord.com/api/oauth2/authorize'
|
||||
TOKEN_URL = 'https://discord.com/api/oauth2/token'
|
||||
USERINFO_URL = 'https://discord.com/api/v10/users/@me'
|
||||
|
||||
def get_authorization_url(self, state: str) -> str:
|
||||
params: dict[str, str] = {
|
||||
'client_id': self.client_id,
|
||||
'redirect_uri': self.redirect_uri,
|
||||
'response_type': 'code',
|
||||
'scope': 'identify email',
|
||||
'state': state,
|
||||
'prompt': 'consent',
|
||||
}
|
||||
request = httpx.Request('GET', self.AUTHORIZE_URL, params=params)
|
||||
return str(request.url)
|
||||
|
||||
async def exchange_code(self, code: str) -> OAuthTokenResponse:
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
response = await client.post(
|
||||
self.TOKEN_URL,
|
||||
data={
|
||||
'client_id': self.client_id,
|
||||
'client_secret': self.client_secret,
|
||||
'code': code,
|
||||
'grant_type': 'authorization_code',
|
||||
'redirect_uri': self.redirect_uri,
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data: OAuthTokenResponse = response.json()
|
||||
return data
|
||||
|
||||
async def get_user_info(self, token_data: OAuthTokenResponse) -> OAuthUserInfo:
|
||||
access_token = token_data['access_token']
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
response = await client.get(
|
||||
self.USERINFO_URL,
|
||||
headers={'Authorization': f'Bearer {access_token}'},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data: DiscordUserInfoResponse = response.json()
|
||||
|
||||
avatar_url: str | None = None
|
||||
if data.get('avatar'):
|
||||
avatar_url = f'https://cdn.discordapp.com/avatars/{data["id"]}/{data["avatar"]}.png'
|
||||
|
||||
return OAuthUserInfo(
|
||||
provider='discord',
|
||||
provider_id=str(data['id']),
|
||||
email=data.get('email'),
|
||||
email_verified=data.get('verified', False),
|
||||
first_name=data.get('global_name') or data.get('username'),
|
||||
username=data.get('username'),
|
||||
avatar_url=avatar_url,
|
||||
)
|
||||
|
||||
|
||||
class VKProvider(OAuthProvider):
|
||||
name = 'vk'
|
||||
display_name = 'VK'
|
||||
|
||||
AUTHORIZE_URL = 'https://oauth.vk.com/authorize'
|
||||
TOKEN_URL = 'https://oauth.vk.com/access_token'
|
||||
USERINFO_URL = 'https://api.vk.com/method/users.get'
|
||||
API_VERSION = '5.131'
|
||||
|
||||
def get_authorization_url(self, state: str) -> str:
|
||||
params: dict[str, str] = {
|
||||
'client_id': self.client_id,
|
||||
'redirect_uri': self.redirect_uri,
|
||||
'response_type': 'code',
|
||||
'scope': 'email',
|
||||
'state': state,
|
||||
'v': self.API_VERSION,
|
||||
}
|
||||
request = httpx.Request('GET', self.AUTHORIZE_URL, params=params)
|
||||
return str(request.url)
|
||||
|
||||
async def exchange_code(self, code: str) -> OAuthTokenResponse:
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
response = await client.get(
|
||||
self.TOKEN_URL,
|
||||
params={
|
||||
'client_id': self.client_id,
|
||||
'client_secret': self.client_secret,
|
||||
'code': code,
|
||||
'redirect_uri': self.redirect_uri,
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data: OAuthTokenResponse = response.json()
|
||||
return data
|
||||
|
||||
async def get_user_info(self, token_data: OAuthTokenResponse) -> OAuthUserInfo:
|
||||
access_token = token_data['access_token']
|
||||
user_id: int | None = token_data.get('user_id')
|
||||
# VK returns email in token response, not in userinfo
|
||||
email: str | None = token_data.get('email')
|
||||
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
response = await client.get(
|
||||
self.USERINFO_URL,
|
||||
params={
|
||||
'access_token': access_token,
|
||||
'fields': 'photo_200',
|
||||
'v': self.API_VERSION,
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data: VKUserInfoResponse = response.json()
|
||||
|
||||
users: list[Any] = data.get('response', [])
|
||||
user_data: VKUserInfoItem = users[0] if users else {} # type: ignore[assignment]
|
||||
|
||||
return OAuthUserInfo(
|
||||
provider='vk',
|
||||
provider_id=str(user_id or user_data.get('id', '')),
|
||||
email=email,
|
||||
email_verified=bool(email),
|
||||
first_name=user_data.get('first_name'),
|
||||
last_name=user_data.get('last_name'),
|
||||
avatar_url=user_data.get('photo_200'),
|
||||
)
|
||||
|
||||
|
||||
# --- Provider factory ---
|
||||
|
||||
_PROVIDERS: dict[str, type[OAuthProvider]] = {
|
||||
'google': GoogleProvider,
|
||||
'yandex': YandexProvider,
|
||||
'discord': DiscordProvider,
|
||||
'vk': VKProvider,
|
||||
}
|
||||
|
||||
|
||||
def get_provider(name: str) -> OAuthProvider | None:
|
||||
"""Get an OAuth provider instance if enabled.
|
||||
|
||||
Returns None if the provider is not enabled or not found.
|
||||
"""
|
||||
providers_config: dict[str, OAuthProviderConfig] = settings.get_oauth_providers_config()
|
||||
config = providers_config.get(name)
|
||||
if not config or not config['enabled']:
|
||||
return None
|
||||
|
||||
provider_class = _PROVIDERS.get(name)
|
||||
if not provider_class:
|
||||
return None
|
||||
|
||||
redirect_uri = f'{settings.CABINET_URL}/auth/oauth/callback'
|
||||
|
||||
return provider_class(
|
||||
client_id=config['client_id'],
|
||||
client_secret=config['client_secret'],
|
||||
redirect_uri=redirect_uri,
|
||||
)
|
||||
@@ -3,7 +3,7 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
from datetime import datetime
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
from urllib.parse import parse_qsl, unquote
|
||||
|
||||
@@ -34,8 +34,8 @@ def validate_telegram_login_widget(data: dict[str, Any], max_age_seconds: int =
|
||||
if auth_date:
|
||||
try:
|
||||
# Use UTC timestamp to avoid timezone issues
|
||||
auth_time = datetime.utcfromtimestamp(int(auth_date))
|
||||
age = (datetime.utcnow() - auth_time).total_seconds()
|
||||
auth_time = datetime.fromtimestamp(int(auth_date), tz=UTC)
|
||||
age = (datetime.now(UTC) - auth_time).total_seconds()
|
||||
if age > max_age_seconds:
|
||||
return False
|
||||
except (ValueError, TypeError, OSError):
|
||||
@@ -81,8 +81,8 @@ def validate_telegram_init_data(init_data: str, max_age_seconds: int = 86400) ->
|
||||
if auth_date:
|
||||
try:
|
||||
# Use UTC timestamp to avoid timezone issues
|
||||
auth_time = datetime.utcfromtimestamp(int(auth_date))
|
||||
age = (datetime.utcnow() - auth_time).total_seconds()
|
||||
auth_time = datetime.fromtimestamp(int(auth_date), tz=UTC)
|
||||
age = (datetime.now(UTC) - auth_time).total_seconds()
|
||||
if age > max_age_seconds:
|
||||
return None
|
||||
except (ValueError, TypeError, OSError):
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""FastAPI dependencies for cabinet module."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
import structlog
|
||||
from aiogram import Bot
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
@@ -12,12 +12,13 @@ from app.config import settings
|
||||
from app.database.crud.user import get_user_by_id
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from app.database.models import User
|
||||
from app.services.blacklist_service import blacklist_service
|
||||
from app.services.maintenance_service import maintenance_service
|
||||
|
||||
from .auth.jwt_handler import get_token_payload
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
security = HTTPBearer(auto_error=False)
|
||||
|
||||
@@ -104,6 +105,18 @@ async def get_current_cabinet_user(
|
||||
detail='User account is not active',
|
||||
)
|
||||
|
||||
# Check blacklist
|
||||
if user.telegram_id is not None:
|
||||
is_blacklisted, reason = await blacklist_service.is_user_blacklisted(user.telegram_id, user.username)
|
||||
if is_blacklisted:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail={
|
||||
'code': 'blacklisted',
|
||||
'message': reason or 'Доступ запрещен',
|
||||
},
|
||||
)
|
||||
|
||||
# Check maintenance mode (allow admins to pass)
|
||||
if maintenance_service.is_maintenance_active():
|
||||
# Проверяем админа по telegram_id ИЛИ email
|
||||
@@ -148,10 +161,12 @@ async def get_current_cabinet_user(
|
||||
except HTTPException:
|
||||
raise
|
||||
except TimeoutError:
|
||||
logger.warning(f'Timeout checking channel subscription for user {user.telegram_id}')
|
||||
logger.warning('Timeout checking channel subscription for user', telegram_id=user.telegram_id)
|
||||
# Don't block user if check times out
|
||||
except Exception as e:
|
||||
logger.warning(f'Failed to check channel subscription for user {user.telegram_id}: {e}')
|
||||
logger.warning(
|
||||
'Failed to check channel subscription for user', telegram_id=user.telegram_id, error=e
|
||||
)
|
||||
# Don't block user if check fails
|
||||
|
||||
return user
|
||||
|
||||
@@ -5,10 +5,13 @@ from fastapi import APIRouter
|
||||
from .admin_apps import router as admin_apps_router
|
||||
from .admin_ban_system import router as admin_ban_system_router
|
||||
from .admin_broadcasts import router as admin_broadcasts_router
|
||||
from .admin_button_styles import router as admin_button_styles_router
|
||||
from .admin_campaigns import router as admin_campaigns_router
|
||||
from .admin_email_templates import router as admin_email_templates_router
|
||||
from .admin_partners import router as admin_partners_router
|
||||
from .admin_payment_methods import router as admin_payment_methods_router
|
||||
from .admin_payments import router as admin_payments_router
|
||||
from .admin_pinned_messages import router as admin_pinned_messages_router
|
||||
from .admin_promo_offers import router as admin_promo_offers_router
|
||||
from .admin_promocodes import promo_groups_router as admin_promo_groups_router, router as admin_promocodes_router
|
||||
from .admin_remnawave import router as admin_remnawave_router
|
||||
@@ -17,8 +20,11 @@ from .admin_settings import router as admin_settings_router
|
||||
from .admin_stats import router as admin_stats_router
|
||||
from .admin_tariffs import router as admin_tariffs_router
|
||||
from .admin_tickets import router as admin_tickets_router
|
||||
from .admin_traffic import router as admin_traffic_router
|
||||
from .admin_updates import router as admin_updates_router
|
||||
from .admin_users import router as admin_users_router
|
||||
from .admin_wheel import router as admin_wheel_router
|
||||
from .admin_withdrawals import router as admin_withdrawals_router
|
||||
from .auth import router as auth_router
|
||||
from .balance import router as balance_router
|
||||
from .branding import router as branding_router
|
||||
@@ -26,6 +32,8 @@ from .contests import router as contests_router
|
||||
from .info import router as info_router
|
||||
from .media import router as media_router
|
||||
from .notifications import router as notifications_router
|
||||
from .oauth import router as oauth_router
|
||||
from .partner_application import router as partner_application_router
|
||||
from .polls import router as polls_router
|
||||
from .promo import router as promo_router
|
||||
from .promocode import router as promocode_router
|
||||
@@ -38,6 +46,7 @@ from .ticket_notifications import (
|
||||
from .tickets import router as tickets_router
|
||||
from .websocket import router as websocket_router
|
||||
from .wheel import router as wheel_router
|
||||
from .withdrawal import router as withdrawal_router
|
||||
|
||||
|
||||
# Main cabinet router
|
||||
@@ -45,9 +54,12 @@ router = APIRouter(prefix='/cabinet', tags=['Cabinet'])
|
||||
|
||||
# Include all sub-routers
|
||||
router.include_router(auth_router)
|
||||
router.include_router(oauth_router)
|
||||
router.include_router(subscription_router)
|
||||
router.include_router(balance_router)
|
||||
router.include_router(referral_router)
|
||||
router.include_router(partner_application_router)
|
||||
router.include_router(withdrawal_router)
|
||||
# Notifications router MUST be before tickets router to avoid route conflict
|
||||
router.include_router(ticket_notifications_router)
|
||||
router.include_router(tickets_router)
|
||||
@@ -77,12 +89,18 @@ router.include_router(admin_broadcasts_router)
|
||||
router.include_router(admin_promocodes_router)
|
||||
router.include_router(admin_promo_groups_router)
|
||||
router.include_router(admin_campaigns_router)
|
||||
router.include_router(admin_partners_router)
|
||||
router.include_router(admin_withdrawals_router)
|
||||
router.include_router(admin_users_router)
|
||||
router.include_router(admin_payment_methods_router)
|
||||
router.include_router(admin_payments_router)
|
||||
router.include_router(admin_promo_offers_router)
|
||||
router.include_router(admin_remnawave_router)
|
||||
router.include_router(admin_email_templates_router)
|
||||
router.include_router(admin_updates_router)
|
||||
router.include_router(admin_traffic_router)
|
||||
router.include_router(admin_pinned_messages_router)
|
||||
router.include_router(admin_button_styles_router)
|
||||
|
||||
# WebSocket route
|
||||
router.include_router(websocket_router)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"""Admin routes for managing VPN applications in app-config.json."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -16,7 +16,7 @@ from app.services.system_settings_service import bot_configuration_service
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/apps', tags=['Cabinet Admin Apps'])
|
||||
|
||||
@@ -231,7 +231,7 @@ async def create_app(
|
||||
config['platforms'] = platforms
|
||||
|
||||
_save_config(config)
|
||||
logger.info(f"Admin {admin.id} created app '{request.app.id}' for platform '{platform}'")
|
||||
logger.info('Admin created app for platform', admin_id=admin.id, app_id=request.app.id, platform=platform)
|
||||
|
||||
return request.app
|
||||
|
||||
@@ -274,7 +274,7 @@ async def update_app(
|
||||
config['platforms'] = platforms
|
||||
|
||||
_save_config(config)
|
||||
logger.info(f"Admin {admin.id} updated app '{app_id}' in platform '{platform}'")
|
||||
logger.info('Admin updated app in platform', admin_id=admin.id, app_id=app_id, platform=platform)
|
||||
|
||||
return request.app
|
||||
|
||||
@@ -310,7 +310,7 @@ async def delete_app(
|
||||
config['platforms'] = platforms
|
||||
|
||||
_save_config(config)
|
||||
logger.info(f"Admin {admin.id} deleted app '{app_id}' from platform '{platform}'")
|
||||
logger.info('Admin deleted app from platform', admin_id=admin.id, app_id=app_id, platform=platform)
|
||||
|
||||
return {'status': 'deleted', 'app_id': app_id}
|
||||
|
||||
@@ -355,7 +355,7 @@ async def reorder_apps(
|
||||
config['platforms'] = platforms
|
||||
|
||||
_save_config(config)
|
||||
logger.info(f"Admin {admin.id} reordered apps in platform '{platform}'")
|
||||
logger.info('Admin reordered apps in platform', admin_id=admin.id, platform=platform)
|
||||
|
||||
return {'status': 'reordered', 'order': request.app_ids}
|
||||
|
||||
@@ -374,7 +374,7 @@ async def update_branding(
|
||||
config['config']['branding'] = request.branding.model_dump()
|
||||
|
||||
_save_config(config)
|
||||
logger.info(f'Admin {admin.id} updated branding')
|
||||
logger.info('Admin updated branding', admin_id=admin.id)
|
||||
|
||||
return request.branding
|
||||
|
||||
@@ -434,7 +434,14 @@ async def copy_app_to_platform(
|
||||
config['platforms'] = platforms
|
||||
|
||||
_save_config(config)
|
||||
logger.info(f"Admin {admin.id} copied app '{app_id}' from '{platform}' to '{target_platform}' as '{new_id}'")
|
||||
logger.info(
|
||||
'Admin copied app from to as',
|
||||
admin_id=admin.id,
|
||||
app_id=app_id,
|
||||
platform=platform,
|
||||
target_platform=target_platform,
|
||||
new_id=new_id,
|
||||
)
|
||||
|
||||
return {'status': 'copied', 'new_id': new_id, 'target_platform': target_platform}
|
||||
|
||||
@@ -498,9 +505,9 @@ async def set_remnawave_config_uuid(
|
||||
try:
|
||||
await bot_configuration_service.set_value(db, 'CABINET_REMNA_SUB_CONFIG', uuid_value)
|
||||
await db.commit()
|
||||
logger.info(f"Admin {admin.id} updated CABINET_REMNA_SUB_CONFIG to '{uuid_value}'")
|
||||
logger.info('Admin updated CABINET_REMNA_SUB_CONFIG to', admin_id=admin.id, uuid_value=uuid_value)
|
||||
except Exception as e:
|
||||
logger.error(f'Error saving RemnaWave config UUID: {e}')
|
||||
logger.error('Error saving RemnaWave config UUID', error=e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to save configuration',
|
||||
@@ -547,7 +554,7 @@ async def get_remnawave_subscription_config(
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f'Error fetching RemnaWave config: {e}')
|
||||
logger.error('Error fetching RemnaWave config', error=e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f'Failed to fetch config from RemnaWave: {e!s}',
|
||||
@@ -572,7 +579,7 @@ async def list_remnawave_subscription_configs(
|
||||
for c in configs
|
||||
]
|
||||
except Exception as e:
|
||||
logger.error(f'Error listing RemnaWave configs: {e}')
|
||||
logger.error('Error listing RemnaWave configs', error=e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f'Failed to fetch configs from RemnaWave: {e!s}',
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""Admin routes for Ban System monitoring in cabinet."""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
|
||||
from app.config import settings
|
||||
@@ -45,7 +45,7 @@ from ..schemas.ban_system import (
|
||||
)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/ban-system', tags=['Cabinet Admin Ban System'])
|
||||
|
||||
@@ -53,9 +53,11 @@ router = APIRouter(prefix='/admin/ban-system', tags=['Cabinet Admin Ban System']
|
||||
def _get_ban_api() -> BanSystemAPI:
|
||||
"""Get Ban System API instance."""
|
||||
logger.debug(
|
||||
f'Ban System check - enabled: {settings.is_ban_system_enabled()}, configured: {settings.is_ban_system_configured()}'
|
||||
'Ban System check enabled: configured',
|
||||
is_ban_system_enabled=settings.is_ban_system_enabled(),
|
||||
is_ban_system_configured=settings.is_ban_system_configured(),
|
||||
)
|
||||
logger.debug(f'Ban System URL: {settings.get_ban_system_api_url()}')
|
||||
logger.debug('Ban System URL', get_ban_system_api_url=settings.get_ban_system_api_url())
|
||||
|
||||
if not settings.is_ban_system_enabled():
|
||||
raise HTTPException(
|
||||
@@ -83,13 +85,13 @@ async def _api_request(api: BanSystemAPI, method: str, *args, **kwargs) -> Any:
|
||||
func = getattr(api, method)
|
||||
return await func(*args, **kwargs)
|
||||
except BanSystemAPIError as e:
|
||||
logger.error(f'Ban System API error: {e}')
|
||||
logger.error('Ban System API error', error=e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f'Ban System API error: {e.message}',
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'Ban System unexpected error: {e}')
|
||||
logger.error('Ban System unexpected error', error=e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f'Internal error: {e!s}',
|
||||
@@ -133,7 +135,7 @@ async def get_stats(
|
||||
api = _get_ban_api()
|
||||
data = await _api_request(api, 'get_stats')
|
||||
|
||||
logger.debug(f'Ban System raw stats: {data}')
|
||||
logger.debug('Ban System raw stats', data=data)
|
||||
|
||||
# Extract punishment stats
|
||||
punishment_stats = data.get('punishment_stats') or {}
|
||||
@@ -364,7 +366,7 @@ async def unban_user(
|
||||
api = _get_ban_api()
|
||||
try:
|
||||
await _api_request(api, 'enable_user', user_id=user_id)
|
||||
logger.info(f'Admin {admin.id} unbanned user {user_id} in Ban System')
|
||||
logger.info('Admin unbanned user in Ban System', admin_id=admin.id, user_id=user_id)
|
||||
return UnbanResponse(success=True, message='User unbanned successfully')
|
||||
except HTTPException:
|
||||
raise
|
||||
@@ -387,7 +389,7 @@ async def ban_user(
|
||||
minutes=request.minutes,
|
||||
reason=request.reason,
|
||||
)
|
||||
logger.info(f'Admin {admin.id} banned user {request.username}: {request.reason}')
|
||||
logger.info('Admin banned user', admin_id=admin.id, username=request.username, reason=request.reason)
|
||||
return UnbanResponse(success=True, message='User banned successfully')
|
||||
except HTTPException:
|
||||
raise
|
||||
@@ -819,7 +821,7 @@ async def set_setting(
|
||||
api = _get_ban_api()
|
||||
data = await _api_request(api, 'set_setting', key=key, value=value)
|
||||
|
||||
logger.info(f'Admin {admin.id} changed Ban System setting {key} to {value}')
|
||||
logger.info('Admin changed Ban System setting to', admin_id=admin.id, key=key, value=value)
|
||||
|
||||
return _parse_setting_response(key, data)
|
||||
|
||||
@@ -833,7 +835,7 @@ async def toggle_setting(
|
||||
api = _get_ban_api()
|
||||
data = await _api_request(api, 'toggle_setting', key=key)
|
||||
|
||||
logger.info(f'Admin {admin.id} toggled Ban System setting {key}')
|
||||
logger.info('Admin toggled Ban System setting', admin_id=admin.id, key=key)
|
||||
|
||||
return _parse_setting_response(key, data, default_type='bool')
|
||||
|
||||
@@ -850,7 +852,7 @@ async def whitelist_add(
|
||||
api = _get_ban_api()
|
||||
try:
|
||||
await _api_request(api, 'whitelist_add', username=request.username)
|
||||
logger.info(f'Admin {admin.id} added {request.username} to Ban System whitelist')
|
||||
logger.info('Admin added to Ban System whitelist', admin_id=admin.id, username=request.username)
|
||||
return UnbanResponse(success=True, message=f'User {request.username} added to whitelist')
|
||||
except HTTPException:
|
||||
raise
|
||||
@@ -867,7 +869,7 @@ async def whitelist_remove(
|
||||
api = _get_ban_api()
|
||||
try:
|
||||
await _api_request(api, 'whitelist_remove', username=request.username)
|
||||
logger.info(f'Admin {admin.id} removed {request.username} from Ban System whitelist')
|
||||
logger.info('Admin removed from Ban System whitelist', admin_id=admin.id, username=request.username)
|
||||
return UnbanResponse(success=True, message=f'User {request.username} removed from whitelist')
|
||||
except HTTPException:
|
||||
raise
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"""Admin routes for broadcasts in cabinet."""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy import distinct, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import BroadcastHistory, Subscription, SubscriptionStatus, Tariff, User
|
||||
@@ -13,7 +13,9 @@ from app.keyboards.admin import BROADCAST_BUTTONS, DEFAULT_BROADCAST_BUTTONS
|
||||
from app.services.broadcast_service import (
|
||||
BroadcastConfig,
|
||||
BroadcastMediaConfig,
|
||||
EmailBroadcastConfig,
|
||||
broadcast_service,
|
||||
email_broadcast_service,
|
||||
)
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
@@ -28,12 +30,17 @@ from ..schemas.broadcasts import (
|
||||
BroadcastPreviewResponse,
|
||||
BroadcastResponse,
|
||||
BroadcastTariffsResponse,
|
||||
CombinedBroadcastCreateRequest,
|
||||
EmailFilterItem,
|
||||
EmailFiltersResponse,
|
||||
EmailPreviewRequest,
|
||||
EmailPreviewResponse,
|
||||
TariffFilter,
|
||||
TariffForBroadcast,
|
||||
)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/broadcasts', tags=['Cabinet Admin Broadcasts'])
|
||||
|
||||
@@ -87,14 +94,34 @@ CUSTOM_FILTER_GROUPS = {
|
||||
}
|
||||
|
||||
|
||||
# ============ Email Filter Labels ============
|
||||
|
||||
EMAIL_FILTER_LABELS = {
|
||||
'all_email': 'Все с email',
|
||||
'email_only': 'Только email-регистрация',
|
||||
'telegram_with_email': 'Telegram с email',
|
||||
'active_email': 'С активной подпиской',
|
||||
'expired_email': 'С истекшей подпиской',
|
||||
}
|
||||
|
||||
EMAIL_FILTER_GROUPS = {
|
||||
'all_email': 'basic',
|
||||
'email_only': 'auth_type',
|
||||
'telegram_with_email': 'auth_type',
|
||||
'active_email': 'subscription',
|
||||
'expired_email': 'subscription',
|
||||
}
|
||||
|
||||
|
||||
# ============ Helper Functions ============
|
||||
|
||||
|
||||
def _serialize_broadcast(broadcast: BroadcastHistory) -> BroadcastResponse:
|
||||
"""Serialize broadcast to response model."""
|
||||
blocked = broadcast.blocked_count or 0
|
||||
progress = 0.0
|
||||
if broadcast.total_count > 0:
|
||||
progress = round((broadcast.sent_count + broadcast.failed_count) / broadcast.total_count * 100, 1)
|
||||
progress = round((broadcast.sent_count + broadcast.failed_count + blocked) / broadcast.total_count * 100, 1)
|
||||
|
||||
return BroadcastResponse(
|
||||
id=broadcast.id,
|
||||
@@ -107,15 +134,80 @@ def _serialize_broadcast(broadcast: BroadcastHistory) -> BroadcastResponse:
|
||||
total_count=broadcast.total_count,
|
||||
sent_count=broadcast.sent_count,
|
||||
failed_count=broadcast.failed_count,
|
||||
blocked_count=blocked,
|
||||
status=broadcast.status,
|
||||
admin_id=broadcast.admin_id,
|
||||
admin_name=broadcast.admin_name,
|
||||
created_at=broadcast.created_at,
|
||||
completed_at=broadcast.completed_at,
|
||||
progress_percent=progress,
|
||||
channel=getattr(broadcast, 'channel', 'telegram') or 'telegram',
|
||||
email_subject=getattr(broadcast, 'email_subject', None),
|
||||
email_html_content=getattr(broadcast, 'email_html_content', None),
|
||||
)
|
||||
|
||||
|
||||
async def _get_email_filter_count(db: AsyncSession, target: str) -> int:
|
||||
"""Get count of email users matching the filter."""
|
||||
base_conditions = [
|
||||
User.email.isnot(None),
|
||||
User.email_verified == True,
|
||||
User.status == 'active',
|
||||
]
|
||||
|
||||
if target == 'all_email':
|
||||
query = select(func.count(User.id)).where(*base_conditions)
|
||||
|
||||
elif target == 'email_only':
|
||||
query = select(func.count(User.id)).where(
|
||||
*base_conditions,
|
||||
User.auth_type == 'email',
|
||||
)
|
||||
|
||||
elif target == 'telegram_with_email':
|
||||
query = select(func.count(User.id)).where(
|
||||
*base_conditions,
|
||||
User.auth_type == 'telegram',
|
||||
User.telegram_id.isnot(None),
|
||||
)
|
||||
|
||||
elif target == 'active_email':
|
||||
query = (
|
||||
select(func.count(distinct(User.id)))
|
||||
.join(Subscription, User.id == Subscription.user_id)
|
||||
.where(
|
||||
*base_conditions,
|
||||
Subscription.status == SubscriptionStatus.ACTIVE.value,
|
||||
)
|
||||
)
|
||||
|
||||
elif target == 'expired_email':
|
||||
query = (
|
||||
select(func.count(distinct(User.id)))
|
||||
.join(Subscription, User.id == Subscription.user_id)
|
||||
.where(
|
||||
*base_conditions,
|
||||
Subscription.status.in_(
|
||||
[
|
||||
SubscriptionStatus.EXPIRED.value,
|
||||
SubscriptionStatus.DISABLED.value,
|
||||
]
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
else:
|
||||
return 0
|
||||
|
||||
result = await db.execute(query)
|
||||
return result.scalar() or 0
|
||||
|
||||
|
||||
def _validate_email_target(target: str) -> bool:
|
||||
"""Validate email target filter."""
|
||||
return target in EMAIL_FILTER_LABELS
|
||||
|
||||
|
||||
async def _get_tariff_user_counts(db: AsyncSession) -> dict:
|
||||
"""Get count of active users per tariff."""
|
||||
result = await db.execute(
|
||||
@@ -165,7 +257,7 @@ async def get_filters(
|
||||
try:
|
||||
count = await get_target_users_count(db, key)
|
||||
except Exception as e:
|
||||
logger.warning(f'Failed to get count for filter {key}: {e}')
|
||||
logger.warning('Failed to get count for filter', key=key, error=e)
|
||||
count = 0
|
||||
filters.append(
|
||||
BroadcastFilter(
|
||||
@@ -182,7 +274,7 @@ async def get_filters(
|
||||
try:
|
||||
count = await get_target_users_count(db, key)
|
||||
except Exception as e:
|
||||
logger.warning(f'Failed to get count for custom filter {key}: {e}')
|
||||
logger.warning('Failed to get count for custom filter', key=key, error=e)
|
||||
count = 0
|
||||
custom_filters.append(
|
||||
BroadcastFilter(
|
||||
@@ -277,7 +369,7 @@ async def preview_broadcast(
|
||||
try:
|
||||
count = await get_target_users_count(db, request.target)
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to get count for target {request.target}: {e}')
|
||||
logger.error('Failed to get count for target', target=request.target, error=e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to count recipients',
|
||||
@@ -360,7 +452,9 @@ async def create_broadcast(
|
||||
await broadcast_service.start_broadcast(broadcast.id, config)
|
||||
await db.refresh(broadcast)
|
||||
|
||||
logger.info(f"Admin {admin.id} created broadcast {broadcast.id} for target '{request.target}'")
|
||||
logger.info(
|
||||
'Admin created broadcast for target', admin_id=admin.id, broadcast_id=broadcast.id, target=request.target
|
||||
)
|
||||
|
||||
return _serialize_broadcast(broadcast)
|
||||
|
||||
@@ -388,6 +482,200 @@ async def list_broadcasts(
|
||||
)
|
||||
|
||||
|
||||
# ============ Email Broadcast Endpoints ============
|
||||
|
||||
|
||||
@router.get('/email-filters', response_model=EmailFiltersResponse)
|
||||
async def get_email_filters(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> EmailFiltersResponse:
|
||||
"""Get all available email filters with user counts."""
|
||||
filters = []
|
||||
total_with_email = 0
|
||||
|
||||
for key, label in EMAIL_FILTER_LABELS.items():
|
||||
try:
|
||||
count = await _get_email_filter_count(db, key)
|
||||
except Exception as e:
|
||||
logger.warning('Failed to get count for email filter', key=key, error=e)
|
||||
count = 0
|
||||
|
||||
filters.append(
|
||||
EmailFilterItem(
|
||||
key=key,
|
||||
label=label,
|
||||
count=count,
|
||||
group=EMAIL_FILTER_GROUPS.get(key),
|
||||
)
|
||||
)
|
||||
|
||||
# Track total with email (all_email filter)
|
||||
if key == 'all_email':
|
||||
total_with_email = count
|
||||
|
||||
return EmailFiltersResponse(
|
||||
filters=filters,
|
||||
total_with_email=total_with_email,
|
||||
)
|
||||
|
||||
|
||||
@router.post('/email-preview', response_model=EmailPreviewResponse)
|
||||
async def preview_email_broadcast(
|
||||
request: EmailPreviewRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> EmailPreviewResponse:
|
||||
"""Preview email broadcast recipients count."""
|
||||
if not _validate_email_target(request.target):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Invalid email target: {request.target}',
|
||||
)
|
||||
|
||||
try:
|
||||
count = await _get_email_filter_count(db, request.target)
|
||||
except Exception as e:
|
||||
logger.error('Failed to get email count for target', target=request.target, error=e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to count email recipients',
|
||||
)
|
||||
|
||||
return EmailPreviewResponse(target=request.target, count=count)
|
||||
|
||||
|
||||
@router.post('/send', response_model=BroadcastResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_combined_broadcast(
|
||||
request: CombinedBroadcastCreateRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> BroadcastResponse:
|
||||
"""Create and start a combined broadcast (telegram/email/both)."""
|
||||
# Get tariff IDs for target validation
|
||||
result = await db.execute(select(Tariff.id))
|
||||
tariff_ids = {row[0] for row in result.all()}
|
||||
|
||||
admin_name = admin.username or f'Admin #{admin.id}'
|
||||
|
||||
# Validate based on channel
|
||||
if request.channel in ('telegram', 'both'):
|
||||
# Validate telegram target
|
||||
if not _validate_target(request.target, tariff_ids):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Invalid target: {request.target}',
|
||||
)
|
||||
|
||||
# Validate telegram message
|
||||
if not request.message_text or not request.message_text.strip():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Message text is required for Telegram broadcast',
|
||||
)
|
||||
|
||||
# Validate buttons
|
||||
if not _validate_buttons(request.selected_buttons):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Invalid button key',
|
||||
)
|
||||
|
||||
if request.channel in ('email', 'both'):
|
||||
# For email channel, target must be email filter or we use telegram target for 'both'
|
||||
if request.channel == 'email' and not _validate_email_target(request.target):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Invalid email target: {request.target}',
|
||||
)
|
||||
|
||||
# Validate email fields
|
||||
if not request.email_subject or not request.email_subject.strip():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Email subject is required for email broadcast',
|
||||
)
|
||||
|
||||
if not request.email_html_content or not request.email_html_content.strip():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Email HTML content is required for email broadcast',
|
||||
)
|
||||
|
||||
media_payload = request.media
|
||||
|
||||
# Create broadcast record
|
||||
broadcast = BroadcastHistory(
|
||||
target_type=request.target,
|
||||
message_text=request.message_text.strip() if request.message_text else None,
|
||||
has_media=media_payload is not None,
|
||||
media_type=media_payload.type if media_payload else None,
|
||||
media_file_id=media_payload.file_id if media_payload else None,
|
||||
media_caption=media_payload.caption if media_payload else None,
|
||||
total_count=0,
|
||||
sent_count=0,
|
||||
failed_count=0,
|
||||
status='queued',
|
||||
admin_id=admin.id,
|
||||
admin_name=admin_name,
|
||||
channel=request.channel,
|
||||
email_subject=request.email_subject.strip() if request.email_subject else None,
|
||||
email_html_content=request.email_html_content.strip() if request.email_html_content else None,
|
||||
)
|
||||
db.add(broadcast)
|
||||
await db.commit()
|
||||
await db.refresh(broadcast)
|
||||
|
||||
# Start broadcasts based on channel
|
||||
if request.channel in ('telegram', 'both'):
|
||||
# Prepare media config
|
||||
media_config = None
|
||||
if media_payload:
|
||||
media_config = BroadcastMediaConfig(
|
||||
type=media_payload.type,
|
||||
file_id=media_payload.file_id,
|
||||
caption=media_payload.caption or request.message_text,
|
||||
)
|
||||
|
||||
# Create telegram broadcast config
|
||||
telegram_config = BroadcastConfig(
|
||||
target=request.target,
|
||||
message_text=request.message_text.strip(),
|
||||
selected_buttons=request.selected_buttons,
|
||||
media=media_config,
|
||||
initiator_name=admin_name,
|
||||
)
|
||||
|
||||
await broadcast_service.start_broadcast(broadcast.id, telegram_config)
|
||||
|
||||
if request.channel in ('email', 'both'):
|
||||
# For 'both' channel, we use 'all_email' as default email target
|
||||
# since telegram target won't match email filters
|
||||
email_target = request.target if request.channel == 'email' else 'all_email'
|
||||
|
||||
# Create email broadcast config
|
||||
email_config = EmailBroadcastConfig(
|
||||
target=email_target,
|
||||
email_subject=request.email_subject.strip(),
|
||||
email_html_content=request.email_html_content.strip(),
|
||||
initiator_name=admin_name,
|
||||
)
|
||||
|
||||
await email_broadcast_service.start_broadcast(broadcast.id, email_config)
|
||||
|
||||
await db.refresh(broadcast)
|
||||
|
||||
logger.info(
|
||||
'Admin created broadcast for target',
|
||||
admin_id=admin.id,
|
||||
channel=request.channel,
|
||||
broadcast_id=broadcast.id,
|
||||
target=request.target,
|
||||
)
|
||||
|
||||
return _serialize_broadcast(broadcast)
|
||||
|
||||
|
||||
@router.get('/{broadcast_id}', response_model=BroadcastResponse)
|
||||
async def get_broadcast(
|
||||
broadcast_id: int,
|
||||
@@ -410,7 +698,7 @@ async def stop_broadcast(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> BroadcastResponse:
|
||||
"""Stop a running broadcast."""
|
||||
"""Stop a running broadcast (telegram or email)."""
|
||||
broadcast = await db.get(BroadcastHistory, broadcast_id)
|
||||
if not broadcast:
|
||||
raise HTTPException(
|
||||
@@ -424,17 +712,25 @@ async def stop_broadcast(
|
||||
detail='Broadcast is not running',
|
||||
)
|
||||
|
||||
is_running = await broadcast_service.request_stop(broadcast_id)
|
||||
# Try to stop both telegram and email broadcasts (one or both may be running)
|
||||
channel = getattr(broadcast, 'channel', 'telegram') or 'telegram'
|
||||
|
||||
is_running = False
|
||||
if channel in ('telegram', 'both'):
|
||||
is_running = await broadcast_service.request_stop(broadcast_id) or is_running
|
||||
|
||||
if channel in ('email', 'both'):
|
||||
is_running = await email_broadcast_service.request_stop(broadcast_id) or is_running
|
||||
|
||||
if is_running:
|
||||
broadcast.status = 'cancelling'
|
||||
else:
|
||||
broadcast.status = 'cancelled'
|
||||
broadcast.completed_at = datetime.utcnow()
|
||||
broadcast.completed_at = datetime.now(UTC)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(broadcast)
|
||||
|
||||
logger.info(f'Admin {admin.id} stopped broadcast {broadcast_id}')
|
||||
logger.info('Admin stopped broadcast', admin_id=admin.id, broadcast_id=broadcast_id)
|
||||
|
||||
return _serialize_broadcast(broadcast)
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
"""Admin routes for per-section cabinet button style configuration."""
|
||||
|
||||
import json
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import User
|
||||
from app.utils.button_styles_cache import (
|
||||
ALLOWED_STYLE_VALUES,
|
||||
BOT_LOCALES,
|
||||
BUTTON_STYLES_KEY,
|
||||
DEFAULT_BUTTON_STYLES,
|
||||
SECTIONS,
|
||||
load_button_styles_cache,
|
||||
)
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/button-styles', tags=['Admin Button Styles'])
|
||||
|
||||
|
||||
# ---- Schemas ---------------------------------------------------------------
|
||||
|
||||
|
||||
class ButtonSectionConfig(BaseModel):
|
||||
"""Configuration for a single button section."""
|
||||
|
||||
style: str = 'primary'
|
||||
icon_custom_emoji_id: str = ''
|
||||
enabled: bool = True
|
||||
labels: dict[str, str] = {}
|
||||
|
||||
|
||||
class ButtonStylesResponse(BaseModel):
|
||||
"""Full button styles configuration (all 7 sections)."""
|
||||
|
||||
home: ButtonSectionConfig = ButtonSectionConfig()
|
||||
subscription: ButtonSectionConfig = ButtonSectionConfig()
|
||||
balance: ButtonSectionConfig = ButtonSectionConfig()
|
||||
referral: ButtonSectionConfig = ButtonSectionConfig()
|
||||
support: ButtonSectionConfig = ButtonSectionConfig()
|
||||
info: ButtonSectionConfig = ButtonSectionConfig()
|
||||
admin: ButtonSectionConfig = ButtonSectionConfig()
|
||||
|
||||
|
||||
MAX_LABEL_LENGTH = 100
|
||||
|
||||
|
||||
class ButtonSectionUpdate(BaseModel):
|
||||
"""Partial update for a single section (None = keep current)."""
|
||||
|
||||
style: str | None = None
|
||||
icon_custom_emoji_id: str | None = None
|
||||
enabled: bool | None = None
|
||||
labels: dict[str, str] | None = None
|
||||
|
||||
|
||||
class ButtonStylesUpdate(BaseModel):
|
||||
"""Partial update — only include sections you want to change."""
|
||||
|
||||
home: ButtonSectionUpdate | None = None
|
||||
subscription: ButtonSectionUpdate | None = None
|
||||
balance: ButtonSectionUpdate | None = None
|
||||
referral: ButtonSectionUpdate | None = None
|
||||
support: ButtonSectionUpdate | None = None
|
||||
info: ButtonSectionUpdate | None = None
|
||||
admin: ButtonSectionUpdate | None = None
|
||||
|
||||
|
||||
# ---- Helpers ---------------------------------------------------------------
|
||||
|
||||
|
||||
async def _get_setting_value(db: AsyncSession, key: str) -> str | None:
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.database.models import SystemSetting
|
||||
|
||||
result = await db.execute(select(SystemSetting).where(SystemSetting.key == key))
|
||||
setting = result.scalar_one_or_none()
|
||||
return setting.value if setting else None
|
||||
|
||||
|
||||
async def _set_setting_value(db: AsyncSession, key: str, value: str) -> None:
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.database.models import SystemSetting
|
||||
|
||||
result = await db.execute(select(SystemSetting).where(SystemSetting.key == key))
|
||||
setting = result.scalar_one_or_none()
|
||||
if setting:
|
||||
setting.value = value
|
||||
else:
|
||||
setting = SystemSetting(key=key, value=value)
|
||||
db.add(setting)
|
||||
await db.commit()
|
||||
|
||||
|
||||
def _build_response(styles: dict[str, dict]) -> ButtonStylesResponse:
|
||||
return ButtonStylesResponse(
|
||||
**{section: ButtonSectionConfig(**cfg) for section, cfg in styles.items() if section in SECTIONS},
|
||||
)
|
||||
|
||||
|
||||
# ---- Routes ----------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get('', response_model=ButtonStylesResponse)
|
||||
async def get_button_styles(
|
||||
_admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Return current per-section button styles. Admin only."""
|
||||
raw = await _get_setting_value(db, BUTTON_STYLES_KEY)
|
||||
merged = {section: {**cfg, 'labels': dict(cfg.get('labels', {}))} for section, cfg in DEFAULT_BUTTON_STYLES.items()}
|
||||
|
||||
if raw:
|
||||
try:
|
||||
db_data = json.loads(raw)
|
||||
for section, overrides in db_data.items():
|
||||
if section in merged and isinstance(overrides, dict):
|
||||
if overrides.get('style') in ALLOWED_STYLE_VALUES:
|
||||
merged[section]['style'] = overrides['style']
|
||||
if isinstance(overrides.get('icon_custom_emoji_id'), str):
|
||||
merged[section]['icon_custom_emoji_id'] = overrides['icon_custom_emoji_id']
|
||||
if isinstance(overrides.get('enabled'), bool):
|
||||
merged[section]['enabled'] = overrides['enabled']
|
||||
if isinstance(overrides.get('labels'), dict):
|
||||
merged[section]['labels'] = {
|
||||
k: v
|
||||
for k, v in overrides['labels'].items()
|
||||
if isinstance(k, str) and isinstance(v, str) and k in BOT_LOCALES
|
||||
}
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
return _build_response(merged)
|
||||
|
||||
|
||||
@router.patch('', response_model=ButtonStylesResponse)
|
||||
async def update_button_styles(
|
||||
payload: ButtonStylesUpdate,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Partially update per-section button styles. Admin only."""
|
||||
# Load current state
|
||||
raw = await _get_setting_value(db, BUTTON_STYLES_KEY)
|
||||
current: dict[str, dict] = {
|
||||
section: {**cfg, 'labels': dict(cfg.get('labels', {}))} for section, cfg in DEFAULT_BUTTON_STYLES.items()
|
||||
}
|
||||
|
||||
if raw:
|
||||
try:
|
||||
db_data = json.loads(raw)
|
||||
for section, overrides in db_data.items():
|
||||
if section in current and isinstance(overrides, dict):
|
||||
if overrides.get('style') in ALLOWED_STYLE_VALUES:
|
||||
current[section]['style'] = overrides['style']
|
||||
if isinstance(overrides.get('icon_custom_emoji_id'), str):
|
||||
current[section]['icon_custom_emoji_id'] = overrides['icon_custom_emoji_id']
|
||||
if isinstance(overrides.get('enabled'), bool):
|
||||
current[section]['enabled'] = overrides['enabled']
|
||||
if isinstance(overrides.get('labels'), dict):
|
||||
current[section]['labels'] = {
|
||||
k: v
|
||||
for k, v in overrides['labels'].items()
|
||||
if isinstance(k, str) and isinstance(v, str) and k in BOT_LOCALES
|
||||
}
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
# Apply updates
|
||||
update_data = payload.model_dump(exclude_none=True)
|
||||
changed_sections: list[str] = []
|
||||
|
||||
for section, updates in update_data.items():
|
||||
if section not in current or not isinstance(updates, dict):
|
||||
continue
|
||||
|
||||
if 'style' in updates:
|
||||
style_val = updates['style']
|
||||
if style_val not in ALLOWED_STYLE_VALUES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Invalid style "{style_val}" for section "{section}". '
|
||||
f'Allowed: {", ".join(sorted(ALLOWED_STYLE_VALUES))}',
|
||||
)
|
||||
current[section]['style'] = style_val
|
||||
|
||||
if 'icon_custom_emoji_id' in updates:
|
||||
emoji_val = (updates['icon_custom_emoji_id'] or '').strip()
|
||||
current[section]['icon_custom_emoji_id'] = emoji_val
|
||||
|
||||
if 'enabled' in updates:
|
||||
current[section]['enabled'] = updates['enabled']
|
||||
|
||||
if 'labels' in updates:
|
||||
raw_labels = updates['labels'] or {}
|
||||
sanitized: dict[str, str] = {}
|
||||
for locale_key, label_val in raw_labels.items():
|
||||
if locale_key not in BOT_LOCALES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Invalid locale "{locale_key}" for section "{section}". '
|
||||
f'Allowed: {", ".join(BOT_LOCALES)}',
|
||||
)
|
||||
if not isinstance(label_val, str):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Label value for locale "{locale_key}" must be a string.',
|
||||
)
|
||||
stripped = label_val.strip()
|
||||
if len(stripped) > MAX_LABEL_LENGTH:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Label for locale "{locale_key}" exceeds {MAX_LABEL_LENGTH} characters.',
|
||||
)
|
||||
# Empty string = remove custom label (use default)
|
||||
if stripped:
|
||||
sanitized[locale_key] = stripped
|
||||
current[section]['labels'] = sanitized
|
||||
|
||||
changed_sections.append(section)
|
||||
|
||||
# Persist
|
||||
await _set_setting_value(db, BUTTON_STYLES_KEY, json.dumps(current))
|
||||
|
||||
# Refresh in-process cache
|
||||
await load_button_styles_cache()
|
||||
|
||||
logger.info(
|
||||
'Admin updated button styles for sections', telegram_id=admin.telegram_id, changed_sections=changed_sections
|
||||
)
|
||||
|
||||
return _build_response(current)
|
||||
|
||||
|
||||
@router.post('/reset', response_model=ButtonStylesResponse)
|
||||
async def reset_button_styles(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Reset all button styles to defaults. Admin only."""
|
||||
await _set_setting_value(db, BUTTON_STYLES_KEY, json.dumps(DEFAULT_BUTTON_STYLES))
|
||||
await load_button_styles_cache()
|
||||
|
||||
logger.info('Admin reset button styles to defaults', telegram_id=admin.telegram_id)
|
||||
|
||||
return _build_response(DEFAULT_BUTTON_STYLES)
|
||||
@@ -1,7 +1,8 @@
|
||||
"""Admin routes for managing advertising campaigns in cabinet."""
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -21,7 +22,9 @@ from app.database.crud.campaign import (
|
||||
from app.database.crud.server_squad import get_all_server_squads
|
||||
from app.database.crud.tariff import get_all_tariffs
|
||||
from app.database.models import (
|
||||
AdvertisingCampaign,
|
||||
AdvertisingCampaignRegistration,
|
||||
PartnerStatus,
|
||||
Subscription,
|
||||
Tariff,
|
||||
User,
|
||||
@@ -29,6 +32,7 @@ from app.database.models import (
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
from ..schemas.campaigns import (
|
||||
AvailablePartnerItem,
|
||||
CampaignCreateRequest,
|
||||
CampaignDetailResponse,
|
||||
CampaignListItem,
|
||||
@@ -45,7 +49,7 @@ from ..schemas.campaigns import (
|
||||
from ..schemas.tariffs import TariffListItem
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/campaigns', tags=['Cabinet Admin Campaigns'])
|
||||
|
||||
@@ -58,6 +62,22 @@ def _get_deep_link(start_parameter: str) -> str:
|
||||
return f'?start={start_parameter}'
|
||||
|
||||
|
||||
def _get_web_link(start_parameter: str) -> str | None:
|
||||
"""Generate web link for campaign."""
|
||||
base_url = (settings.MINIAPP_CUSTOM_URL or '').rstrip('/')
|
||||
if base_url:
|
||||
return f'{base_url}/?campaign={start_parameter}'
|
||||
return None
|
||||
|
||||
|
||||
def _get_partner_name(campaign: AdvertisingCampaign) -> str | None:
|
||||
"""Get partner display name from campaign."""
|
||||
if not campaign.partner_user_id or not campaign.partner:
|
||||
return None
|
||||
partner = campaign.partner
|
||||
return partner.first_name or partner.username or f'#{partner.id}'
|
||||
|
||||
|
||||
@router.get('/overview', response_model=CampaignsOverviewResponse)
|
||||
async def get_overview(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
@@ -133,6 +153,26 @@ async def get_available_tariffs(
|
||||
]
|
||||
|
||||
|
||||
@router.get('/available-partners', response_model=list[AvailablePartnerItem])
|
||||
async def get_available_partners(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get list of approved partners for campaign partner selector."""
|
||||
result = await db.execute(
|
||||
select(User).where(User.partner_status == PartnerStatus.APPROVED.value).order_by(User.first_name, User.username)
|
||||
)
|
||||
partners = result.scalars().all()
|
||||
return [
|
||||
AvailablePartnerItem(
|
||||
user_id=p.id,
|
||||
username=p.username,
|
||||
first_name=p.first_name,
|
||||
)
|
||||
for p in partners
|
||||
]
|
||||
|
||||
|
||||
@router.get('', response_model=CampaignListResponse)
|
||||
async def list_campaigns(
|
||||
include_inactive: bool = True,
|
||||
@@ -159,6 +199,8 @@ async def list_campaigns(
|
||||
registrations_count=stats['registrations'],
|
||||
total_revenue_kopeks=stats['total_revenue_kopeks'],
|
||||
conversion_rate=stats['conversion_rate'],
|
||||
partner_user_id=campaign.partner_user_id,
|
||||
partner_name=_get_partner_name(campaign),
|
||||
created_at=campaign.created_at,
|
||||
)
|
||||
)
|
||||
@@ -202,10 +244,13 @@ async def get_campaign(
|
||||
tariff_id=campaign.tariff_id,
|
||||
tariff_duration_days=campaign.tariff_duration_days,
|
||||
tariff=tariff_info,
|
||||
partner_user_id=campaign.partner_user_id,
|
||||
partner_name=_get_partner_name(campaign),
|
||||
created_by=campaign.created_by,
|
||||
created_at=campaign.created_at,
|
||||
updated_at=campaign.updated_at,
|
||||
deep_link=_get_deep_link(campaign.start_parameter),
|
||||
web_link=_get_web_link(campaign.start_parameter),
|
||||
)
|
||||
|
||||
|
||||
@@ -249,6 +294,7 @@ async def get_campaign_stats(
|
||||
conversion_rate=stats['conversion_rate'],
|
||||
trial_conversion_rate=stats['trial_conversion_rate'],
|
||||
deep_link=_get_deep_link(campaign.start_parameter),
|
||||
web_link=_get_web_link(campaign.start_parameter),
|
||||
)
|
||||
|
||||
|
||||
@@ -289,19 +335,22 @@ async def get_campaign_registrations(
|
||||
)
|
||||
total = count_result.scalar() or 0
|
||||
|
||||
items = []
|
||||
for reg, user in rows:
|
||||
# Check if user has subscription
|
||||
# Batch query: find which users have active subscriptions (avoids N+1)
|
||||
user_ids = [user.id for _reg, user in rows]
|
||||
active_sub_user_ids: set[int] = set()
|
||||
if user_ids:
|
||||
sub_result = await db.execute(
|
||||
select(Subscription)
|
||||
select(Subscription.user_id)
|
||||
.where(
|
||||
Subscription.user_id == user.id,
|
||||
Subscription.user_id.in_(user_ids),
|
||||
Subscription.status == 'active',
|
||||
)
|
||||
.limit(1)
|
||||
.distinct()
|
||||
)
|
||||
has_sub = sub_result.scalar_one_or_none() is not None
|
||||
active_sub_user_ids = set(sub_result.scalars().all())
|
||||
|
||||
items = []
|
||||
for reg, user in rows:
|
||||
items.append(
|
||||
CampaignRegistrationItem(
|
||||
id=reg.id,
|
||||
@@ -316,7 +365,7 @@ async def get_campaign_registrations(
|
||||
tariff_duration_days=reg.tariff_duration_days,
|
||||
created_at=reg.created_at,
|
||||
user_balance_kopeks=user.balance_kopeks or 0,
|
||||
has_subscription=has_sub,
|
||||
has_subscription=user.id in active_sub_user_ids,
|
||||
has_paid=user.has_had_paid_subscription or False,
|
||||
)
|
||||
)
|
||||
@@ -359,6 +408,15 @@ async def create_new_campaign(
|
||||
detail='Tariff not found',
|
||||
)
|
||||
|
||||
# Validate partner exists and is approved
|
||||
if request.partner_user_id is not None:
|
||||
partner_user = await db.get(User, request.partner_user_id)
|
||||
if not partner_user or partner_user.partner_status != 'approved':
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Partner not found or not approved',
|
||||
)
|
||||
|
||||
campaign = await create_campaign(
|
||||
db,
|
||||
name=request.name,
|
||||
@@ -373,12 +431,13 @@ async def create_new_campaign(
|
||||
tariff_id=request.tariff_id,
|
||||
tariff_duration_days=request.tariff_duration_days,
|
||||
is_active=request.is_active,
|
||||
partner_user_id=request.partner_user_id,
|
||||
)
|
||||
|
||||
# Reload to get tariff relationship
|
||||
campaign = await get_campaign_by_id(db, campaign.id)
|
||||
|
||||
logger.info(f'Admin {admin.id} created campaign {campaign.id}: {campaign.name}')
|
||||
logger.info('Admin created campaign', admin_id=admin.id, campaign_id=campaign.id, campaign_name=campaign.name)
|
||||
|
||||
return await get_campaign(campaign.id, admin, db)
|
||||
|
||||
@@ -444,10 +503,28 @@ async def update_existing_campaign(
|
||||
if request.tariff_duration_days is not None:
|
||||
updates['tariff_duration_days'] = request.tariff_duration_days
|
||||
|
||||
# Handle partner_user_id separately (allows explicit None to unassign)
|
||||
partner_changed = False
|
||||
if 'partner_user_id' in request.model_fields_set:
|
||||
new_partner_id = request.partner_user_id
|
||||
if new_partner_id is not None:
|
||||
partner_user = await db.get(User, new_partner_id)
|
||||
if not partner_user or partner_user.partner_status != 'approved':
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Partner not found or not approved',
|
||||
)
|
||||
campaign.partner_user_id = new_partner_id
|
||||
campaign.updated_at = datetime.now(UTC)
|
||||
partner_changed = True
|
||||
|
||||
if updates:
|
||||
await update_campaign(db, campaign, **updates)
|
||||
elif partner_changed:
|
||||
await db.commit()
|
||||
await db.refresh(campaign)
|
||||
|
||||
logger.info(f'Admin {admin.id} updated campaign {campaign_id}')
|
||||
logger.info('Admin updated campaign', admin_id=admin.id, campaign_id=campaign_id)
|
||||
|
||||
return await get_campaign(campaign_id, admin, db)
|
||||
|
||||
@@ -475,7 +552,7 @@ async def delete_existing_campaign(
|
||||
)
|
||||
|
||||
await delete_campaign(db, campaign)
|
||||
logger.info(f'Admin {admin.id} deleted campaign {campaign_id}: {campaign.name}')
|
||||
logger.info('Admin deleted campaign', admin_id=admin.id, campaign_id=campaign_id, campaign_name=campaign.name)
|
||||
|
||||
return {'message': 'Campaign deleted successfully'}
|
||||
|
||||
@@ -498,7 +575,7 @@ async def toggle_campaign(
|
||||
await update_campaign(db, campaign, is_active=new_status)
|
||||
|
||||
status_text = 'activated' if new_status else 'deactivated'
|
||||
logger.info(f'Admin {admin.id} {status_text} campaign {campaign_id}')
|
||||
logger.info('Admin campaign', admin_id=admin.id, status_text=status_text, campaign_id=campaign_id)
|
||||
|
||||
return CampaignToggleResponse(
|
||||
id=campaign_id,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"""Admin routes for managing email notification templates."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -20,7 +20,7 @@ from ..services.email_template_overrides import (
|
||||
from ..services.email_templates import EmailNotificationTemplates
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/email-templates', tags=['Admin Email Templates'])
|
||||
|
||||
@@ -337,7 +337,7 @@ SAMPLE_CONTEXTS: dict[str, dict[str, Any]] = {
|
||||
'password_reset': {'username': 'John', 'reset_url': 'https://example.com/reset?token=abc123', 'expire_hours': 1},
|
||||
}
|
||||
|
||||
AVAILABLE_LANGUAGES = ['ru', 'en', 'zh', 'ua']
|
||||
AVAILABLE_LANGUAGES = ['ru', 'en', 'zh', 'ua', 'fa']
|
||||
|
||||
|
||||
# ============ Schemas ============
|
||||
@@ -505,10 +505,7 @@ async def update_template(
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'Админ %s обновил email шаблон %s/%s',
|
||||
admin.id,
|
||||
notification_type,
|
||||
language,
|
||||
'Админ обновил email шаблон /', admin_id=admin.id, notification_type=notification_type, language=language
|
||||
)
|
||||
|
||||
return {'status': 'ok', 'template': result}
|
||||
@@ -533,10 +530,10 @@ async def reset_template(
|
||||
|
||||
if deleted:
|
||||
logger.info(
|
||||
'Админ %s сбросил email шаблон %s/%s к дефолту',
|
||||
admin.id,
|
||||
notification_type,
|
||||
language,
|
||||
'Админ сбросил email шаблон / к дефолту',
|
||||
admin_id=admin.id,
|
||||
notification_type=notification_type,
|
||||
language=language,
|
||||
)
|
||||
|
||||
return {'status': 'ok', 'was_custom': deleted}
|
||||
@@ -657,7 +654,7 @@ async def send_test_email(
|
||||
body_html=body_html,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error('Ошибка отправки тестового email: %s', e)
|
||||
logger.error('Ошибка отправки тестового email', e=e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f'Failed to send test email: {e!s}',
|
||||
@@ -670,11 +667,11 @@ async def send_test_email(
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'Админ %s отправил тестовый email %s/%s на %s',
|
||||
admin.id,
|
||||
notification_type,
|
||||
language,
|
||||
to_email,
|
||||
'Админ отправил тестовый email / на',
|
||||
admin_id=admin.id,
|
||||
notification_type=notification_type,
|
||||
language=language,
|
||||
to_email=to_email,
|
||||
)
|
||||
|
||||
return {'status': 'ok', 'sent_to': to_email}
|
||||
|
||||
@@ -0,0 +1,582 @@
|
||||
"""Admin routes for managing partners in cabinet."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Literal
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import desc, func, or_, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import (
|
||||
AdvertisingCampaign,
|
||||
PartnerApplication,
|
||||
PartnerStatus,
|
||||
ReferralEarning,
|
||||
User,
|
||||
)
|
||||
from app.services.partner_application_service import partner_application_service
|
||||
from app.services.partner_stats_service import PartnerStatsService
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
from ..schemas.partners import (
|
||||
AdminApproveRequest,
|
||||
AdminPartnerApplicationItem,
|
||||
AdminPartnerApplicationsResponse,
|
||||
AdminPartnerDetailResponse,
|
||||
AdminPartnerItem,
|
||||
AdminPartnerListResponse,
|
||||
AdminRejectRequest,
|
||||
AdminUpdateCommissionRequest,
|
||||
CampaignSummary,
|
||||
)
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/partners', tags=['Cabinet Admin Partners'])
|
||||
|
||||
|
||||
# ==================== Settings ====================
|
||||
|
||||
|
||||
class PartnerSettingsResponse(BaseModel):
|
||||
withdrawal_enabled: bool
|
||||
withdrawal_min_amount_kopeks: int
|
||||
withdrawal_cooldown_days: int
|
||||
withdrawal_requisites_text: str
|
||||
partner_section_visible: bool
|
||||
referral_program_enabled: bool
|
||||
|
||||
|
||||
class PartnerSettingsUpdateRequest(BaseModel):
|
||||
withdrawal_enabled: bool | None = None
|
||||
withdrawal_min_amount_kopeks: int | None = Field(None, ge=0, le=100_000_000)
|
||||
withdrawal_cooldown_days: int | None = Field(None, ge=0, le=365)
|
||||
withdrawal_requisites_text: str | None = Field(None, max_length=2000)
|
||||
partner_section_visible: bool | None = None
|
||||
referral_program_enabled: bool | None = None
|
||||
|
||||
|
||||
def _build_partner_settings_response() -> PartnerSettingsResponse:
|
||||
return PartnerSettingsResponse(
|
||||
withdrawal_enabled=settings.REFERRAL_WITHDRAWAL_ENABLED,
|
||||
withdrawal_min_amount_kopeks=settings.REFERRAL_WITHDRAWAL_MIN_AMOUNT_KOPEKS,
|
||||
withdrawal_cooldown_days=settings.REFERRAL_WITHDRAWAL_COOLDOWN_DAYS,
|
||||
withdrawal_requisites_text=settings.REFERRAL_WITHDRAWAL_REQUISITES_TEXT,
|
||||
partner_section_visible=settings.REFERRAL_PARTNER_SECTION_VISIBLE,
|
||||
referral_program_enabled=settings.REFERRAL_PROGRAM_ENABLED,
|
||||
)
|
||||
|
||||
|
||||
@router.get('/settings', response_model=PartnerSettingsResponse)
|
||||
async def get_partner_settings(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""Get partner system settings."""
|
||||
return _build_partner_settings_response()
|
||||
|
||||
|
||||
@router.patch('/settings', response_model=PartnerSettingsResponse)
|
||||
async def update_partner_settings(
|
||||
request: PartnerSettingsUpdateRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""Update partner system settings."""
|
||||
from pathlib import Path
|
||||
|
||||
# Update in-memory settings
|
||||
if request.withdrawal_enabled is not None:
|
||||
settings.REFERRAL_WITHDRAWAL_ENABLED = request.withdrawal_enabled
|
||||
if request.withdrawal_min_amount_kopeks is not None:
|
||||
settings.REFERRAL_WITHDRAWAL_MIN_AMOUNT_KOPEKS = request.withdrawal_min_amount_kopeks
|
||||
if request.withdrawal_cooldown_days is not None:
|
||||
settings.REFERRAL_WITHDRAWAL_COOLDOWN_DAYS = request.withdrawal_cooldown_days
|
||||
if request.withdrawal_requisites_text is not None:
|
||||
settings.REFERRAL_WITHDRAWAL_REQUISITES_TEXT = request.withdrawal_requisites_text
|
||||
if request.partner_section_visible is not None:
|
||||
settings.REFERRAL_PARTNER_SECTION_VISIBLE = request.partner_section_visible
|
||||
if request.referral_program_enabled is not None:
|
||||
settings.REFERRAL_PROGRAM_ENABLED = request.referral_program_enabled
|
||||
|
||||
# Persist to .env file
|
||||
try:
|
||||
env_file = Path('.env')
|
||||
if env_file.exists():
|
||||
lines = env_file.read_text().splitlines()
|
||||
updates: dict[str, str] = {}
|
||||
|
||||
if request.withdrawal_enabled is not None:
|
||||
updates['REFERRAL_WITHDRAWAL_ENABLED'] = str(request.withdrawal_enabled).lower()
|
||||
if request.withdrawal_min_amount_kopeks is not None:
|
||||
updates['REFERRAL_WITHDRAWAL_MIN_AMOUNT_KOPEKS'] = str(request.withdrawal_min_amount_kopeks)
|
||||
if request.withdrawal_cooldown_days is not None:
|
||||
updates['REFERRAL_WITHDRAWAL_COOLDOWN_DAYS'] = str(request.withdrawal_cooldown_days)
|
||||
if request.withdrawal_requisites_text is not None:
|
||||
# Sanitize: replace newlines to prevent .env injection
|
||||
sanitized = (
|
||||
request.withdrawal_requisites_text.replace('\r\n', ' ').replace('\n', ' ').replace('\r', ' ')
|
||||
)
|
||||
updates['REFERRAL_WITHDRAWAL_REQUISITES_TEXT'] = sanitized
|
||||
if request.partner_section_visible is not None:
|
||||
updates['REFERRAL_PARTNER_SECTION_VISIBLE'] = str(request.partner_section_visible).lower()
|
||||
if request.referral_program_enabled is not None:
|
||||
updates['REFERRAL_PROGRAM_ENABLED'] = str(request.referral_program_enabled).lower()
|
||||
|
||||
new_lines = []
|
||||
updated_keys: set[str] = set()
|
||||
|
||||
for line in lines:
|
||||
updated = False
|
||||
for key, value in updates.items():
|
||||
if line.startswith(f'{key}='):
|
||||
new_lines.append(f'{key}={value}')
|
||||
updated_keys.add(key)
|
||||
updated = True
|
||||
break
|
||||
if not updated:
|
||||
new_lines.append(line)
|
||||
|
||||
for key, value in updates.items():
|
||||
if key not in updated_keys:
|
||||
new_lines.append(f'{key}={value}')
|
||||
|
||||
env_file.write_text('\n'.join(new_lines) + '\n')
|
||||
logger.info('Updated partner settings in .env file', admin_id=admin.id)
|
||||
except Exception as e:
|
||||
logger.warning('Failed to update .env file', error=e)
|
||||
|
||||
return _build_partner_settings_response()
|
||||
|
||||
|
||||
# ==================== Applications (static paths first) ====================
|
||||
|
||||
|
||||
@router.get('/applications', response_model=AdminPartnerApplicationsResponse)
|
||||
async def list_applications(
|
||||
application_status: Literal['pending', 'approved', 'rejected', 'none'] | None = Query(None, alias='status'),
|
||||
offset: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""List partner applications."""
|
||||
applications, total = await partner_application_service.get_all_applications(
|
||||
db, status=application_status, limit=limit, offset=offset
|
||||
)
|
||||
|
||||
# Batch-fetch users to avoid N+1
|
||||
user_ids = list({app.user_id for app in applications})
|
||||
if user_ids:
|
||||
users_result = await db.execute(select(User).where(User.id.in_(user_ids)))
|
||||
users_map = {u.id: u for u in users_result.scalars().all()}
|
||||
else:
|
||||
users_map = {}
|
||||
|
||||
items = []
|
||||
for app in applications:
|
||||
user = users_map.get(app.user_id)
|
||||
items.append(
|
||||
AdminPartnerApplicationItem(
|
||||
id=app.id,
|
||||
user_id=app.user_id,
|
||||
username=user.username if user else None,
|
||||
first_name=user.first_name if user else None,
|
||||
telegram_id=user.telegram_id if user else None,
|
||||
company_name=app.company_name,
|
||||
website_url=app.website_url,
|
||||
telegram_channel=app.telegram_channel,
|
||||
description=app.description,
|
||||
expected_monthly_referrals=app.expected_monthly_referrals,
|
||||
status=app.status,
|
||||
admin_comment=app.admin_comment,
|
||||
approved_commission_percent=app.approved_commission_percent,
|
||||
created_at=app.created_at,
|
||||
processed_at=app.processed_at,
|
||||
)
|
||||
)
|
||||
|
||||
return AdminPartnerApplicationsResponse(items=items, total=total)
|
||||
|
||||
|
||||
@router.post('/applications/{application_id}/approve')
|
||||
async def approve_application(
|
||||
application_id: int,
|
||||
request: AdminApproveRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Approve a partner application."""
|
||||
success, error = await partner_application_service.approve_application(
|
||||
db,
|
||||
application_id=application_id,
|
||||
admin_id=admin.id,
|
||||
commission_percent=request.commission_percent,
|
||||
comment=request.comment,
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=error,
|
||||
)
|
||||
|
||||
# Notify user about approval
|
||||
try:
|
||||
from aiogram import Bot
|
||||
|
||||
from app.config import settings
|
||||
from app.services.notification_delivery_service import notification_delivery_service
|
||||
|
||||
if settings.BOT_TOKEN:
|
||||
application = await db.get(PartnerApplication, application_id)
|
||||
user = await db.get(User, application.user_id) if application else None
|
||||
if user:
|
||||
comment_text = f'\n{request.comment}' if request.comment else ''
|
||||
tg_message = (
|
||||
f'✅ Ваша заявка на партнёрство одобрена!\nКомиссия: {request.commission_percent}%{comment_text}'
|
||||
)
|
||||
bot = Bot(token=settings.BOT_TOKEN)
|
||||
try:
|
||||
await notification_delivery_service.notify_partner_approved(
|
||||
user=user,
|
||||
commission_percent=request.commission_percent,
|
||||
comment=request.comment,
|
||||
bot=bot,
|
||||
telegram_message=tg_message,
|
||||
)
|
||||
finally:
|
||||
await bot.session.close()
|
||||
except Exception as e:
|
||||
logger.error('Failed to send partner approval notification', error=e)
|
||||
|
||||
return {'success': True}
|
||||
|
||||
|
||||
@router.post('/applications/{application_id}/reject')
|
||||
async def reject_application(
|
||||
application_id: int,
|
||||
request: AdminRejectRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Reject a partner application."""
|
||||
success, error = await partner_application_service.reject_application(
|
||||
db,
|
||||
application_id=application_id,
|
||||
admin_id=admin.id,
|
||||
comment=request.comment,
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=error,
|
||||
)
|
||||
|
||||
# Notify user about rejection
|
||||
try:
|
||||
from aiogram import Bot
|
||||
|
||||
from app.config import settings
|
||||
from app.services.notification_delivery_service import notification_delivery_service
|
||||
|
||||
if settings.BOT_TOKEN:
|
||||
application = await db.get(PartnerApplication, application_id)
|
||||
user = await db.get(User, application.user_id) if application else None
|
||||
if user:
|
||||
comment_text = f'\nПричина: {request.comment}' if request.comment else ''
|
||||
tg_message = f'❌ Ваша заявка на партнёрство отклонена.{comment_text}'
|
||||
bot = Bot(token=settings.BOT_TOKEN)
|
||||
try:
|
||||
await notification_delivery_service.notify_partner_rejected(
|
||||
user=user,
|
||||
comment=request.comment,
|
||||
bot=bot,
|
||||
telegram_message=tg_message,
|
||||
)
|
||||
finally:
|
||||
await bot.session.close()
|
||||
except Exception as e:
|
||||
logger.error('Failed to send partner rejection notification', error=e)
|
||||
|
||||
return {'success': True}
|
||||
|
||||
|
||||
# ==================== Stats (static paths) ====================
|
||||
|
||||
|
||||
@router.get('/stats')
|
||||
async def get_partner_stats(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get overall partner statistics."""
|
||||
total_partners = await db.execute(
|
||||
select(func.count()).select_from(User).where(User.partner_status == PartnerStatus.APPROVED.value)
|
||||
)
|
||||
pending_apps = await db.execute(
|
||||
select(func.count())
|
||||
.select_from(PartnerApplication)
|
||||
.where(PartnerApplication.status == PartnerStatus.PENDING.value)
|
||||
)
|
||||
total_referrals = await db.execute(select(func.count()).select_from(User).where(User.referred_by_id.isnot(None)))
|
||||
total_earnings = await db.execute(select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0)))
|
||||
|
||||
return {
|
||||
'total_partners': total_partners.scalar() or 0,
|
||||
'pending_applications': pending_apps.scalar() or 0,
|
||||
'total_referrals': total_referrals.scalar() or 0,
|
||||
'total_earnings_kopeks': total_earnings.scalar() or 0,
|
||||
}
|
||||
|
||||
|
||||
# ==================== Partners list ====================
|
||||
|
||||
|
||||
@router.get('', response_model=AdminPartnerListResponse)
|
||||
async def list_partners(
|
||||
offset: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""List approved partners."""
|
||||
count_result = await db.execute(
|
||||
select(func.count()).select_from(User).where(User.partner_status == PartnerStatus.APPROVED.value)
|
||||
)
|
||||
total = count_result.scalar() or 0
|
||||
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.where(User.partner_status == PartnerStatus.APPROVED.value)
|
||||
.order_by(desc(User.created_at))
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
partners = result.scalars().all()
|
||||
|
||||
# Batch-fetch earnings and referral counts to avoid N+1
|
||||
partner_ids = [u.id for u in partners]
|
||||
earnings_map: dict[int, int] = {}
|
||||
referral_count_map: dict[int, int] = {}
|
||||
|
||||
if partner_ids:
|
||||
earnings_result = await db.execute(
|
||||
select(ReferralEarning.user_id, func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0))
|
||||
.where(ReferralEarning.user_id.in_(partner_ids))
|
||||
.group_by(ReferralEarning.user_id)
|
||||
)
|
||||
earnings_map = {row[0]: int(row[1]) for row in earnings_result.all()}
|
||||
|
||||
referral_result = await db.execute(
|
||||
select(User.referred_by_id, func.count())
|
||||
.where(User.referred_by_id.in_(partner_ids))
|
||||
.group_by(User.referred_by_id)
|
||||
)
|
||||
referral_count_map = {row[0]: row[1] for row in referral_result.all()}
|
||||
|
||||
items = []
|
||||
for user in partners:
|
||||
items.append(
|
||||
AdminPartnerItem(
|
||||
user_id=user.id,
|
||||
username=user.username,
|
||||
first_name=user.first_name,
|
||||
telegram_id=user.telegram_id,
|
||||
commission_percent=user.referral_commission_percent,
|
||||
total_referrals=referral_count_map.get(user.id, 0),
|
||||
total_earnings_kopeks=earnings_map.get(user.id, 0),
|
||||
balance_kopeks=user.balance_kopeks,
|
||||
partner_status=user.partner_status,
|
||||
created_at=user.created_at,
|
||||
)
|
||||
)
|
||||
|
||||
return AdminPartnerListResponse(items=items, total=total)
|
||||
|
||||
|
||||
# ==================== Partner detail (parametric paths last) ====================
|
||||
|
||||
|
||||
@router.get('/{user_id}', response_model=AdminPartnerDetailResponse)
|
||||
async def get_partner_detail(
|
||||
user_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get detailed partner info."""
|
||||
user = await db.get(User, user_id)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Пользователь не найден',
|
||||
)
|
||||
|
||||
stats = await PartnerStatsService.get_referrer_detailed_stats(db, user_id)
|
||||
|
||||
# Get assigned campaigns
|
||||
campaigns_result = await db.execute(
|
||||
select(AdvertisingCampaign).where(AdvertisingCampaign.partner_user_id == user_id)
|
||||
)
|
||||
campaigns = campaigns_result.scalars().all()
|
||||
campaign_list = [
|
||||
CampaignSummary(
|
||||
id=c.id,
|
||||
name=c.name,
|
||||
start_parameter=c.start_parameter,
|
||||
is_active=c.is_active,
|
||||
)
|
||||
for c in campaigns
|
||||
]
|
||||
|
||||
summary = stats['summary']
|
||||
earnings = stats['earnings']
|
||||
|
||||
return AdminPartnerDetailResponse(
|
||||
user_id=user.id,
|
||||
username=user.username,
|
||||
first_name=user.first_name,
|
||||
telegram_id=user.telegram_id,
|
||||
commission_percent=user.referral_commission_percent,
|
||||
partner_status=user.partner_status,
|
||||
balance_kopeks=user.balance_kopeks,
|
||||
total_referrals=summary['total_referrals'],
|
||||
paid_referrals=summary['paid_referrals'],
|
||||
active_referrals=summary['active_referrals'],
|
||||
earnings_all_time=earnings['all_time_kopeks'],
|
||||
earnings_today=earnings['today_kopeks'],
|
||||
earnings_week=earnings['week_kopeks'],
|
||||
earnings_month=earnings['month_kopeks'],
|
||||
conversion_to_paid=summary['conversion_to_paid_percent'],
|
||||
campaigns=campaign_list,
|
||||
created_at=user.created_at,
|
||||
)
|
||||
|
||||
|
||||
@router.patch('/{user_id}/commission')
|
||||
async def update_commission(
|
||||
user_id: int,
|
||||
request: AdminUpdateCommissionRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update partner commission percent."""
|
||||
user = await db.get(User, user_id)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Пользователь не найден',
|
||||
)
|
||||
|
||||
if user.partner_status != PartnerStatus.APPROVED.value:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Пользователь не является партнёром',
|
||||
)
|
||||
|
||||
old_commission = user.referral_commission_percent
|
||||
user.referral_commission_percent = request.commission_percent
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
'Комиссия партнёра обновлена',
|
||||
user_id=user_id,
|
||||
old_commission=old_commission,
|
||||
new_commission=request.commission_percent,
|
||||
admin_id=admin.id,
|
||||
)
|
||||
|
||||
return {'success': True, 'commission_percent': request.commission_percent}
|
||||
|
||||
|
||||
@router.post('/{user_id}/revoke')
|
||||
async def revoke_partner(
|
||||
user_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Revoke partner status."""
|
||||
success, error = await partner_application_service.revoke_partner(db, user_id=user_id, admin_id=admin.id)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=error,
|
||||
)
|
||||
|
||||
return {'success': True}
|
||||
|
||||
|
||||
@router.post('/{user_id}/campaigns/{campaign_id}/assign')
|
||||
async def assign_campaign(
|
||||
user_id: int,
|
||||
campaign_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Assign a campaign to a partner."""
|
||||
campaign = await db.get(AdvertisingCampaign, campaign_id)
|
||||
if not campaign:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Кампания не найдена',
|
||||
)
|
||||
|
||||
user = await db.get(User, user_id)
|
||||
if not user or user.partner_status != PartnerStatus.APPROVED.value:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Пользователь не является партнёром',
|
||||
)
|
||||
|
||||
# Atomic check-and-set to prevent race conditions
|
||||
result = await db.execute(
|
||||
update(AdvertisingCampaign)
|
||||
.where(
|
||||
AdvertisingCampaign.id == campaign_id,
|
||||
or_(
|
||||
AdvertisingCampaign.partner_user_id.is_(None),
|
||||
AdvertisingCampaign.partner_user_id == user_id,
|
||||
),
|
||||
)
|
||||
.values(partner_user_id=user_id, updated_at=datetime.now(UTC))
|
||||
)
|
||||
if result.rowcount == 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Кампания уже привязана к другому партнёру',
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
return {'success': True}
|
||||
|
||||
|
||||
@router.post('/{user_id}/campaigns/{campaign_id}/unassign')
|
||||
async def unassign_campaign(
|
||||
user_id: int,
|
||||
campaign_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Unassign a campaign from a partner."""
|
||||
campaign = await db.get(AdvertisingCampaign, campaign_id)
|
||||
if not campaign:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Кампания не найдена',
|
||||
)
|
||||
|
||||
if campaign.partner_user_id != user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Кампания не привязана к этому партнёру',
|
||||
)
|
||||
|
||||
campaign.partner_user_id = None
|
||||
campaign.updated_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
|
||||
return {'success': True}
|
||||
@@ -1,8 +1,8 @@
|
||||
"""Admin routes for payment method configuration in cabinet."""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -20,7 +20,7 @@ from app.services.payment_method_config_service import (
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/payment-methods', tags=['Cabinet Admin Payment Methods'])
|
||||
|
||||
@@ -168,7 +168,7 @@ async def update_payment_methods_order(
|
||||
):
|
||||
"""Batch update sort order for payment methods."""
|
||||
await update_sort_order(db, request.method_ids)
|
||||
logger.info(f'Admin {admin.id} updated payment methods order: {request.method_ids}')
|
||||
logger.info('Admin updated payment methods order', admin_id=admin.id, method_ids=request.method_ids)
|
||||
return {'success': True}
|
||||
|
||||
|
||||
@@ -222,7 +222,7 @@ async def update_payment_method(
|
||||
detail=f'Payment method not found: {method_id}',
|
||||
)
|
||||
|
||||
logger.info(f'Admin {admin.id} updated payment method config: {method_id}')
|
||||
logger.info('Admin updated payment method config', admin_id=admin.id, method_id=method_id)
|
||||
|
||||
defaults = _get_method_defaults()
|
||||
return _enrich_config(config, defaults)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"""Admin routes for payment verification in cabinet."""
|
||||
|
||||
import logging
|
||||
import math
|
||||
from datetime import datetime
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -22,7 +22,7 @@ from app.services.payment_verification_service import (
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/payments', tags=['Cabinet Admin Payments'])
|
||||
|
||||
@@ -406,7 +406,14 @@ async def check_payment_status(
|
||||
if status_changed:
|
||||
_, new_status_text = _get_status_info(updated)
|
||||
message = f'Статус обновлён: {new_status_text}'
|
||||
logger.info(f'Admin {admin.id} checked payment {method}/{payment_id}: {old_status} -> {updated.status}')
|
||||
logger.info(
|
||||
'Admin checked payment /',
|
||||
admin_id=admin.id,
|
||||
method=method,
|
||||
payment_id=payment_id,
|
||||
old_status=old_status,
|
||||
status=updated.status,
|
||||
)
|
||||
else:
|
||||
message = 'Статус не изменился'
|
||||
|
||||
|
||||
@@ -0,0 +1,412 @@
|
||||
"""Admin routes for pinned messages in cabinet."""
|
||||
|
||||
import time
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from aiogram import Bot
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import PinnedMessage, User
|
||||
from app.services.pinned_message_service import (
|
||||
broadcast_pinned_message,
|
||||
deactivate_active_pinned_message,
|
||||
get_active_pinned_message,
|
||||
set_active_pinned_message,
|
||||
unpin_active_pinned_message,
|
||||
)
|
||||
from app.utils.validators import sanitize_html, validate_html_tags
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
from ..schemas.pinned_messages import (
|
||||
PinnedMessageBroadcastResponse,
|
||||
PinnedMessageCreateRequest,
|
||||
PinnedMessageListResponse,
|
||||
PinnedMessageResponse,
|
||||
PinnedMessageSettingsRequest,
|
||||
PinnedMessageUnpinResponse,
|
||||
PinnedMessageUpdateRequest,
|
||||
)
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/pinned-messages', tags=['Cabinet Admin Pinned Messages'])
|
||||
|
||||
# Broadcast cooldown: min 60 seconds between mass operations
|
||||
_BROADCAST_COOLDOWN_SECONDS = 60
|
||||
_last_broadcast_time: float = 0.0
|
||||
|
||||
|
||||
def _check_broadcast_cooldown() -> None:
|
||||
global _last_broadcast_time
|
||||
now = time.monotonic()
|
||||
elapsed = now - _last_broadcast_time
|
||||
if _last_broadcast_time > 0 and elapsed < _BROADCAST_COOLDOWN_SECONDS:
|
||||
remaining = int(_BROADCAST_COOLDOWN_SECONDS - elapsed)
|
||||
raise HTTPException(
|
||||
status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
f'Broadcast cooldown active. Try again in {remaining} seconds.',
|
||||
)
|
||||
_last_broadcast_time = now
|
||||
|
||||
|
||||
def _serialize_pinned_message(msg: PinnedMessage) -> PinnedMessageResponse:
|
||||
return PinnedMessageResponse(
|
||||
id=msg.id,
|
||||
content=msg.content,
|
||||
media_type=msg.media_type,
|
||||
media_file_id=msg.media_file_id,
|
||||
send_before_menu=msg.send_before_menu,
|
||||
send_on_every_start=msg.send_on_every_start,
|
||||
is_active=msg.is_active,
|
||||
created_by=msg.created_by,
|
||||
created_at=msg.created_at,
|
||||
updated_at=msg.updated_at,
|
||||
)
|
||||
|
||||
|
||||
_cached_bot: Bot | None = None
|
||||
|
||||
|
||||
def _get_bot() -> Bot:
|
||||
global _cached_bot
|
||||
if _cached_bot is None:
|
||||
_cached_bot = Bot(
|
||||
token=settings.BOT_TOKEN,
|
||||
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
|
||||
)
|
||||
return _cached_bot
|
||||
|
||||
|
||||
# ============ List / Get Endpoints ============
|
||||
|
||||
|
||||
@router.get('', response_model=PinnedMessageListResponse)
|
||||
async def list_pinned_messages(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
offset: int = Query(0, ge=0),
|
||||
active_only: bool = Query(False),
|
||||
) -> PinnedMessageListResponse:
|
||||
"""Get list of pinned messages with pagination."""
|
||||
query = select(PinnedMessage).order_by(PinnedMessage.created_at.desc())
|
||||
count_query = select(func.count(PinnedMessage.id))
|
||||
|
||||
if active_only:
|
||||
query = query.where(PinnedMessage.is_active.is_(True))
|
||||
count_query = count_query.where(PinnedMessage.is_active.is_(True))
|
||||
|
||||
total = await db.scalar(count_query) or 0
|
||||
result = await db.execute(query.offset(offset).limit(limit))
|
||||
items = result.scalars().all()
|
||||
|
||||
return PinnedMessageListResponse(
|
||||
items=[_serialize_pinned_message(msg) for msg in items],
|
||||
total=int(total),
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
|
||||
@router.get('/active', response_model=PinnedMessageResponse | None)
|
||||
async def get_active_message(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> PinnedMessageResponse | None:
|
||||
"""Get current active pinned message."""
|
||||
msg = await get_active_pinned_message(db)
|
||||
if not msg:
|
||||
return None
|
||||
return _serialize_pinned_message(msg)
|
||||
|
||||
|
||||
@router.get('/{message_id}', response_model=PinnedMessageResponse)
|
||||
async def get_pinned_message(
|
||||
message_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> PinnedMessageResponse:
|
||||
"""Get pinned message by ID."""
|
||||
result = await db.execute(select(PinnedMessage).where(PinnedMessage.id == message_id))
|
||||
msg = result.scalar_one_or_none()
|
||||
if not msg:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Pinned message not found')
|
||||
return _serialize_pinned_message(msg)
|
||||
|
||||
|
||||
# ============ Create / Update Endpoints ============
|
||||
|
||||
|
||||
@router.post('', response_model=PinnedMessageBroadcastResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_pinned_message(
|
||||
payload: PinnedMessageCreateRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> PinnedMessageBroadcastResponse:
|
||||
"""
|
||||
Create a new pinned message.
|
||||
|
||||
Automatically deactivates previous active message.
|
||||
If broadcast=true, sends to all active users immediately.
|
||||
"""
|
||||
# Проверяем cooldown ДО мутации в БД
|
||||
if payload.broadcast:
|
||||
_check_broadcast_cooldown()
|
||||
|
||||
content = payload.content.strip()
|
||||
if not content and not payload.media:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Either content or media must be provided')
|
||||
|
||||
media_type = payload.media.type if payload.media else None
|
||||
media_file_id = payload.media.file_id if payload.media else None
|
||||
|
||||
try:
|
||||
msg = await set_active_pinned_message(
|
||||
db=db,
|
||||
content=content,
|
||||
created_by=admin.id,
|
||||
media_type=media_type,
|
||||
media_file_id=media_file_id,
|
||||
send_before_menu=payload.send_before_menu,
|
||||
send_on_every_start=payload.send_on_every_start,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e))
|
||||
|
||||
sent_count = 0
|
||||
failed_count = 0
|
||||
|
||||
if payload.broadcast:
|
||||
sent_count, failed_count = await broadcast_pinned_message(_get_bot(), db, msg)
|
||||
|
||||
logger.info(
|
||||
'Admin created pinned message # (broadcast=)', admin_id=admin.id, message_id=msg.id, broadcast=payload.broadcast
|
||||
)
|
||||
|
||||
return PinnedMessageBroadcastResponse(
|
||||
message=_serialize_pinned_message(msg),
|
||||
sent_count=sent_count,
|
||||
failed_count=failed_count,
|
||||
)
|
||||
|
||||
|
||||
@router.patch('/{message_id}', response_model=PinnedMessageResponse)
|
||||
async def update_pinned_message(
|
||||
message_id: int,
|
||||
payload: PinnedMessageUpdateRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> PinnedMessageResponse:
|
||||
"""Update a pinned message content, media, or settings."""
|
||||
result = await db.execute(select(PinnedMessage).where(PinnedMessage.id == message_id))
|
||||
msg = result.scalar_one_or_none()
|
||||
if not msg:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Pinned message not found')
|
||||
|
||||
if payload.content is not None:
|
||||
sanitized = sanitize_html(payload.content)
|
||||
is_valid, error = validate_html_tags(sanitized)
|
||||
if not is_valid:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, error)
|
||||
msg.content = sanitized
|
||||
|
||||
if payload.media is not None:
|
||||
msg.media_type = payload.media.type
|
||||
msg.media_file_id = payload.media.file_id
|
||||
|
||||
if payload.send_before_menu is not None:
|
||||
msg.send_before_menu = payload.send_before_menu
|
||||
|
||||
if payload.send_on_every_start is not None:
|
||||
msg.send_on_every_start = payload.send_on_every_start
|
||||
|
||||
msg.updated_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
await db.refresh(msg)
|
||||
|
||||
logger.info('Admin updated pinned message #', admin_id=admin.id, message_id=message_id)
|
||||
|
||||
return _serialize_pinned_message(msg)
|
||||
|
||||
|
||||
@router.patch('/{message_id}/settings', response_model=PinnedMessageResponse)
|
||||
async def update_pinned_message_settings(
|
||||
message_id: int,
|
||||
payload: PinnedMessageSettingsRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> PinnedMessageResponse:
|
||||
"""Update only pinned message display settings."""
|
||||
result = await db.execute(select(PinnedMessage).where(PinnedMessage.id == message_id))
|
||||
msg = result.scalar_one_or_none()
|
||||
if not msg:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Pinned message not found')
|
||||
|
||||
if payload.send_before_menu is not None:
|
||||
msg.send_before_menu = payload.send_before_menu
|
||||
|
||||
if payload.send_on_every_start is not None:
|
||||
msg.send_on_every_start = payload.send_on_every_start
|
||||
|
||||
msg.updated_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
await db.refresh(msg)
|
||||
|
||||
return _serialize_pinned_message(msg)
|
||||
|
||||
|
||||
# ============ Active Message Actions (before /{message_id} POST routes) ============
|
||||
|
||||
|
||||
@router.post('/active/deactivate', response_model=PinnedMessageResponse | None)
|
||||
async def deactivate_active_message(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> PinnedMessageResponse | None:
|
||||
"""Deactivate the current active pinned message without unpinning from users."""
|
||||
msg = await deactivate_active_pinned_message(db)
|
||||
if not msg:
|
||||
return None
|
||||
|
||||
logger.info('Admin deactivated pinned message #', admin_id=admin.id, message_id=msg.id)
|
||||
|
||||
return _serialize_pinned_message(msg)
|
||||
|
||||
|
||||
@router.post('/active/unpin', response_model=PinnedMessageUnpinResponse)
|
||||
async def unpin_active_message(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> PinnedMessageUnpinResponse:
|
||||
"""Unpin messages from all users and deactivate the active pinned message."""
|
||||
_check_broadcast_cooldown()
|
||||
unpinned_count, failed_count, was_active = await unpin_active_pinned_message(_get_bot(), db)
|
||||
|
||||
if was_active:
|
||||
logger.info(
|
||||
'Admin unpinned active message: unpinned=, failed',
|
||||
admin_id=admin.id,
|
||||
unpinned_count=unpinned_count,
|
||||
failed_count=failed_count,
|
||||
)
|
||||
|
||||
return PinnedMessageUnpinResponse(
|
||||
unpinned_count=unpinned_count,
|
||||
failed_count=failed_count,
|
||||
was_active=was_active,
|
||||
)
|
||||
|
||||
|
||||
# ============ Per-Message Actions ============
|
||||
|
||||
|
||||
@router.post('/{message_id}/activate', response_model=PinnedMessageBroadcastResponse)
|
||||
async def activate_pinned_message(
|
||||
message_id: int,
|
||||
broadcast: bool = Query(False),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> PinnedMessageBroadcastResponse:
|
||||
"""
|
||||
Activate a pinned message.
|
||||
|
||||
Deactivates the current active message and activates the specified one.
|
||||
If broadcast=true, sends to all active users immediately.
|
||||
"""
|
||||
# Проверяем cooldown ДО мутации в БД
|
||||
if broadcast:
|
||||
_check_broadcast_cooldown()
|
||||
|
||||
result = await db.execute(select(PinnedMessage).where(PinnedMessage.id == message_id))
|
||||
msg = result.scalar_one_or_none()
|
||||
if not msg:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Pinned message not found')
|
||||
|
||||
await db.execute(
|
||||
update(PinnedMessage)
|
||||
.where(PinnedMessage.is_active.is_(True))
|
||||
.values(is_active=False, updated_at=datetime.now(UTC))
|
||||
)
|
||||
|
||||
msg.is_active = True
|
||||
msg.updated_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
await db.refresh(msg)
|
||||
|
||||
sent_count = 0
|
||||
failed_count = 0
|
||||
|
||||
if broadcast:
|
||||
sent_count, failed_count = await broadcast_pinned_message(_get_bot(), db, msg)
|
||||
|
||||
logger.info(
|
||||
'Admin activated pinned message # (broadcast=)', admin_id=admin.id, message_id=message_id, broadcast=broadcast
|
||||
)
|
||||
|
||||
return PinnedMessageBroadcastResponse(
|
||||
message=_serialize_pinned_message(msg),
|
||||
sent_count=sent_count,
|
||||
failed_count=failed_count,
|
||||
)
|
||||
|
||||
|
||||
@router.post('/{message_id}/broadcast', response_model=PinnedMessageBroadcastResponse)
|
||||
async def broadcast_message(
|
||||
message_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> PinnedMessageBroadcastResponse:
|
||||
"""Broadcast a pinned message to all active users."""
|
||||
_check_broadcast_cooldown()
|
||||
|
||||
result = await db.execute(select(PinnedMessage).where(PinnedMessage.id == message_id))
|
||||
msg = result.scalar_one_or_none()
|
||||
if not msg:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Pinned message not found')
|
||||
|
||||
sent_count, failed_count = await broadcast_pinned_message(_get_bot(), db, msg)
|
||||
|
||||
logger.info(
|
||||
'Admin broadcast pinned message #: sent=, failed',
|
||||
admin_id=admin.id,
|
||||
message_id=message_id,
|
||||
sent_count=sent_count,
|
||||
failed_count=failed_count,
|
||||
)
|
||||
|
||||
return PinnedMessageBroadcastResponse(
|
||||
message=_serialize_pinned_message(msg),
|
||||
sent_count=sent_count,
|
||||
failed_count=failed_count,
|
||||
)
|
||||
|
||||
|
||||
@router.delete('/{message_id}', status_code=status.HTTP_204_NO_CONTENT, response_model=None)
|
||||
async def delete_pinned_message(
|
||||
message_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> None:
|
||||
"""Delete a pinned message. Active messages must be deactivated first."""
|
||||
result = await db.execute(select(PinnedMessage).where(PinnedMessage.id == message_id))
|
||||
msg = result.scalar_one_or_none()
|
||||
if not msg:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Pinned message not found')
|
||||
|
||||
if msg.is_active:
|
||||
raise HTTPException(
|
||||
status.HTTP_409_CONFLICT,
|
||||
'Cannot delete active pinned message. Deactivate it first.',
|
||||
)
|
||||
|
||||
await db.delete(msg)
|
||||
await db.commit()
|
||||
|
||||
logger.info('Admin deleted pinned message #', admin_id=admin.id, message_id=message_id)
|
||||
@@ -3,10 +3,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
from aiogram import Bot
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
@@ -37,7 +37,7 @@ from app.utils.miniapp_buttons import build_miniapp_or_callback_button
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/promo-offers', tags=['Admin Promo Offers'])
|
||||
|
||||
@@ -430,7 +430,7 @@ async def _send_promo_notifications(
|
||||
async def send_single(user: User, offer: DiscountOffer) -> bool:
|
||||
# Skip email-only users (no telegram_id)
|
||||
if not user.telegram_id:
|
||||
logger.debug(f'Skipping promo notification for email-only user {user.id}')
|
||||
logger.debug('Skipping promo notification for email-only user', user_id=user.id)
|
||||
return False
|
||||
|
||||
async with semaphore:
|
||||
@@ -459,18 +459,10 @@ async def _send_promo_notifications(
|
||||
)
|
||||
return True
|
||||
except (TelegramForbiddenError, TelegramBadRequest) as exc:
|
||||
logger.warning(
|
||||
'Failed to send promo notification to user %s: %s',
|
||||
user.telegram_id,
|
||||
exc,
|
||||
)
|
||||
logger.warning('Failed to send promo notification to user', telegram_id=user.telegram_id, exc=exc)
|
||||
return False
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
'Error sending promo notification to user %s: %s',
|
||||
user.telegram_id,
|
||||
exc,
|
||||
)
|
||||
logger.error('Error sending promo notification to user', telegram_id=user.telegram_id, exc=exc)
|
||||
return False
|
||||
|
||||
# Send in batches
|
||||
@@ -575,9 +567,24 @@ async def broadcast_offer(
|
||||
notifications_failed = 0
|
||||
|
||||
if payload.send_notification and offers_to_notify:
|
||||
# Render placeholders in custom message text
|
||||
rendered_message_text = payload.message_text
|
||||
if rendered_message_text:
|
||||
extra = payload.extra_data or {}
|
||||
try:
|
||||
rendered_message_text = rendered_message_text.format(
|
||||
discount_percent=payload.discount_percent,
|
||||
valid_hours=payload.valid_hours,
|
||||
active_discount_hours=extra.get('active_discount_hours') or payload.valid_hours,
|
||||
test_duration_hours=extra.get('test_duration_hours') or 0,
|
||||
server_name=extra.get('server_name', ''),
|
||||
)
|
||||
except (KeyError, ValueError, IndexError):
|
||||
logger.warning('Failed to render promo message placeholders')
|
||||
|
||||
notifications_sent, notifications_failed = await _send_promo_notifications(
|
||||
offers_to_notify=offers_to_notify,
|
||||
message_text=payload.message_text,
|
||||
message_text=rendered_message_text,
|
||||
button_text=payload.button_text,
|
||||
discount_percent=payload.discount_percent,
|
||||
bonus_amount_kopeks=payload.bonus_amount_kopeks,
|
||||
|
||||
@@ -162,9 +162,9 @@ def _normalize_datetime(value: datetime | None) -> datetime | None:
|
||||
if value is None:
|
||||
return None
|
||||
if value.tzinfo is not None and value.utcoffset() is not None:
|
||||
return value.astimezone(UTC).replace(tzinfo=None)
|
||||
return value.astimezone(UTC)
|
||||
if value.tzinfo is not None:
|
||||
return value.replace(tzinfo=None)
|
||||
return value
|
||||
return value
|
||||
|
||||
|
||||
@@ -363,13 +363,16 @@ async def create_promocode_endpoint(
|
||||
if existing:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Promo code with this code already exists')
|
||||
|
||||
# 0 means unlimited — convert to large number for is_valid check (current_uses < max_uses)
|
||||
effective_max_uses = 999999 if payload.max_uses == 0 else payload.max_uses
|
||||
|
||||
promocode = await create_promocode(
|
||||
db,
|
||||
code=normalized_code,
|
||||
type=payload.type,
|
||||
balance_bonus_kopeks=payload.balance_bonus_kopeks,
|
||||
subscription_days=payload.subscription_days,
|
||||
max_uses=payload.max_uses,
|
||||
max_uses=effective_max_uses,
|
||||
valid_until=normalized_valid_until,
|
||||
created_by=admin.id,
|
||||
)
|
||||
@@ -426,7 +429,7 @@ async def update_promocode_endpoint(
|
||||
updates['subscription_days'] = payload.subscription_days
|
||||
|
||||
if payload.max_uses is not None:
|
||||
updates['max_uses'] = payload.max_uses
|
||||
updates['max_uses'] = 999999 if payload.max_uses == 0 else payload.max_uses
|
||||
|
||||
if payload.valid_from is not None:
|
||||
updates['valid_from'] = _normalize_datetime(payload.valid_from)
|
||||
@@ -472,6 +475,61 @@ async def delete_promocode_endpoint(
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
class DeactivateDiscountResponse(BaseModel):
|
||||
success: bool
|
||||
message: str
|
||||
deactivated_code: str | None = None
|
||||
discount_percent: int = 0
|
||||
user_id: int
|
||||
|
||||
|
||||
@router.post('/deactivate-discount/{user_id}', response_model=DeactivateDiscountResponse)
|
||||
async def admin_deactivate_discount_promocode(
|
||||
user_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> DeactivateDiscountResponse:
|
||||
"""Admin: deactivate a user's active discount promo code."""
|
||||
from app.database.crud.user import get_user_by_id as get_user
|
||||
|
||||
target_user = await get_user(db, user_id)
|
||||
if not target_user:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, 'User not found')
|
||||
|
||||
from app.services.promocode_service import PromoCodeService
|
||||
|
||||
service = PromoCodeService()
|
||||
result = await service.deactivate_discount_promocode(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
admin_initiated=True,
|
||||
)
|
||||
|
||||
if result['success']:
|
||||
return DeactivateDiscountResponse(
|
||||
success=True,
|
||||
message=f'Discount promo code deactivated for user {user_id}',
|
||||
deactivated_code=result.get('deactivated_code'),
|
||||
discount_percent=result.get('discount_percent', 0),
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
error_messages = {
|
||||
'user_not_found': 'User not found',
|
||||
'no_active_discount_promocode': 'User has no active discount from a promo code',
|
||||
'discount_already_expired': 'Discount has already expired (cleaned up)',
|
||||
'server_error': 'Server error occurred',
|
||||
}
|
||||
|
||||
error_code = result.get('error', 'server_error')
|
||||
error_message = error_messages.get(error_code, 'Failed to deactivate promo code')
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=error_message,
|
||||
)
|
||||
|
||||
|
||||
# ============== PromoGroup Endpoints ==============
|
||||
|
||||
promo_groups_router = APIRouter(prefix='/admin/promo-groups', tags=['Admin Promo Groups'])
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"""Admin routes for RemnaWave management in cabinet."""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -76,7 +76,7 @@ except Exception:
|
||||
remnawave_sync_service = None
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/remnawave', tags=['Cabinet Admin RemnaWave'])
|
||||
|
||||
@@ -109,7 +109,10 @@ def _parse_datetime(value: Any) -> datetime | None:
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
return datetime.fromisoformat(value)
|
||||
parsed = datetime.fromisoformat(value)
|
||||
if parsed.tzinfo is None:
|
||||
return parsed.replace(tzinfo=UTC)
|
||||
return parsed
|
||||
except ValueError:
|
||||
return None
|
||||
return None
|
||||
@@ -338,7 +341,7 @@ async def get_node_usage(
|
||||
service = _get_service()
|
||||
_ensure_configured(service)
|
||||
|
||||
end_dt = end or datetime.utcnow()
|
||||
end_dt = end or datetime.now(UTC)
|
||||
start_dt = start or (end_dt - timedelta(days=7))
|
||||
|
||||
if start_dt >= end_dt:
|
||||
@@ -380,7 +383,9 @@ async def perform_node_action(
|
||||
}
|
||||
|
||||
if success:
|
||||
logger.info(f'Admin {admin.telegram_id} performed {payload.action} on node {node_uuid}')
|
||||
logger.info(
|
||||
'Admin performed on node', telegram_id=admin.telegram_id, action=payload.action, node_uuid=node_uuid
|
||||
)
|
||||
return NodeActionResponse(
|
||||
success=True,
|
||||
message=messages.get(payload.action, 'Action completed'),
|
||||
@@ -403,7 +408,7 @@ async def restart_all_nodes(
|
||||
success = await service.restart_all_nodes()
|
||||
|
||||
if success:
|
||||
logger.info(f'Admin {admin.telegram_id} restarted all nodes')
|
||||
logger.info('Admin restarted all nodes', telegram_id=admin.telegram_id)
|
||||
return NodeActionResponse(success=True, message='All nodes restart initiated')
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
@@ -510,7 +515,9 @@ async def create_squad(
|
||||
squad_uuid = await service.create_squad(payload.name, payload.inbound_uuids)
|
||||
|
||||
if squad_uuid:
|
||||
logger.info(f'Admin {admin.telegram_id} created squad {payload.name} ({squad_uuid})')
|
||||
logger.info(
|
||||
'Admin created squad', telegram_id=admin.telegram_id, payload_name=payload.name, squad_uuid=squad_uuid
|
||||
)
|
||||
return SquadOperationResponse(
|
||||
success=True,
|
||||
message='Squad created successfully',
|
||||
@@ -545,7 +552,7 @@ async def update_squad(
|
||||
)
|
||||
|
||||
if success:
|
||||
logger.info(f'Admin {admin.telegram_id} updated squad {squad_uuid}')
|
||||
logger.info('Admin updated squad', telegram_id=admin.telegram_id, squad_uuid=squad_uuid)
|
||||
return SquadOperationResponse(success=True, message='Squad updated')
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
@@ -594,7 +601,7 @@ async def perform_squad_action(
|
||||
message = 'Inbounds updated' if success else 'Failed to update inbounds'
|
||||
|
||||
if success:
|
||||
logger.info(f'Admin {admin.telegram_id} performed {action} on squad {squad_uuid}')
|
||||
logger.info('Admin performed on squad', telegram_id=admin.telegram_id, action=action, squad_uuid=squad_uuid)
|
||||
|
||||
return SquadOperationResponse(success=success, message=message)
|
||||
|
||||
@@ -611,7 +618,7 @@ async def delete_squad(
|
||||
success = await service.delete_squad(squad_uuid)
|
||||
|
||||
if success:
|
||||
logger.info(f'Admin {admin.telegram_id} deleted squad {squad_uuid}')
|
||||
logger.info('Admin deleted squad', telegram_id=admin.telegram_id, squad_uuid=squad_uuid)
|
||||
return SquadOperationResponse(success=True, message='Squad deleted')
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
@@ -699,7 +706,9 @@ async def migrate_squad_users(
|
||||
error=result.get('error'),
|
||||
)
|
||||
|
||||
logger.info(f'Admin {admin.telegram_id} migrated users from {source_uuid} to {target_uuid}')
|
||||
logger.info(
|
||||
'Admin migrated users from to', telegram_id=admin.telegram_id, source_uuid=source_uuid, target_uuid=target_uuid
|
||||
)
|
||||
|
||||
return MigrationResponse(
|
||||
success=True,
|
||||
@@ -782,14 +791,14 @@ async def toggle_auto_sync(
|
||||
if payload.enabled and not current_status.enabled:
|
||||
# Enable - would need to update settings and refresh schedule
|
||||
remnawave_sync_service.schedule_refresh(run_immediately=True)
|
||||
logger.info(f'Admin {admin.telegram_id} enabled auto sync')
|
||||
logger.info('Admin enabled auto sync', telegram_id=admin.telegram_id)
|
||||
return SyncResponse(
|
||||
success=True,
|
||||
message='Auto sync enabled and scheduled',
|
||||
)
|
||||
if not payload.enabled and current_status.enabled:
|
||||
# Disable - would need to update settings and stop scheduler
|
||||
logger.info(f'Admin {admin.telegram_id} disabled auto sync')
|
||||
logger.info('Admin disabled auto sync', telegram_id=admin.telegram_id)
|
||||
return SyncResponse(
|
||||
success=True,
|
||||
message='Auto sync setting change requested. Restart may be required.',
|
||||
@@ -811,7 +820,7 @@ async def run_auto_sync_now(
|
||||
detail='Auto sync service is not available',
|
||||
)
|
||||
|
||||
logger.info(f'Admin {admin.telegram_id} triggered manual sync')
|
||||
logger.info('Admin triggered manual sync', telegram_id=admin.telegram_id)
|
||||
result = await remnawave_sync_service.run_sync_now(reason='manual')
|
||||
|
||||
return AutoSyncRunResponse(
|
||||
@@ -839,7 +848,7 @@ async def sync_from_panel(
|
||||
|
||||
try:
|
||||
stats = await service.sync_users_from_panel(db, payload.mode)
|
||||
logger.info(f'Admin {admin.telegram_id} synced from panel (mode: {payload.mode})')
|
||||
logger.info('Admin synced from panel (mode: )', telegram_id=admin.telegram_id, mode=payload.mode)
|
||||
return SyncResponse(
|
||||
success=True,
|
||||
message='Sync from panel completed',
|
||||
@@ -862,7 +871,7 @@ async def sync_to_panel(
|
||||
_ensure_configured(service)
|
||||
|
||||
stats = await service.sync_users_to_panel(db)
|
||||
logger.info(f'Admin {admin.telegram_id} synced to panel')
|
||||
logger.info('Admin synced to panel', telegram_id=admin.telegram_id)
|
||||
|
||||
return SyncResponse(
|
||||
success=True,
|
||||
@@ -892,9 +901,15 @@ async def sync_servers(
|
||||
try:
|
||||
await cache.delete_pattern('available_countries*')
|
||||
except Exception as e:
|
||||
logger.warning(f'Failed to clear countries cache: {e}')
|
||||
logger.warning('Failed to clear countries cache', error=e)
|
||||
|
||||
logger.info(f'Admin {admin.telegram_id} synced servers: created={created}, updated={updated}, removed={removed}')
|
||||
logger.info(
|
||||
'Admin synced servers: created=, updated=, removed',
|
||||
telegram_id=admin.telegram_id,
|
||||
created=created,
|
||||
updated=updated,
|
||||
removed=removed,
|
||||
)
|
||||
|
||||
return SyncResponse(
|
||||
success=True,
|
||||
@@ -918,7 +933,7 @@ async def validate_subscriptions(
|
||||
_ensure_configured(service)
|
||||
|
||||
stats = await service.validate_and_fix_subscriptions(db)
|
||||
logger.info(f'Admin {admin.telegram_id} validated subscriptions')
|
||||
logger.info('Admin validated subscriptions', telegram_id=admin.telegram_id)
|
||||
|
||||
return SyncResponse(
|
||||
success=True,
|
||||
@@ -937,7 +952,7 @@ async def cleanup_subscriptions(
|
||||
_ensure_configured(service)
|
||||
|
||||
stats = await service.cleanup_orphaned_subscriptions(db)
|
||||
logger.info(f'Admin {admin.telegram_id} cleaned up subscriptions')
|
||||
logger.info('Admin cleaned up subscriptions', telegram_id=admin.telegram_id)
|
||||
|
||||
return SyncResponse(
|
||||
success=True,
|
||||
@@ -956,7 +971,7 @@ async def sync_subscription_statuses(
|
||||
_ensure_configured(service)
|
||||
|
||||
stats = await service.sync_subscription_statuses(db)
|
||||
logger.info(f'Admin {admin.telegram_id} synced subscription statuses')
|
||||
logger.info('Admin synced subscription statuses', telegram_id=admin.telegram_id)
|
||||
|
||||
return SyncResponse(
|
||||
success=True,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Admin routes for managing servers in cabinet."""
|
||||
|
||||
import logging
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import String, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -31,7 +30,7 @@ from ..schemas.servers import (
|
||||
)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/servers', tags=['Cabinet Admin Servers'])
|
||||
|
||||
@@ -184,7 +183,7 @@ async def update_existing_server(
|
||||
if request.promo_group_ids is not None:
|
||||
await update_server_squad_promo_groups(db, server_id, request.promo_group_ids)
|
||||
|
||||
logger.info(f'Admin {admin.id} updated server {server_id}')
|
||||
logger.info('Admin updated server', admin_id=admin.id, server_id=server_id)
|
||||
|
||||
return await get_server(server_id, admin, db)
|
||||
|
||||
@@ -207,7 +206,7 @@ async def toggle_server(
|
||||
await update_server_squad(db, server_id, is_available=new_status)
|
||||
|
||||
status_text = 'enabled' if new_status else 'disabled'
|
||||
logger.info(f'Admin {admin.id} {status_text} server {server_id}')
|
||||
logger.info('Admin server', admin_id=admin.id, status_text=status_text, server_id=server_id)
|
||||
|
||||
return ServerToggleResponse(
|
||||
id=server_id,
|
||||
@@ -234,7 +233,7 @@ async def toggle_server_trial(
|
||||
await update_server_squad(db, server_id, is_trial_eligible=new_status)
|
||||
|
||||
status_text = 'enabled for trial' if new_status else 'disabled for trial'
|
||||
logger.info(f'Admin {admin.id} {status_text} server {server_id}')
|
||||
logger.info('Admin server', admin_id=admin.id, status_text=status_text, server_id=server_id)
|
||||
|
||||
return ServerTrialToggleResponse(
|
||||
id=server_id,
|
||||
@@ -311,7 +310,7 @@ async def sync_servers(
|
||||
# Sync with database
|
||||
created, updated, removed = await sync_with_remnawave(db, squads)
|
||||
|
||||
logger.info(f'Admin {admin.id} synced servers: +{created} ~{updated} -{removed}')
|
||||
logger.info('Admin synced servers: + ~', admin_id=admin.id, created=created, updated=updated, removed=removed)
|
||||
|
||||
return ServerSyncResponse(
|
||||
created=created,
|
||||
@@ -323,7 +322,7 @@ async def sync_servers(
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to sync servers: {e}')
|
||||
logger.error('Failed to sync servers', error=e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f'Sync failed: {e!s}',
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""Admin settings routes for cabinet - system configuration management."""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -16,7 +16,7 @@ from app.services.system_settings_service import (
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/settings', tags=['Admin Settings'])
|
||||
|
||||
@@ -248,7 +248,7 @@ async def update_setting(
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, str(error)) from error
|
||||
await db.commit()
|
||||
|
||||
logger.info(f'Admin {admin.telegram_id} updated setting {key} to {value}')
|
||||
logger.info('Admin updated setting to', telegram_id=admin.telegram_id, key=key, value=value)
|
||||
return _serialize_definition(definition)
|
||||
|
||||
|
||||
@@ -270,5 +270,5 @@ async def reset_setting(
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, str(error)) from error
|
||||
await db.commit()
|
||||
|
||||
logger.info(f'Admin {admin.telegram_id} reset setting {key}')
|
||||
logger.info('Admin reset setting', telegram_id=admin.telegram_id, key=key)
|
||||
return _serialize_definition(definition)
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"""Admin routes for statistics dashboard in cabinet."""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
import sys
|
||||
import time
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import and_, func, select
|
||||
@@ -22,11 +24,14 @@ from app.database.models import (
|
||||
User,
|
||||
)
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
from app.services.version_service import version_service
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
_start_time = time.time()
|
||||
|
||||
router = APIRouter(prefix='/admin/stats', tags=['Cabinet Admin Stats'])
|
||||
|
||||
@@ -142,6 +147,16 @@ class DashboardStats(BaseModel):
|
||||
tariff_stats: TariffStats | None = None
|
||||
|
||||
|
||||
class SystemInfoResponse(BaseModel):
|
||||
"""System information for admin dashboard."""
|
||||
|
||||
bot_version: str
|
||||
python_version: str
|
||||
uptime_seconds: int
|
||||
users_total: int
|
||||
subscriptions_active: int
|
||||
|
||||
|
||||
# ============ Extended Stats Schemas ============
|
||||
|
||||
|
||||
@@ -243,7 +258,7 @@ async def get_dashboard_stats(
|
||||
sub_stats = await get_subscriptions_statistics(db)
|
||||
|
||||
# Get financial statistics
|
||||
now = datetime.utcnow()
|
||||
now = datetime.now(UTC)
|
||||
month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
trans_stats = await get_transactions_statistics(db, month_start, now)
|
||||
@@ -302,13 +317,45 @@ async def get_dashboard_stats(
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to get dashboard stats: {e}')
|
||||
logger.error('Failed to get dashboard stats', error=e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to load dashboard statistics',
|
||||
)
|
||||
|
||||
|
||||
@router.get('/system-info', response_model=SystemInfoResponse)
|
||||
async def get_system_info(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get system information for admin dashboard."""
|
||||
try:
|
||||
users_total_result = await db.execute(select(func.count()).select_from(User))
|
||||
users_total = users_total_result.scalar() or 0
|
||||
|
||||
subs_active_result = await db.execute(
|
||||
select(func.count(Subscription.id)).where(
|
||||
Subscription.status == SubscriptionStatus.ACTIVE.value,
|
||||
)
|
||||
)
|
||||
subscriptions_active = subs_active_result.scalar() or 0
|
||||
|
||||
return SystemInfoResponse(
|
||||
bot_version=version_service.current_version,
|
||||
python_version=sys.version.split()[0],
|
||||
uptime_seconds=int(time.time() - _start_time),
|
||||
users_total=users_total,
|
||||
subscriptions_active=subscriptions_active,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error('Failed to get system info', error=e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to load system information',
|
||||
)
|
||||
|
||||
|
||||
@router.get('/nodes', response_model=NodesOverview)
|
||||
async def get_nodes_status(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
@@ -317,7 +364,7 @@ async def get_nodes_status(
|
||||
try:
|
||||
return await _get_nodes_overview()
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to get nodes status: {e}')
|
||||
logger.error('Failed to get nodes status', error=e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to load nodes status',
|
||||
@@ -335,7 +382,7 @@ async def restart_node(
|
||||
success = await service.manage_node(node_uuid, 'restart')
|
||||
|
||||
if success:
|
||||
logger.info(f'Admin {admin.id} restarted node {node_uuid}')
|
||||
logger.info('Admin restarted node', admin_id=admin.id, node_uuid=node_uuid)
|
||||
return {'success': True, 'message': 'Node restart initiated'}
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
@@ -344,7 +391,7 @@ async def restart_node(
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to restart node {node_uuid}: {e}')
|
||||
logger.error('Failed to restart node', node_uuid=node_uuid, error=e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to restart node',
|
||||
@@ -373,7 +420,7 @@ async def toggle_node(
|
||||
success = await service.manage_node(node_uuid, action)
|
||||
|
||||
if success:
|
||||
logger.info(f'Admin {admin.id} {action}d node {node_uuid}')
|
||||
logger.info('Admin d node', admin_id=admin.id, action=action, node_uuid=node_uuid)
|
||||
return {'success': True, 'message': f'Node {action}d', 'is_disabled': not is_disabled}
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
@@ -382,7 +429,7 @@ async def toggle_node(
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to toggle node {node_uuid}: {e}')
|
||||
logger.error('Failed to toggle node', node_uuid=node_uuid, error=e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to toggle node',
|
||||
@@ -433,7 +480,7 @@ async def _get_nodes_overview() -> NodesOverview:
|
||||
nodes=node_statuses,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f'Failed to get nodes from RemnaWave: {e}')
|
||||
logger.warning('Failed to get nodes from RemnaWave', error=e)
|
||||
# Return empty data if RemnaWave is unavailable
|
||||
return NodesOverview(
|
||||
total=0,
|
||||
@@ -456,7 +503,7 @@ async def _get_tariff_stats(db: AsyncSession) -> TariffStats | None:
|
||||
logger.info('📊 Нет тарифов в системе, пропускаем статистику')
|
||||
return None
|
||||
|
||||
now = datetime.utcnow()
|
||||
now = datetime.now(UTC)
|
||||
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
week_ago = now - timedelta(days=7)
|
||||
month_ago = now - timedelta(days=30)
|
||||
@@ -513,7 +560,9 @@ async def _get_tariff_stats(db: AsyncSession) -> TariffStats | None:
|
||||
)
|
||||
purchased_month = month_result.scalar() or 0
|
||||
|
||||
logger.info(f"📊 Тариф '{tariff.name}': активных={active_count}, триал={trial_count}")
|
||||
logger.info(
|
||||
'📊 Тариф активных=, триал', tariff_name=tariff.name, active_count=active_count, trial_count=trial_count
|
||||
)
|
||||
|
||||
tariff_items.append(
|
||||
TariffStatItem(
|
||||
@@ -529,7 +578,7 @@ async def _get_tariff_stats(db: AsyncSession) -> TariffStats | None:
|
||||
|
||||
total_tariff_subscriptions += active_count
|
||||
|
||||
logger.info(f'📊 Всего подписок по тарифам: {total_tariff_subscriptions}')
|
||||
logger.info('📊 Всего подписок по тарифам', total_tariff_subscriptions=total_tariff_subscriptions)
|
||||
|
||||
return TariffStats(
|
||||
tariffs=tariff_items,
|
||||
@@ -537,7 +586,7 @@ async def _get_tariff_stats(db: AsyncSession) -> TariffStats | None:
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to get tariff stats: {e}', exc_info=True)
|
||||
logger.error('Failed to get tariff stats', error=e, exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
@@ -552,7 +601,7 @@ async def get_top_referrers(
|
||||
):
|
||||
"""Get top referrers with earnings breakdown by period."""
|
||||
try:
|
||||
now = datetime.utcnow()
|
||||
now = datetime.now(UTC)
|
||||
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
week_ago = now - timedelta(days=7)
|
||||
month_ago = now - timedelta(days=30)
|
||||
@@ -753,7 +802,7 @@ async def get_top_referrers(
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to get top referrers: {e}', exc_info=True)
|
||||
logger.error('Failed to get top referrers', error=e, exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to load referrers statistics',
|
||||
@@ -810,7 +859,7 @@ async def get_top_campaigns(
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to get top campaigns: {e}', exc_info=True)
|
||||
logger.error('Failed to get top campaigns', error=e, exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to load campaigns statistics',
|
||||
@@ -825,7 +874,7 @@ async def get_recent_payments(
|
||||
):
|
||||
"""Get recent payments with user info."""
|
||||
try:
|
||||
now = datetime.utcnow()
|
||||
now = datetime.now(UTC)
|
||||
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
week_ago = now - timedelta(days=7)
|
||||
|
||||
@@ -949,7 +998,7 @@ async def get_recent_payments(
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to get recent payments: {e}', exc_info=True)
|
||||
logger.error('Failed to get recent payments', error=e, exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to load recent payments',
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Admin routes for managing tariffs in cabinet."""
|
||||
|
||||
import logging
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -14,6 +13,7 @@ from app.database.crud.tariff import (
|
||||
get_tariff_by_id,
|
||||
get_tariff_subscriptions_count,
|
||||
load_period_prices_from_db,
|
||||
reorder_tariffs,
|
||||
set_tariff_promo_groups,
|
||||
update_tariff,
|
||||
)
|
||||
@@ -29,6 +29,7 @@ from ..schemas.tariffs import (
|
||||
TariffDetailResponse,
|
||||
TariffListItem,
|
||||
TariffListResponse,
|
||||
TariffSortOrderRequest,
|
||||
TariffStatsResponse,
|
||||
TariffToggleResponse,
|
||||
TariffTrialResponse,
|
||||
@@ -36,7 +37,7 @@ from ..schemas.tariffs import (
|
||||
)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/tariffs', tags=['Cabinet Admin Tariffs'])
|
||||
|
||||
@@ -157,6 +158,21 @@ async def get_available_servers(
|
||||
]
|
||||
|
||||
|
||||
@router.put('/order')
|
||||
async def update_tariff_order(
|
||||
request: TariffSortOrderRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update the display order of tariffs."""
|
||||
await reorder_tariffs(db, request.tariff_ids)
|
||||
await db.commit()
|
||||
|
||||
logger.info('Admin updated tariff order', admin_id=admin.id, tariff_ids=request.tariff_ids)
|
||||
|
||||
return {'message': 'Tariff order updated successfully'}
|
||||
|
||||
|
||||
@router.get('/{tariff_id}', response_model=TariffDetailResponse)
|
||||
async def get_tariff(
|
||||
tariff_id: int,
|
||||
@@ -278,7 +294,7 @@ async def create_new_tariff(
|
||||
traffic_reset_mode=request.traffic_reset_mode,
|
||||
)
|
||||
|
||||
logger.info(f'Admin {admin.id} created tariff {tariff.id}: {tariff.name}')
|
||||
logger.info('Admin created tariff', admin_id=admin.id, tariff_id=tariff.id, tariff_name=tariff.name)
|
||||
|
||||
# Перезагружаем периоды из БД для синхронизации с ботом
|
||||
await load_period_prices_from_db(db)
|
||||
@@ -371,9 +387,9 @@ async def update_existing_tariff(
|
||||
|
||||
# Update promo groups separately
|
||||
if request.promo_group_ids is not None:
|
||||
await set_tariff_promo_groups(db, tariff_id, request.promo_group_ids)
|
||||
await set_tariff_promo_groups(db, tariff, request.promo_group_ids)
|
||||
|
||||
logger.info(f'Admin {admin.id} updated tariff {tariff_id}')
|
||||
logger.info('Admin updated tariff', admin_id=admin.id, tariff_id=tariff_id)
|
||||
|
||||
# Перезагружаем периоды из БД для синхронизации с ботом
|
||||
await load_period_prices_from_db(db)
|
||||
@@ -395,21 +411,20 @@ async def delete_existing_tariff(
|
||||
detail='Tariff not found',
|
||||
)
|
||||
|
||||
# Check if tariff has subscriptions
|
||||
subs_count = await get_tariff_subscriptions_count(db, tariff_id)
|
||||
if subs_count > 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Cannot delete tariff with {subs_count} active subscriptions',
|
||||
)
|
||||
|
||||
await delete_tariff(db, tariff)
|
||||
logger.info(f'Admin {admin.id} deleted tariff {tariff_id}: {tariff.name}')
|
||||
logger.info(
|
||||
'Admin deleted tariff (affected subscriptions: )',
|
||||
admin_id=admin.id,
|
||||
tariff_id=tariff_id,
|
||||
tariff_name=tariff.name,
|
||||
subs_count=subs_count,
|
||||
)
|
||||
|
||||
# Перезагружаем периоды из БД для синхронизации с ботом
|
||||
await load_period_prices_from_db(db)
|
||||
|
||||
return {'message': 'Tariff deleted successfully'}
|
||||
return {'message': 'Tariff deleted successfully', 'affected_subscriptions': subs_count}
|
||||
|
||||
|
||||
@router.post('/{tariff_id}/toggle', response_model=TariffToggleResponse)
|
||||
@@ -430,7 +445,7 @@ async def toggle_tariff(
|
||||
await update_tariff(db, tariff, is_active=new_status)
|
||||
|
||||
status_text = 'activated' if new_status else 'deactivated'
|
||||
logger.info(f'Admin {admin.id} {status_text} tariff {tariff_id}')
|
||||
logger.info('Admin tariff', admin_id=admin.id, status_text=status_text, tariff_id=tariff_id)
|
||||
|
||||
# Перезагружаем периоды из БД для синхронизации с ботом
|
||||
await load_period_prices_from_db(db)
|
||||
@@ -473,7 +488,7 @@ async def toggle_trial_tariff(
|
||||
await update_tariff(db, tariff, is_trial_available=new_status)
|
||||
|
||||
status_text = 'set as trial' if new_status else 'removed from trial'
|
||||
logger.info(f'Admin {admin.id} {status_text} tariff {tariff_id}')
|
||||
logger.info('Admin tariff', admin_id=admin.id, status_text=status_text, tariff_id=tariff_id)
|
||||
|
||||
return TariffTrialResponse(
|
||||
id=tariff_id,
|
||||
@@ -520,7 +535,7 @@ async def get_tariff_stats(
|
||||
|
||||
# Calculate revenue from subscription payments for users on this tariff
|
||||
revenue_result = await db.execute(
|
||||
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0))
|
||||
select(func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0))
|
||||
.join(Subscription, Transaction.user_id == Subscription.user_id)
|
||||
.where(
|
||||
Subscription.tariff_id == tariff_id,
|
||||
|
||||
+126
-121
@@ -1,9 +1,9 @@
|
||||
"""Admin tickets routes for cabinet."""
|
||||
|
||||
import logging
|
||||
import math
|
||||
from datetime import datetime
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import desc, func, select
|
||||
@@ -20,7 +20,7 @@ from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
from ..schemas.tickets import TicketMessageResponse
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/tickets', tags=['Cabinet Admin Tickets'])
|
||||
|
||||
@@ -220,12 +220,123 @@ async def get_ticket_stats(
|
||||
)
|
||||
|
||||
|
||||
@router.get('/settings', response_model=TicketSettingsResponse)
|
||||
async def get_ticket_settings(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get ticket system settings."""
|
||||
from app.services.support_settings_service import SupportSettingsService
|
||||
|
||||
return TicketSettingsResponse(
|
||||
sla_enabled=settings.SUPPORT_TICKET_SLA_ENABLED,
|
||||
sla_minutes=settings.SUPPORT_TICKET_SLA_MINUTES,
|
||||
sla_check_interval_seconds=settings.SUPPORT_TICKET_SLA_CHECK_INTERVAL_SECONDS,
|
||||
sla_reminder_cooldown_minutes=settings.SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES,
|
||||
support_system_mode=settings.get_support_system_mode(),
|
||||
cabinet_user_notifications_enabled=SupportSettingsService.get_cabinet_user_notifications_enabled(),
|
||||
cabinet_admin_notifications_enabled=SupportSettingsService.get_cabinet_admin_notifications_enabled(),
|
||||
)
|
||||
|
||||
|
||||
@router.patch('/settings', response_model=TicketSettingsResponse)
|
||||
async def update_ticket_settings(
|
||||
request: TicketSettingsUpdateRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update ticket system settings."""
|
||||
from pathlib import Path
|
||||
|
||||
from app.services.support_settings_service import SupportSettingsService
|
||||
|
||||
# Validate support_system_mode
|
||||
if request.support_system_mode is not None:
|
||||
mode = request.support_system_mode.strip().lower()
|
||||
if mode not in {'tickets', 'contact', 'both'}:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Invalid support_system_mode. Must be: tickets, contact, or both',
|
||||
)
|
||||
|
||||
# Update in-memory settings
|
||||
if request.sla_enabled is not None:
|
||||
settings.SUPPORT_TICKET_SLA_ENABLED = request.sla_enabled
|
||||
if request.sla_minutes is not None:
|
||||
settings.SUPPORT_TICKET_SLA_MINUTES = request.sla_minutes
|
||||
if request.sla_check_interval_seconds is not None:
|
||||
settings.SUPPORT_TICKET_SLA_CHECK_INTERVAL_SECONDS = request.sla_check_interval_seconds
|
||||
if request.sla_reminder_cooldown_minutes is not None:
|
||||
settings.SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES = request.sla_reminder_cooldown_minutes
|
||||
if request.support_system_mode is not None:
|
||||
SupportSettingsService.set_system_mode(request.support_system_mode.strip().lower())
|
||||
|
||||
# Update cabinet notification settings
|
||||
if request.cabinet_user_notifications_enabled is not None:
|
||||
SupportSettingsService.set_cabinet_user_notifications_enabled(request.cabinet_user_notifications_enabled)
|
||||
if request.cabinet_admin_notifications_enabled is not None:
|
||||
SupportSettingsService.set_cabinet_admin_notifications_enabled(request.cabinet_admin_notifications_enabled)
|
||||
|
||||
# Try to persist to .env file
|
||||
try:
|
||||
env_file = Path('.env')
|
||||
if env_file.exists():
|
||||
lines = env_file.read_text().splitlines()
|
||||
updates = {}
|
||||
|
||||
if request.sla_enabled is not None:
|
||||
updates['SUPPORT_TICKET_SLA_ENABLED'] = str(request.sla_enabled).lower()
|
||||
if request.sla_minutes is not None:
|
||||
updates['SUPPORT_TICKET_SLA_MINUTES'] = str(request.sla_minutes)
|
||||
if request.sla_check_interval_seconds is not None:
|
||||
updates['SUPPORT_TICKET_SLA_CHECK_INTERVAL_SECONDS'] = str(request.sla_check_interval_seconds)
|
||||
if request.sla_reminder_cooldown_minutes is not None:
|
||||
updates['SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES'] = str(request.sla_reminder_cooldown_minutes)
|
||||
if request.support_system_mode is not None:
|
||||
updates['SUPPORT_SYSTEM_MODE'] = request.support_system_mode.strip().lower()
|
||||
|
||||
new_lines = []
|
||||
updated_keys = set()
|
||||
|
||||
for line in lines:
|
||||
updated = False
|
||||
for key, value in updates.items():
|
||||
if line.startswith(f'{key}='):
|
||||
new_lines.append(f'{key}={value}')
|
||||
updated_keys.add(key)
|
||||
updated = True
|
||||
break
|
||||
if not updated:
|
||||
new_lines.append(line)
|
||||
|
||||
# Add any keys that weren't found
|
||||
for key, value in updates.items():
|
||||
if key not in updated_keys:
|
||||
new_lines.append(f'{key}={value}')
|
||||
|
||||
env_file.write_text('\n'.join(new_lines) + '\n')
|
||||
logger.info('Updated ticket settings in .env file')
|
||||
except Exception as e:
|
||||
logger.warning('Failed to update .env file', error=e)
|
||||
|
||||
return TicketSettingsResponse(
|
||||
sla_enabled=settings.SUPPORT_TICKET_SLA_ENABLED,
|
||||
sla_minutes=settings.SUPPORT_TICKET_SLA_MINUTES,
|
||||
sla_check_interval_seconds=settings.SUPPORT_TICKET_SLA_CHECK_INTERVAL_SECONDS,
|
||||
sla_reminder_cooldown_minutes=settings.SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES,
|
||||
support_system_mode=settings.get_support_system_mode(),
|
||||
cabinet_user_notifications_enabled=SupportSettingsService.get_cabinet_user_notifications_enabled(),
|
||||
cabinet_admin_notifications_enabled=SupportSettingsService.get_cabinet_admin_notifications_enabled(),
|
||||
)
|
||||
|
||||
|
||||
@router.get('', response_model=AdminTicketListResponse)
|
||||
async def get_all_tickets(
|
||||
page: int = Query(1, ge=1, description='Page number'),
|
||||
per_page: int = Query(20, ge=1, le=100, description='Items per page'),
|
||||
status_filter: str | None = Query(None, alias='status', description='Filter by status'),
|
||||
priority_filter: str | None = Query(None, alias='priority', description='Filter by priority'),
|
||||
user_id: int | None = Query(None, description='Filter by user ID'),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
@@ -245,6 +356,10 @@ async def get_all_tickets(
|
||||
query = query.where(Ticket.priority == priority_filter)
|
||||
count_query = count_query.where(Ticket.priority == priority_filter)
|
||||
|
||||
if user_id:
|
||||
query = query.where(Ticket.user_id == user_id)
|
||||
count_query = count_query.where(Ticket.user_id == user_id)
|
||||
|
||||
# Get total count
|
||||
total_result = await db.execute(count_query)
|
||||
total = total_result.scalar() or 0
|
||||
@@ -332,13 +447,13 @@ async def reply_to_ticket(
|
||||
user_id=ticket.user_id,
|
||||
message_text=request.message,
|
||||
is_from_admin=True,
|
||||
created_at=datetime.utcnow(),
|
||||
created_at=datetime.now(UTC),
|
||||
)
|
||||
db.add(message)
|
||||
|
||||
# Update ticket status to answered
|
||||
ticket.status = 'answered'
|
||||
ticket.updated_at = datetime.utcnow()
|
||||
ticket.updated_at = datetime.now(UTC)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(message)
|
||||
@@ -358,11 +473,11 @@ async def reply_to_ticket(
|
||||
|
||||
await notify_user_about_ticket_reply(bot, ticket, request.message, db)
|
||||
except Exception as e:
|
||||
logger.warning(f'Failed to notify user about ticket reply: {e}')
|
||||
logger.warning('Failed to notify user about ticket reply', error=e)
|
||||
finally:
|
||||
await bot.session.close()
|
||||
except Exception as e:
|
||||
logger.warning(f'Failed to send Telegram notification: {e}')
|
||||
logger.warning('Failed to send Telegram notification', error=e)
|
||||
|
||||
# Уведомить пользователя в кабинете
|
||||
try:
|
||||
@@ -373,7 +488,7 @@ async def reply_to_ticket(
|
||||
# Отправить WebSocket уведомление
|
||||
await notify_user_ticket_reply(ticket.user_id, ticket.id, (request.message or '')[:100])
|
||||
except Exception as e:
|
||||
logger.warning(f'Failed to create cabinet notification for admin reply: {e}')
|
||||
logger.warning('Failed to create cabinet notification for admin reply', error=e)
|
||||
|
||||
return _message_to_response(message)
|
||||
|
||||
@@ -407,9 +522,9 @@ async def update_ticket_status(
|
||||
)
|
||||
|
||||
ticket.status = request.status
|
||||
ticket.updated_at = datetime.utcnow()
|
||||
ticket.updated_at = datetime.now(UTC)
|
||||
if request.status == 'closed':
|
||||
ticket.closed_at = datetime.utcnow()
|
||||
ticket.closed_at = datetime.now(UTC)
|
||||
else:
|
||||
ticket.closed_at = None
|
||||
|
||||
@@ -466,7 +581,7 @@ async def update_ticket_priority(
|
||||
)
|
||||
|
||||
ticket.priority = request.priority
|
||||
ticket.updated_at = datetime.utcnow()
|
||||
ticket.updated_at = datetime.now(UTC)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(ticket)
|
||||
@@ -490,113 +605,3 @@ async def update_ticket_priority(
|
||||
user=user_info,
|
||||
messages=messages_response,
|
||||
)
|
||||
|
||||
|
||||
@router.get('/settings', response_model=TicketSettingsResponse)
|
||||
async def get_ticket_settings(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get ticket system settings."""
|
||||
from app.services.support_settings_service import SupportSettingsService
|
||||
|
||||
return TicketSettingsResponse(
|
||||
sla_enabled=settings.SUPPORT_TICKET_SLA_ENABLED,
|
||||
sla_minutes=settings.SUPPORT_TICKET_SLA_MINUTES,
|
||||
sla_check_interval_seconds=settings.SUPPORT_TICKET_SLA_CHECK_INTERVAL_SECONDS,
|
||||
sla_reminder_cooldown_minutes=settings.SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES,
|
||||
support_system_mode=settings.get_support_system_mode(),
|
||||
cabinet_user_notifications_enabled=SupportSettingsService.get_cabinet_user_notifications_enabled(),
|
||||
cabinet_admin_notifications_enabled=SupportSettingsService.get_cabinet_admin_notifications_enabled(),
|
||||
)
|
||||
|
||||
|
||||
@router.patch('/settings', response_model=TicketSettingsResponse)
|
||||
async def update_ticket_settings(
|
||||
request: TicketSettingsUpdateRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update ticket system settings."""
|
||||
from pathlib import Path
|
||||
|
||||
from app.services.support_settings_service import SupportSettingsService
|
||||
|
||||
# Validate support_system_mode
|
||||
if request.support_system_mode is not None:
|
||||
mode = request.support_system_mode.strip().lower()
|
||||
if mode not in {'tickets', 'contact', 'both'}:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Invalid support_system_mode. Must be: tickets, contact, or both',
|
||||
)
|
||||
|
||||
# Update in-memory settings
|
||||
if request.sla_enabled is not None:
|
||||
settings.SUPPORT_TICKET_SLA_ENABLED = request.sla_enabled
|
||||
if request.sla_minutes is not None:
|
||||
settings.SUPPORT_TICKET_SLA_MINUTES = request.sla_minutes
|
||||
if request.sla_check_interval_seconds is not None:
|
||||
settings.SUPPORT_TICKET_SLA_CHECK_INTERVAL_SECONDS = request.sla_check_interval_seconds
|
||||
if request.sla_reminder_cooldown_minutes is not None:
|
||||
settings.SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES = request.sla_reminder_cooldown_minutes
|
||||
if request.support_system_mode is not None:
|
||||
settings.SUPPORT_SYSTEM_MODE = request.support_system_mode.strip().lower()
|
||||
|
||||
# Update cabinet notification settings
|
||||
if request.cabinet_user_notifications_enabled is not None:
|
||||
SupportSettingsService.set_cabinet_user_notifications_enabled(request.cabinet_user_notifications_enabled)
|
||||
if request.cabinet_admin_notifications_enabled is not None:
|
||||
SupportSettingsService.set_cabinet_admin_notifications_enabled(request.cabinet_admin_notifications_enabled)
|
||||
|
||||
# Try to persist to .env file
|
||||
try:
|
||||
env_file = Path('.env')
|
||||
if env_file.exists():
|
||||
lines = env_file.read_text().splitlines()
|
||||
updates = {}
|
||||
|
||||
if request.sla_enabled is not None:
|
||||
updates['SUPPORT_TICKET_SLA_ENABLED'] = str(request.sla_enabled).lower()
|
||||
if request.sla_minutes is not None:
|
||||
updates['SUPPORT_TICKET_SLA_MINUTES'] = str(request.sla_minutes)
|
||||
if request.sla_check_interval_seconds is not None:
|
||||
updates['SUPPORT_TICKET_SLA_CHECK_INTERVAL_SECONDS'] = str(request.sla_check_interval_seconds)
|
||||
if request.sla_reminder_cooldown_minutes is not None:
|
||||
updates['SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES'] = str(request.sla_reminder_cooldown_minutes)
|
||||
if request.support_system_mode is not None:
|
||||
updates['SUPPORT_SYSTEM_MODE'] = request.support_system_mode.strip().lower()
|
||||
|
||||
new_lines = []
|
||||
updated_keys = set()
|
||||
|
||||
for line in lines:
|
||||
updated = False
|
||||
for key, value in updates.items():
|
||||
if line.startswith(f'{key}='):
|
||||
new_lines.append(f'{key}={value}')
|
||||
updated_keys.add(key)
|
||||
updated = True
|
||||
break
|
||||
if not updated:
|
||||
new_lines.append(line)
|
||||
|
||||
# Add any keys that weren't found
|
||||
for key, value in updates.items():
|
||||
if key not in updated_keys:
|
||||
new_lines.append(f'{key}={value}')
|
||||
|
||||
env_file.write_text('\n'.join(new_lines) + '\n')
|
||||
logger.info('Updated ticket settings in .env file')
|
||||
except Exception as e:
|
||||
logger.warning(f'Failed to update .env file: {e}')
|
||||
|
||||
return TicketSettingsResponse(
|
||||
sla_enabled=settings.SUPPORT_TICKET_SLA_ENABLED,
|
||||
sla_minutes=settings.SUPPORT_TICKET_SLA_MINUTES,
|
||||
sla_check_interval_seconds=settings.SUPPORT_TICKET_SLA_CHECK_INTERVAL_SECONDS,
|
||||
sla_reminder_cooldown_minutes=settings.SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES,
|
||||
support_system_mode=settings.get_support_system_mode(),
|
||||
cabinet_user_notifications_enabled=SupportSettingsService.get_cabinet_user_notifications_enabled(),
|
||||
cabinet_admin_notifications_enabled=SupportSettingsService.get_cabinet_admin_notifications_enabled(),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,694 @@
|
||||
"""Admin routes for traffic usage statistics."""
|
||||
|
||||
import asyncio
|
||||
import csv
|
||||
import io
|
||||
import time
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import structlog
|
||||
from aiogram import Bot
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
from aiogram.types import BufferedInputFile
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import and_, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import Subscription, Transaction, TransactionType, User
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
from ..schemas.traffic import (
|
||||
ExportCsvRequest,
|
||||
ExportCsvResponse,
|
||||
TrafficEnrichmentResponse,
|
||||
TrafficNodeInfo,
|
||||
TrafficUsageResponse,
|
||||
UserTrafficEnrichment,
|
||||
UserTrafficItem,
|
||||
)
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/traffic', tags=['Admin Traffic'])
|
||||
|
||||
_ALLOWED_PERIODS = frozenset({1, 3, 7, 14, 30})
|
||||
_CONCURRENCY_LIMIT = 5 # Max parallel API calls to avoid rate limiting
|
||||
|
||||
# In-memory cache: {(start_str, end_str): (timestamp, aggregated_data, nodes_info)}
|
||||
_traffic_cache: dict[tuple[str, str], tuple[float, dict[str, dict[str, int]], list[TrafficNodeInfo]]] = {}
|
||||
_CACHE_TTL = 300 # 5 minutes
|
||||
_cache_lock = asyncio.Lock()
|
||||
|
||||
# Valid sort fields for the GET endpoint
|
||||
_SORT_FIELDS = frozenset({'total_bytes', 'full_name', 'tariff_name', 'device_limit', 'traffic_limit_gb'})
|
||||
_ENRICHMENT_SORT_FIELDS = frozenset({'connected', 'total_spent', 'sub_start', 'sub_end', 'last_node'})
|
||||
|
||||
|
||||
def _get_status(sub) -> str | None:
|
||||
"""Get subscription status via actual_status property."""
|
||||
return sub.actual_status
|
||||
|
||||
|
||||
def _validate_period(period: int) -> None:
|
||||
if period not in _ALLOWED_PERIODS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Period must be one of: {sorted(_ALLOWED_PERIODS)}',
|
||||
)
|
||||
|
||||
|
||||
async def _aggregate_traffic(
|
||||
start_str: str, end_str: str, user_uuids: list[str]
|
||||
) -> tuple[dict[str, dict[str, int]], list[TrafficNodeInfo]]:
|
||||
"""Aggregate per-user traffic across all nodes for a given date range.
|
||||
|
||||
Uses legacy per-node endpoint to fetch all users' traffic per node —
|
||||
O(nodes) API calls instead of O(users). The legacy endpoint returns
|
||||
{userUuid, nodeUuid, total} per entry (non-legacy only returns topUsers
|
||||
without userUuid).
|
||||
|
||||
Returns (user_traffic, nodes_info) where:
|
||||
user_traffic = {remnawave_uuid: {node_uuid: total_bytes, ...}}
|
||||
nodes_info = [TrafficNodeInfo, ...]
|
||||
"""
|
||||
cache_key = (start_str, end_str)
|
||||
|
||||
# Quick check without lock
|
||||
now = time.time()
|
||||
cached = _traffic_cache.get(cache_key)
|
||||
if cached and (now - cached[0]) < _CACHE_TTL:
|
||||
return cached[1], cached[2]
|
||||
|
||||
# Acquire lock for the slow path
|
||||
async with _cache_lock:
|
||||
# Re-check after acquiring lock
|
||||
now = time.time()
|
||||
cached = _traffic_cache.get(cache_key)
|
||||
if cached and (now - cached[0]) < _CACHE_TTL:
|
||||
return cached[1], cached[2]
|
||||
|
||||
service = RemnaWaveService()
|
||||
if not service.is_configured:
|
||||
return {}, []
|
||||
|
||||
user_uuids_set = set(user_uuids)
|
||||
|
||||
async with service.get_api_client() as api:
|
||||
nodes = await api.get_all_nodes()
|
||||
|
||||
# Fetch per-node user stats — O(nodes) calls instead of O(users)
|
||||
semaphore = asyncio.Semaphore(_CONCURRENCY_LIMIT)
|
||||
|
||||
async def fetch_node_users(node):
|
||||
async with semaphore:
|
||||
try:
|
||||
stats = await api.get_bandwidth_stats_node_users_legacy(node.uuid, start_str, end_str)
|
||||
return node.uuid, stats
|
||||
except Exception:
|
||||
logger.warning('Failed to get traffic for node', node_name=node.name, exc_info=True)
|
||||
return node.uuid, None
|
||||
|
||||
results = await asyncio.gather(*(fetch_node_users(n) for n in nodes))
|
||||
|
||||
nodes_info: list[TrafficNodeInfo] = [
|
||||
TrafficNodeInfo(node_uuid=node.uuid, node_name=node.name, country_code=node.country_code) for node in nodes
|
||||
]
|
||||
nodes_info.sort(key=lambda n: n.node_name)
|
||||
|
||||
# Legacy response: [{userUuid, username, nodeUuid, total, date}, ...]
|
||||
user_traffic: dict[str, dict[str, int]] = {}
|
||||
for node_uuid, entries in results:
|
||||
if not isinstance(entries, list):
|
||||
continue
|
||||
for entry in entries:
|
||||
uid = entry.get('userUuid', '')
|
||||
total = int(entry.get('total', 0))
|
||||
if uid and total > 0 and uid in user_uuids_set:
|
||||
user_traffic.setdefault(uid, {})[node_uuid] = user_traffic.get(uid, {}).get(node_uuid, 0) + total
|
||||
|
||||
_traffic_cache[cache_key] = (now, user_traffic, nodes_info)
|
||||
|
||||
# Evict expired entries to prevent unbounded growth
|
||||
expired = [k for k, (ts, _, _) in _traffic_cache.items() if (now - ts) >= _CACHE_TTL]
|
||||
for k in expired:
|
||||
del _traffic_cache[k]
|
||||
|
||||
return user_traffic, nodes_info
|
||||
|
||||
|
||||
def _compute_date_range(period_days: int) -> tuple[str, str]:
|
||||
"""Compute ISO date-time range from period days.
|
||||
|
||||
Truncates to 5-minute intervals for stable cache keys.
|
||||
"""
|
||||
end_dt = datetime.now(UTC).replace(second=0, microsecond=0)
|
||||
end_dt = end_dt.replace(minute=(end_dt.minute // 5) * 5)
|
||||
start_dt = end_dt - timedelta(days=period_days)
|
||||
return start_dt.strftime('%Y-%m-%dT%H:%M:%SZ'), end_dt.strftime('%Y-%m-%dT%H:%M:%SZ')
|
||||
|
||||
|
||||
async def _load_user_map(db: AsyncSession) -> dict[str, User]:
|
||||
"""Load all users with remnawave_uuid, eagerly loading subscription + tariff."""
|
||||
stmt = (
|
||||
select(User)
|
||||
.where(User.remnawave_uuid.isnot(None))
|
||||
.options(selectinload(User.subscription).selectinload(Subscription.tariff))
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
users = result.scalars().all()
|
||||
return {u.remnawave_uuid: u for u in users if u.remnawave_uuid}
|
||||
|
||||
|
||||
def _build_traffic_items(
|
||||
user_traffic: dict[str, dict[str, int]],
|
||||
user_map: dict[str, User],
|
||||
nodes_info: list[TrafficNodeInfo],
|
||||
search: str = '',
|
||||
sort_by: str = 'total_bytes',
|
||||
sort_desc: bool = True,
|
||||
tariff_filter: set[str] | None = None,
|
||||
status_filter: set[str] | None = None,
|
||||
node_filter: set[str] | None = None,
|
||||
) -> list[UserTrafficItem]:
|
||||
"""Merge traffic data with user data, apply search/tariff/status/node filters, return sorted list."""
|
||||
items: list[UserTrafficItem] = []
|
||||
search_lower = search.lower().strip()
|
||||
|
||||
all_uuids = set(user_traffic.keys()) | set(user_map.keys())
|
||||
for uuid in all_uuids:
|
||||
user = user_map.get(uuid)
|
||||
if not user:
|
||||
continue
|
||||
|
||||
traffic = user_traffic.get(uuid, {})
|
||||
|
||||
full_name = user.full_name
|
||||
username = user.username
|
||||
email = user.email
|
||||
|
||||
if search_lower:
|
||||
if (
|
||||
search_lower not in (full_name or '').lower()
|
||||
and search_lower not in (username or '').lower()
|
||||
and search_lower not in (email or '').lower()
|
||||
):
|
||||
continue
|
||||
|
||||
sub = user.subscription
|
||||
tariff_name = None
|
||||
subscription_status = None
|
||||
traffic_limit_gb = 0.0
|
||||
device_limit = 1
|
||||
|
||||
if sub:
|
||||
subscription_status = _get_status(sub)
|
||||
traffic_limit_gb = float(sub.traffic_limit_gb or 0)
|
||||
device_limit = sub.device_limit or 1
|
||||
if sub.tariff:
|
||||
tariff_name = sub.tariff.name
|
||||
|
||||
if tariff_filter is not None:
|
||||
if (tariff_name or '') not in tariff_filter:
|
||||
continue
|
||||
|
||||
if status_filter is not None:
|
||||
if (subscription_status or '') not in status_filter:
|
||||
continue
|
||||
|
||||
# Apply node filter: keep only selected nodes, recalculate total
|
||||
if node_filter is not None:
|
||||
traffic = {k: v for k, v in traffic.items() if k in node_filter}
|
||||
|
||||
total_bytes = sum(traffic.values())
|
||||
|
||||
items.append(
|
||||
UserTrafficItem(
|
||||
user_id=user.id,
|
||||
telegram_id=user.telegram_id,
|
||||
username=username,
|
||||
email=email,
|
||||
full_name=full_name,
|
||||
tariff_name=tariff_name,
|
||||
subscription_status=subscription_status,
|
||||
traffic_limit_gb=traffic_limit_gb,
|
||||
device_limit=device_limit,
|
||||
node_traffic=traffic,
|
||||
total_bytes=total_bytes,
|
||||
)
|
||||
)
|
||||
|
||||
# Sort by the requested field; node columns use 'node_<uuid>' prefix
|
||||
if sort_by.startswith('node_'):
|
||||
node_uuid = sort_by[5:]
|
||||
items.sort(key=lambda x: x.node_traffic.get(node_uuid, 0), reverse=sort_desc)
|
||||
elif sort_by in ('full_name', 'tariff_name'):
|
||||
items.sort(key=lambda x: (getattr(x, sort_by, None) or '').lower(), reverse=sort_desc)
|
||||
else:
|
||||
items.sort(key=lambda x: getattr(x, sort_by, 0) or 0, reverse=sort_desc)
|
||||
|
||||
return items
|
||||
|
||||
|
||||
@router.get('', response_model=TrafficUsageResponse)
|
||||
async def get_traffic_usage(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
period: int = Query(30, ge=1, le=30),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
search: str = Query('', max_length=100),
|
||||
sort_by: str = Query('total_bytes', max_length=100),
|
||||
sort_desc: bool = Query(True),
|
||||
tariffs: str = Query('', max_length=500),
|
||||
statuses: str = Query('', max_length=500),
|
||||
nodes: str = Query('', max_length=2000),
|
||||
start_date: str = Query('', max_length=10),
|
||||
end_date: str = Query('', max_length=10),
|
||||
):
|
||||
"""Get paginated per-user traffic usage by node."""
|
||||
# Determine date range: custom dates or period-based
|
||||
if start_date.strip() and end_date.strip():
|
||||
try:
|
||||
start_dt = datetime.strptime(start_date.strip(), '%Y-%m-%d').replace(tzinfo=UTC)
|
||||
end_dt = datetime.strptime(end_date.strip(), '%Y-%m-%d').replace(tzinfo=UTC, hour=23, minute=59, second=59)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Invalid date format. Use YYYY-MM-DD.')
|
||||
|
||||
now = datetime.now(UTC)
|
||||
end_dt = min(end_dt, now)
|
||||
|
||||
if start_dt > end_dt:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='start_date must be before end_date.')
|
||||
|
||||
if (end_dt - start_dt).days > 31:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Date range cannot exceed 31 days.')
|
||||
|
||||
start_str = start_dt.strftime('%Y-%m-%dT%H:%M:%SZ')
|
||||
end_str = end_dt.strftime('%Y-%m-%dT%H:%M:%SZ')
|
||||
effective_period = (end_dt - start_dt).days or 1
|
||||
else:
|
||||
_validate_period(period)
|
||||
start_str, end_str = _compute_date_range(period)
|
||||
effective_period = period
|
||||
|
||||
user_map = await _load_user_map(db)
|
||||
user_traffic, nodes_info = await _aggregate_traffic(start_str, end_str, list(user_map.keys()))
|
||||
|
||||
# Collect all available tariff names (before filtering)
|
||||
available_tariffs = sorted(
|
||||
{
|
||||
u.subscription.tariff.name
|
||||
for u in user_map.values()
|
||||
if u.subscription and u.subscription.tariff and u.subscription.tariff.name
|
||||
}
|
||||
)
|
||||
|
||||
# Collect all available statuses (before filtering)
|
||||
available_statuses = sorted(
|
||||
{_get_status(sub) for u in user_map.values() if (sub := u.subscription) and _get_status(sub)}
|
||||
)
|
||||
|
||||
# Parse tariff filter
|
||||
tariff_filter: set[str] | None = None
|
||||
if tariffs.strip():
|
||||
tariff_filter = {t.strip() for t in tariffs.split(',') if t.strip()}
|
||||
|
||||
# Parse status filter
|
||||
status_filter: set[str] | None = None
|
||||
if statuses.strip():
|
||||
status_filter = {s.strip() for s in statuses.split(',') if s.strip()}
|
||||
|
||||
# Parse node filter
|
||||
node_filter: set[str] | None = None
|
||||
all_node_uuids = {n.node_uuid for n in nodes_info}
|
||||
if nodes.strip():
|
||||
node_filter = {n.strip() for n in nodes.split(',') if n.strip()} & all_node_uuids
|
||||
if not node_filter:
|
||||
node_filter = None # No valid nodes matched, treat as "all nodes"
|
||||
|
||||
# Validate sort_by: allow known fields + enrichment fields + 'node_<uuid>'
|
||||
is_node_sort = sort_by.startswith('node_') and sort_by[5:] in all_node_uuids
|
||||
is_enrichment_sort = sort_by in _ENRICHMENT_SORT_FIELDS
|
||||
if sort_by not in _SORT_FIELDS and not is_node_sort and not is_enrichment_sort:
|
||||
sort_by = 'total_bytes'
|
||||
|
||||
# For enrichment sort, build items unsorted then sort by enrichment field
|
||||
effective_sort = 'total_bytes' if is_enrichment_sort else sort_by
|
||||
items = _build_traffic_items(
|
||||
user_traffic, user_map, nodes_info, search, effective_sort, sort_desc, tariff_filter, status_filter, node_filter
|
||||
)
|
||||
|
||||
if is_enrichment_sort:
|
||||
enrichment_data = await _build_enrichment(db, user_map)
|
||||
enr_key_map = {
|
||||
'connected': lambda e: e.devices_connected,
|
||||
'total_spent': lambda e: e.total_spent_kopeks,
|
||||
'sub_start': lambda e: e.subscription_start_date or '',
|
||||
'sub_end': lambda e: e.subscription_end_date or '',
|
||||
'last_node': lambda e: e.last_node_name or '',
|
||||
}
|
||||
key_fn = enr_key_map[sort_by]
|
||||
empty = UserTrafficEnrichment()
|
||||
items.sort(key=lambda x: key_fn(enrichment_data.get(x.user_id, empty)), reverse=sort_desc)
|
||||
|
||||
total = len(items)
|
||||
paginated = items[offset : offset + limit]
|
||||
|
||||
return TrafficUsageResponse(
|
||||
items=paginated,
|
||||
nodes=nodes_info,
|
||||
total=total,
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
period_days=effective_period,
|
||||
available_tariffs=available_tariffs,
|
||||
available_statuses=available_statuses,
|
||||
)
|
||||
|
||||
|
||||
# ============== Enrichment endpoint ==============
|
||||
|
||||
_enrichment_cache: dict[str, tuple[float, dict[int, UserTrafficEnrichment]]] = {}
|
||||
_ENRICHMENT_CACHE_TTL = 300 # 5 minutes
|
||||
_enrichment_lock = asyncio.Lock()
|
||||
|
||||
|
||||
async def _get_bulk_spending(db: AsyncSession, user_ids: list[int]) -> dict[int, int]:
|
||||
"""Get total spent kopeks for multiple users in a single query."""
|
||||
if not user_ids:
|
||||
return {}
|
||||
result = await db.execute(
|
||||
select(Transaction.user_id, func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0))
|
||||
.where(
|
||||
and_(
|
||||
Transaction.user_id.in_(user_ids),
|
||||
Transaction.is_completed.is_(True),
|
||||
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
|
||||
)
|
||||
)
|
||||
.group_by(Transaction.user_id)
|
||||
)
|
||||
return {row[0]: int(row[1]) for row in result.all()}
|
||||
|
||||
|
||||
async def _build_enrichment(db: AsyncSession, user_map: dict[str, User]) -> dict[int, UserTrafficEnrichment]:
|
||||
"""Build enrichment data for all users: devices, spending, dates, last node."""
|
||||
uuid_to_user_id: dict[str, int] = {}
|
||||
for uuid, user in user_map.items():
|
||||
uuid_to_user_id[uuid] = user.id
|
||||
|
||||
service = RemnaWaveService()
|
||||
devices_by_user: dict[int, int] = {}
|
||||
last_node_uuid_by_user: dict[int, str] = {}
|
||||
node_uuid_to_name: dict[str, str] = {}
|
||||
|
||||
if service.is_configured:
|
||||
async with service.get_api_client() as api:
|
||||
# 3 bulk calls: nodes + users (paginated) + devices
|
||||
try:
|
||||
nodes_list = await api.get_all_nodes()
|
||||
except Exception:
|
||||
logger.warning('Failed to fetch nodes for enrichment', exc_info=True)
|
||||
nodes_list = []
|
||||
|
||||
for node in nodes_list:
|
||||
node_uuid_to_name[node.uuid] = node.name
|
||||
|
||||
# Fetch all panel users (paginated) for last connected node
|
||||
panel_users = []
|
||||
try:
|
||||
first_page = await api.get_all_users(start=0, size=500)
|
||||
panel_users.extend(first_page['users'])
|
||||
total_panel = first_page['total']
|
||||
|
||||
if total_panel > 500:
|
||||
remaining_tasks = [
|
||||
api.get_all_users(start=offset, size=500) for offset in range(500, total_panel, 500)
|
||||
]
|
||||
pages = await asyncio.gather(*remaining_tasks, return_exceptions=True)
|
||||
for page in pages:
|
||||
if isinstance(page, dict):
|
||||
panel_users.extend(page['users'])
|
||||
except Exception:
|
||||
logger.warning('Failed to fetch panel users for enrichment', exc_info=True)
|
||||
|
||||
for pu in panel_users:
|
||||
uid = uuid_to_user_id.get(pu.uuid)
|
||||
if uid is None:
|
||||
continue
|
||||
if pu.user_traffic and pu.user_traffic.last_connected_node_uuid:
|
||||
last_node_uuid_by_user[uid] = pu.user_traffic.last_connected_node_uuid
|
||||
|
||||
# Bulk device fetch — single API call (paginated with start/size)
|
||||
try:
|
||||
devices_data = await api.get_all_hwid_devices()
|
||||
for device in devices_data.get('devices', []):
|
||||
user_uuid = device.get('userUuid', '')
|
||||
uid = uuid_to_user_id.get(user_uuid)
|
||||
if uid is not None:
|
||||
devices_by_user[uid] = devices_by_user.get(uid, 0) + 1
|
||||
except Exception:
|
||||
logger.warning('Failed to fetch bulk devices for enrichment', exc_info=True)
|
||||
|
||||
# Bulk spending stats
|
||||
all_user_ids = [u.id for u in user_map.values()]
|
||||
spending_map = await _get_bulk_spending(db, all_user_ids)
|
||||
|
||||
# Build enrichment data
|
||||
enrichment: dict[int, UserTrafficEnrichment] = {}
|
||||
for uuid, user in user_map.items():
|
||||
uid = user.id
|
||||
sub = user.subscription
|
||||
|
||||
start_date = None
|
||||
end_date = None
|
||||
if sub:
|
||||
if sub.start_date:
|
||||
start_date = sub.start_date.isoformat()
|
||||
if sub.end_date:
|
||||
end_date = sub.end_date.isoformat()
|
||||
|
||||
last_node_name = None
|
||||
last_uuid = last_node_uuid_by_user.get(uid)
|
||||
if last_uuid:
|
||||
last_node_name = node_uuid_to_name.get(last_uuid)
|
||||
|
||||
enrichment[uid] = UserTrafficEnrichment(
|
||||
devices_connected=devices_by_user.get(uid, 0),
|
||||
total_spent_kopeks=spending_map.get(uid, 0),
|
||||
subscription_start_date=start_date,
|
||||
subscription_end_date=end_date,
|
||||
last_node_name=last_node_name,
|
||||
)
|
||||
|
||||
return enrichment
|
||||
|
||||
|
||||
@router.get('/enrichment', response_model=TrafficEnrichmentResponse)
|
||||
async def get_traffic_enrichment(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Return enrichment data: device counts, spending, dates, last node."""
|
||||
cache_key = 'enrichment'
|
||||
now = time.time()
|
||||
|
||||
cached = _enrichment_cache.get(cache_key)
|
||||
if cached and (now - cached[0]) < _ENRICHMENT_CACHE_TTL:
|
||||
return TrafficEnrichmentResponse(data=cached[1])
|
||||
|
||||
async with _enrichment_lock:
|
||||
now = time.time()
|
||||
cached = _enrichment_cache.get(cache_key)
|
||||
if cached and (now - cached[0]) < _ENRICHMENT_CACHE_TTL:
|
||||
return TrafficEnrichmentResponse(data=cached[1])
|
||||
|
||||
user_map = await _load_user_map(db)
|
||||
enrichment = await _build_enrichment(db, user_map)
|
||||
|
||||
_enrichment_cache[cache_key] = (now, enrichment)
|
||||
|
||||
# Evict expired
|
||||
expired = [k for k, (ts, _) in _enrichment_cache.items() if (now - ts) >= _ENRICHMENT_CACHE_TTL]
|
||||
for k in expired:
|
||||
del _enrichment_cache[k]
|
||||
|
||||
return TrafficEnrichmentResponse(data=enrichment)
|
||||
|
||||
|
||||
@router.post('/export-csv', response_model=ExportCsvResponse)
|
||||
async def export_traffic_csv(
|
||||
request: ExportCsvRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Generate CSV with traffic usage and send to admin's Telegram DM."""
|
||||
if not admin.telegram_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Admin has no Telegram ID configured',
|
||||
)
|
||||
|
||||
# Determine date range: custom dates or period-based
|
||||
if request.start_date and request.end_date:
|
||||
try:
|
||||
start_dt = datetime.strptime(request.start_date.strip(), '%Y-%m-%d').replace(tzinfo=UTC)
|
||||
end_dt = datetime.strptime(request.end_date.strip(), '%Y-%m-%d').replace(
|
||||
tzinfo=UTC, hour=23, minute=59, second=59
|
||||
)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Invalid date format. Use YYYY-MM-DD.')
|
||||
|
||||
now = datetime.now(UTC)
|
||||
end_dt = min(end_dt, now)
|
||||
if start_dt > end_dt:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='start_date must be before end_date.')
|
||||
if (end_dt - start_dt).days > 31:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Date range cannot exceed 31 days.')
|
||||
|
||||
start_str = start_dt.strftime('%Y-%m-%dT%H:%M:%SZ')
|
||||
end_str = end_dt.strftime('%Y-%m-%dT%H:%M:%SZ')
|
||||
period_label = f'{request.start_date}_{request.end_date}'
|
||||
else:
|
||||
_validate_period(request.period)
|
||||
start_str, end_str = _compute_date_range(request.period)
|
||||
period_label = f'{request.period}d'
|
||||
|
||||
user_map = await _load_user_map(db)
|
||||
user_traffic, nodes_info = await _aggregate_traffic(start_str, end_str, list(user_map.keys()))
|
||||
enrichment = await _build_enrichment(db, user_map)
|
||||
|
||||
# Parse filters
|
||||
tariff_filter: set[str] | None = None
|
||||
if request.tariffs and request.tariffs.strip():
|
||||
tariff_filter = {t.strip() for t in request.tariffs.split(',') if t.strip()}
|
||||
|
||||
status_filter: set[str] | None = None
|
||||
if request.statuses and request.statuses.strip():
|
||||
status_filter = {s.strip() for s in request.statuses.split(',') if s.strip()}
|
||||
|
||||
node_filter: set[str] | None = None
|
||||
all_node_uuids = {n.node_uuid for n in nodes_info}
|
||||
if request.nodes and request.nodes.strip():
|
||||
node_filter = {n.strip() for n in request.nodes.split(',') if n.strip()} & all_node_uuids
|
||||
if not node_filter:
|
||||
node_filter = None
|
||||
|
||||
items = _build_traffic_items(
|
||||
user_traffic,
|
||||
user_map,
|
||||
nodes_info,
|
||||
sort_by='total_bytes',
|
||||
sort_desc=True,
|
||||
tariff_filter=tariff_filter,
|
||||
status_filter=status_filter,
|
||||
node_filter=node_filter,
|
||||
)
|
||||
|
||||
# Determine which nodes to include in CSV columns
|
||||
csv_nodes = [n for n in nodes_info if n.node_uuid in node_filter] if node_filter else nodes_info
|
||||
|
||||
# Compute period days for risk calculation
|
||||
if request.start_date and request.end_date:
|
||||
period_days = max((end_dt - start_dt).days, 1)
|
||||
else:
|
||||
period_days = request.period
|
||||
|
||||
total_thr = request.total_threshold_gb or 0
|
||||
node_thr = request.node_threshold_gb or 0
|
||||
has_risk = total_thr > 0 or node_thr > 0
|
||||
|
||||
# Build CSV rows
|
||||
rows: list[dict] = []
|
||||
for item in items:
|
||||
row: dict = {
|
||||
'User ID': item.user_id,
|
||||
'Telegram ID': item.telegram_id or '',
|
||||
'Username': item.username or '',
|
||||
'Email': item.email or '',
|
||||
'Full Name': item.full_name,
|
||||
'Tariff': item.tariff_name or '',
|
||||
'Status': item.subscription_status or '',
|
||||
'Traffic Limit (GB)': item.traffic_limit_gb,
|
||||
'Device Limit': item.device_limit,
|
||||
}
|
||||
# Enrichment columns
|
||||
enr = enrichment.get(item.user_id)
|
||||
row['Connected Devices'] = enr.devices_connected if enr else 0
|
||||
row['Total Spent (RUB)'] = round(enr.total_spent_kopeks / 100, 2) if enr else 0
|
||||
row['Sub Start'] = enr.subscription_start_date or '' if enr else ''
|
||||
row['Sub End'] = enr.subscription_end_date or '' if enr else ''
|
||||
row['Last Node'] = enr.last_node_name or '' if enr else ''
|
||||
|
||||
for node in csv_nodes:
|
||||
row[f'{node.node_name} (bytes)'] = item.node_traffic.get(node.node_uuid, 0)
|
||||
row['Total (bytes)'] = item.total_bytes
|
||||
row['Total (GB)'] = round(item.total_bytes / (1024**3), 2) if item.total_bytes else 0
|
||||
|
||||
if has_risk:
|
||||
daily_total = item.total_bytes / period_days / (1024**3) if period_days > 0 else 0
|
||||
row['Total GB/day'] = round(daily_total, 4)
|
||||
|
||||
total_ratio = daily_total / total_thr if total_thr > 0 else 0
|
||||
|
||||
max_node_ratio = 0.0
|
||||
worst_node_daily = 0.0
|
||||
for node_bytes in item.node_traffic.values():
|
||||
if node_bytes > 0 and node_thr > 0:
|
||||
daily_node = node_bytes / period_days / (1024**3) if period_days > 0 else 0
|
||||
ratio = daily_node / node_thr
|
||||
if ratio > max_node_ratio:
|
||||
max_node_ratio = ratio
|
||||
worst_node_daily = daily_node
|
||||
|
||||
ratio = max(total_ratio, max_node_ratio)
|
||||
if ratio < 0.5:
|
||||
risk_level = 'low'
|
||||
elif ratio < 0.8:
|
||||
risk_level = 'medium'
|
||||
elif ratio < 1.2:
|
||||
risk_level = 'high'
|
||||
else:
|
||||
risk_level = 'critical'
|
||||
|
||||
row['Risk Level'] = risk_level
|
||||
row['Risk Ratio'] = round(ratio, 3)
|
||||
row['Risk GB/day'] = round(daily_total if total_ratio >= max_node_ratio else worst_node_daily, 4)
|
||||
|
||||
rows.append(row)
|
||||
|
||||
# Generate CSV
|
||||
output = io.StringIO()
|
||||
if rows:
|
||||
writer = csv.DictWriter(output, fieldnames=rows[0].keys())
|
||||
writer.writeheader()
|
||||
writer.writerows(rows)
|
||||
csv_bytes = output.getvalue().encode('utf-8-sig')
|
||||
|
||||
timestamp = datetime.now(UTC).strftime('%Y%m%d_%H%M%S')
|
||||
filename = f'traffic_usage_{period_label}_{timestamp}.csv'
|
||||
|
||||
try:
|
||||
bot = Bot(
|
||||
token=settings.BOT_TOKEN,
|
||||
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
|
||||
)
|
||||
async with bot:
|
||||
await bot.send_document(
|
||||
chat_id=admin.telegram_id,
|
||||
document=BufferedInputFile(csv_bytes, filename=filename),
|
||||
caption=f'Traffic usage report ({period_label})\nUsers: {len(rows)}',
|
||||
)
|
||||
except Exception:
|
||||
logger.error('Failed to send CSV to admin', telegram_id=admin.telegram_id, exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to send CSV report. Please try again later.',
|
||||
)
|
||||
|
||||
return ExportCsvResponse(success=True, message=f'CSV sent ({len(rows)} users)')
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Admin routes for version and release information."""
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import aiohttp
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.database.models import User
|
||||
from app.services.version_service import version_service
|
||||
|
||||
from ..dependencies import get_current_admin_user
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/updates', tags=['Cabinet Admin Updates'])
|
||||
|
||||
|
||||
# ============ Schemas ============
|
||||
|
||||
|
||||
class ReleaseItem(BaseModel):
|
||||
tag_name: str
|
||||
name: str
|
||||
body: str
|
||||
published_at: str
|
||||
prerelease: bool
|
||||
|
||||
|
||||
class ProjectReleasesInfo(BaseModel):
|
||||
current_version: str
|
||||
has_updates: bool
|
||||
releases: list[ReleaseItem]
|
||||
repo_url: str
|
||||
|
||||
|
||||
class ReleasesResponse(BaseModel):
|
||||
bot: ProjectReleasesInfo
|
||||
cabinet: ProjectReleasesInfo
|
||||
|
||||
|
||||
# ============ Cabinet releases cache ============
|
||||
|
||||
CABINET_REPO = 'BEDOLAGA-DEV/bedolaga-cabinet'
|
||||
_cabinet_cache: dict = {}
|
||||
_cabinet_last_check: datetime | None = None
|
||||
_CACHE_TTL = 3600
|
||||
|
||||
|
||||
async def _fetch_cabinet_releases(force: bool = False) -> list[dict]:
|
||||
global _cabinet_last_check
|
||||
|
||||
if not force and _cabinet_cache.get('releases') and _cabinet_last_check:
|
||||
if datetime.now(UTC) - _cabinet_last_check < timedelta(seconds=_CACHE_TTL):
|
||||
return _cabinet_cache['releases']
|
||||
|
||||
url = f'https://api.github.com/repos/{CABINET_REPO}/releases'
|
||||
|
||||
try:
|
||||
timeout = aiohttp.ClientTimeout(total=10)
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session, session.get(url) as response:
|
||||
if response.status == 200:
|
||||
data = await response.json()
|
||||
releases = []
|
||||
for item in data[:20]:
|
||||
releases.append(
|
||||
{
|
||||
'tag_name': item['tag_name'],
|
||||
'name': item.get('name') or item['tag_name'],
|
||||
'body': item.get('body') or '',
|
||||
'published_at': item['published_at'],
|
||||
'prerelease': item.get('prerelease', False),
|
||||
}
|
||||
)
|
||||
_cabinet_cache['releases'] = releases
|
||||
_cabinet_last_check = datetime.now(UTC)
|
||||
logger.info('Fetched cabinet releases from GitHub', releases_count=len(releases))
|
||||
return releases
|
||||
logger.warning('GitHub API returned status for cabinet releases', response_status=response.status)
|
||||
return _cabinet_cache.get('releases', [])
|
||||
except TimeoutError:
|
||||
logger.warning('Timeout fetching cabinet releases from GitHub')
|
||||
return _cabinet_cache.get('releases', [])
|
||||
except Exception as e:
|
||||
logger.error('Error fetching cabinet releases', e=e)
|
||||
return _cabinet_cache.get('releases', [])
|
||||
|
||||
|
||||
# ============ Routes ============
|
||||
|
||||
|
||||
@router.get('/releases', response_model=ReleasesResponse)
|
||||
async def get_releases(
|
||||
current_user: User = Depends(get_current_admin_user),
|
||||
) -> ReleasesResponse:
|
||||
"""Get release information for bot and cabinet."""
|
||||
# Bot releases
|
||||
bot_releases_raw = await version_service._fetch_releases()
|
||||
has_updates, _ = await version_service.check_for_updates()
|
||||
|
||||
bot_releases = [
|
||||
ReleaseItem(
|
||||
tag_name=r.tag_name,
|
||||
name=r.name,
|
||||
body=r.full_description,
|
||||
published_at=r.published_at.isoformat(),
|
||||
prerelease=r.prerelease,
|
||||
)
|
||||
for r in bot_releases_raw[:10]
|
||||
]
|
||||
|
||||
bot_info = ProjectReleasesInfo(
|
||||
current_version=version_service.current_version,
|
||||
has_updates=has_updates,
|
||||
releases=bot_releases,
|
||||
repo_url=f'https://github.com/{version_service.repo}',
|
||||
)
|
||||
|
||||
# Cabinet releases
|
||||
cabinet_releases_raw = await _fetch_cabinet_releases()
|
||||
cabinet_releases = [ReleaseItem(**r) for r in cabinet_releases_raw[:10]]
|
||||
|
||||
# Current version = latest non-prerelease tag
|
||||
cabinet_current = ''
|
||||
for r in cabinet_releases_raw:
|
||||
if not r.get('prerelease', False):
|
||||
cabinet_current = r['tag_name']
|
||||
break
|
||||
|
||||
cabinet_info = ProjectReleasesInfo(
|
||||
current_version=cabinet_current,
|
||||
has_updates=False,
|
||||
releases=cabinet_releases,
|
||||
repo_url=f'https://github.com/{CABINET_REPO}',
|
||||
)
|
||||
|
||||
return ReleasesResponse(bot=bot_info, cabinet=cabinet_info)
|
||||
+924
-102
File diff suppressed because it is too large
Load Diff
@@ -2,10 +2,10 @@
|
||||
API роуты колеса удачи для администраторов.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import math
|
||||
from datetime import datetime
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -35,7 +35,7 @@ from app.database.models import User
|
||||
from app.services.wheel_service import wheel_service
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/wheel', tags=['Admin Fortune Wheel'])
|
||||
|
||||
@@ -107,7 +107,7 @@ async def update_admin_wheel_config(
|
||||
|
||||
config = await update_wheel_config(db, **update_data)
|
||||
|
||||
logger.info(f'🎡 Admin {admin.telegram_id} updated wheel config: {update_data}')
|
||||
logger.info('🎡 Admin updated wheel config', telegram_id=admin.telegram_id, update_data=update_data)
|
||||
|
||||
# Возвращаем полную конфигурацию
|
||||
prizes = await get_wheel_prizes(db, config.id, active_only=False)
|
||||
@@ -211,7 +211,7 @@ async def create_prize(
|
||||
promo_traffic_gb=request.promo_traffic_gb,
|
||||
)
|
||||
|
||||
logger.info(f'🎁 Admin {admin.telegram_id} created prize: {prize.display_name}')
|
||||
logger.info('🎁 Admin created prize', telegram_id=admin.telegram_id, display_name=prize.display_name)
|
||||
|
||||
return WheelPrizeAdminResponse(
|
||||
id=prize.id,
|
||||
@@ -261,7 +261,7 @@ async def update_prize(
|
||||
detail='Prize not found',
|
||||
)
|
||||
|
||||
logger.info(f'🎁 Admin {admin.telegram_id} updated prize {prize_id}: {update_data}')
|
||||
logger.info('🎁 Admin updated prize', telegram_id=admin.telegram_id, prize_id=prize_id, update_data=update_data)
|
||||
|
||||
return WheelPrizeAdminResponse(
|
||||
id=prize.id,
|
||||
@@ -298,7 +298,7 @@ async def delete_prize_endpoint(
|
||||
detail='Prize not found',
|
||||
)
|
||||
|
||||
logger.info(f'🗑️ Admin {admin.telegram_id} deleted prize {prize_id}')
|
||||
logger.info('🗑️ Admin deleted prize', telegram_id=admin.telegram_id, prize_id=prize_id)
|
||||
|
||||
|
||||
@router.post('/prizes/reorder', status_code=status.HTTP_200_OK)
|
||||
@@ -309,7 +309,7 @@ async def reorder_prizes(
|
||||
):
|
||||
"""Переупорядочить призы."""
|
||||
await reorder_wheel_prizes(db, request.prize_ids)
|
||||
logger.info(f'🔄 Admin {admin.telegram_id} reordered prizes: {request.prize_ids}')
|
||||
logger.info('🔄 Admin reordered prizes', telegram_id=admin.telegram_id, prize_ids=request.prize_ids)
|
||||
return {'success': True}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
"""Admin routes for managing withdrawal requests in cabinet."""
|
||||
|
||||
import json
|
||||
from typing import Literal
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import desc, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import (
|
||||
ReferralEarning,
|
||||
User,
|
||||
WithdrawalRequest,
|
||||
WithdrawalRequestStatus,
|
||||
)
|
||||
from app.services.referral_withdrawal_service import referral_withdrawal_service
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
from ..schemas.withdrawals import (
|
||||
AdminApproveWithdrawalRequest,
|
||||
AdminRejectWithdrawalRequest,
|
||||
AdminWithdrawalDetailResponse,
|
||||
AdminWithdrawalItem,
|
||||
AdminWithdrawalListResponse,
|
||||
)
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/withdrawals', tags=['Cabinet Admin Withdrawals'])
|
||||
|
||||
|
||||
def _get_risk_level(risk_score: int) -> str:
|
||||
"""Get risk level from score."""
|
||||
if risk_score >= 70:
|
||||
return 'critical'
|
||||
if risk_score >= 50:
|
||||
return 'high'
|
||||
if risk_score >= 30:
|
||||
return 'medium'
|
||||
return 'low'
|
||||
|
||||
|
||||
@router.get('', response_model=AdminWithdrawalListResponse)
|
||||
async def list_withdrawals(
|
||||
withdrawal_status: Literal['pending', 'approved', 'rejected', 'completed', 'cancelled'] | None = Query(
|
||||
None, alias='status'
|
||||
),
|
||||
offset: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""List all withdrawal requests."""
|
||||
query = select(WithdrawalRequest)
|
||||
count_query = select(func.count()).select_from(WithdrawalRequest)
|
||||
|
||||
if withdrawal_status:
|
||||
query = query.where(WithdrawalRequest.status == withdrawal_status)
|
||||
count_query = count_query.where(WithdrawalRequest.status == withdrawal_status)
|
||||
|
||||
total_result = await db.execute(count_query)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
# Pending stats
|
||||
pending_count_result = await db.execute(
|
||||
select(func.count())
|
||||
.select_from(WithdrawalRequest)
|
||||
.where(WithdrawalRequest.status == WithdrawalRequestStatus.PENDING.value)
|
||||
)
|
||||
pending_count = pending_count_result.scalar() or 0
|
||||
|
||||
pending_total_result = await db.execute(
|
||||
select(func.coalesce(func.sum(WithdrawalRequest.amount_kopeks), 0)).where(
|
||||
WithdrawalRequest.status == WithdrawalRequestStatus.PENDING.value
|
||||
)
|
||||
)
|
||||
pending_total = pending_total_result.scalar() or 0
|
||||
|
||||
query = query.order_by(desc(WithdrawalRequest.created_at)).offset(offset).limit(limit)
|
||||
result = await db.execute(query)
|
||||
withdrawals = result.scalars().all()
|
||||
|
||||
# Batch-fetch users to avoid N+1
|
||||
user_ids = list({w.user_id for w in withdrawals})
|
||||
if user_ids:
|
||||
users_result = await db.execute(select(User).where(User.id.in_(user_ids)))
|
||||
users_map = {u.id: u for u in users_result.scalars().all()}
|
||||
else:
|
||||
users_map = {}
|
||||
|
||||
items = []
|
||||
for w in withdrawals:
|
||||
user = users_map.get(w.user_id)
|
||||
items.append(
|
||||
AdminWithdrawalItem(
|
||||
id=w.id,
|
||||
user_id=w.user_id,
|
||||
username=user.username if user else None,
|
||||
first_name=user.first_name if user else None,
|
||||
telegram_id=user.telegram_id if user else None,
|
||||
amount_kopeks=w.amount_kopeks,
|
||||
amount_rubles=w.amount_kopeks / 100,
|
||||
status=w.status,
|
||||
risk_score=w.risk_score or 0,
|
||||
risk_level=_get_risk_level(w.risk_score or 0),
|
||||
payment_details=w.payment_details,
|
||||
admin_comment=w.admin_comment,
|
||||
created_at=w.created_at,
|
||||
processed_at=w.processed_at,
|
||||
)
|
||||
)
|
||||
|
||||
return AdminWithdrawalListResponse(
|
||||
items=items,
|
||||
total=total,
|
||||
pending_count=pending_count,
|
||||
pending_total_kopeks=pending_total,
|
||||
)
|
||||
|
||||
|
||||
@router.get('/{withdrawal_id}', response_model=AdminWithdrawalDetailResponse)
|
||||
async def get_withdrawal_detail(
|
||||
withdrawal_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get detailed withdrawal request with risk analysis."""
|
||||
withdrawal = await db.get(WithdrawalRequest, withdrawal_id)
|
||||
if not withdrawal:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Заявка не найдена',
|
||||
)
|
||||
|
||||
user = await db.get(User, withdrawal.user_id)
|
||||
|
||||
# Parse risk analysis
|
||||
risk_analysis = None
|
||||
if withdrawal.risk_analysis:
|
||||
try:
|
||||
risk_analysis = json.loads(withdrawal.risk_analysis)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
# Get referral stats
|
||||
referral_count = await db.execute(
|
||||
select(func.count()).select_from(User).where(User.referred_by_id == withdrawal.user_id)
|
||||
)
|
||||
total_earnings = await db.execute(
|
||||
select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0)).where(
|
||||
ReferralEarning.user_id == withdrawal.user_id
|
||||
)
|
||||
)
|
||||
|
||||
return AdminWithdrawalDetailResponse(
|
||||
id=withdrawal.id,
|
||||
user_id=withdrawal.user_id,
|
||||
username=user.username if user else None,
|
||||
first_name=user.first_name if user else None,
|
||||
telegram_id=user.telegram_id if user else None,
|
||||
amount_kopeks=withdrawal.amount_kopeks,
|
||||
amount_rubles=withdrawal.amount_kopeks / 100,
|
||||
status=withdrawal.status,
|
||||
risk_score=withdrawal.risk_score or 0,
|
||||
risk_level=_get_risk_level(withdrawal.risk_score or 0),
|
||||
risk_analysis=risk_analysis,
|
||||
payment_details=withdrawal.payment_details,
|
||||
admin_comment=withdrawal.admin_comment,
|
||||
balance_kopeks=user.balance_kopeks if user else 0,
|
||||
total_referrals=referral_count.scalar() or 0,
|
||||
total_earnings_kopeks=total_earnings.scalar() or 0,
|
||||
created_at=withdrawal.created_at,
|
||||
processed_at=withdrawal.processed_at,
|
||||
)
|
||||
|
||||
|
||||
@router.post('/{withdrawal_id}/approve')
|
||||
async def approve_withdrawal(
|
||||
withdrawal_id: int,
|
||||
request: AdminApproveWithdrawalRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Approve a withdrawal request."""
|
||||
success, error = await referral_withdrawal_service.approve_request(
|
||||
db,
|
||||
request_id=withdrawal_id,
|
||||
admin_id=admin.id,
|
||||
comment=request.comment,
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=error,
|
||||
)
|
||||
|
||||
# Notify user about approval
|
||||
try:
|
||||
from aiogram import Bot
|
||||
|
||||
from app.config import settings
|
||||
from app.services.notification_delivery_service import notification_delivery_service
|
||||
|
||||
if settings.BOT_TOKEN:
|
||||
withdrawal = await db.get(WithdrawalRequest, withdrawal_id)
|
||||
user = await db.get(User, withdrawal.user_id) if withdrawal else None
|
||||
if user and withdrawal:
|
||||
formatted_amount = settings.format_price(withdrawal.amount_kopeks)
|
||||
comment_text = f'\n{request.comment}' if request.comment else ''
|
||||
tg_message = f'✅ Ваш запрос на вывод {formatted_amount} одобрен.{comment_text}'
|
||||
bot = Bot(token=settings.BOT_TOKEN)
|
||||
try:
|
||||
await notification_delivery_service.notify_withdrawal_approved(
|
||||
user=user,
|
||||
amount_kopeks=withdrawal.amount_kopeks,
|
||||
comment=request.comment,
|
||||
bot=bot,
|
||||
telegram_message=tg_message,
|
||||
)
|
||||
finally:
|
||||
await bot.session.close()
|
||||
except Exception as e:
|
||||
logger.error('Failed to send withdrawal approval notification', error=e)
|
||||
|
||||
return {'success': True}
|
||||
|
||||
|
||||
@router.post('/{withdrawal_id}/reject')
|
||||
async def reject_withdrawal(
|
||||
withdrawal_id: int,
|
||||
request: AdminRejectWithdrawalRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Reject a withdrawal request."""
|
||||
success, error = await referral_withdrawal_service.reject_request(
|
||||
db,
|
||||
request_id=withdrawal_id,
|
||||
admin_id=admin.id,
|
||||
comment=request.comment,
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=error or 'Не удалось отклонить заявку',
|
||||
)
|
||||
|
||||
# Notify user about rejection
|
||||
try:
|
||||
from aiogram import Bot
|
||||
|
||||
from app.config import settings
|
||||
from app.services.notification_delivery_service import notification_delivery_service
|
||||
|
||||
if settings.BOT_TOKEN:
|
||||
withdrawal = await db.get(WithdrawalRequest, withdrawal_id)
|
||||
user = await db.get(User, withdrawal.user_id) if withdrawal else None
|
||||
if user and withdrawal:
|
||||
formatted_amount = settings.format_price(withdrawal.amount_kopeks)
|
||||
comment_text = f'\nПричина: {request.comment}' if request.comment else ''
|
||||
tg_message = f'❌ Ваш запрос на вывод {formatted_amount} отклонён.{comment_text}'
|
||||
bot = Bot(token=settings.BOT_TOKEN)
|
||||
try:
|
||||
await notification_delivery_service.notify_withdrawal_rejected(
|
||||
user=user,
|
||||
amount_kopeks=withdrawal.amount_kopeks,
|
||||
comment=request.comment,
|
||||
bot=bot,
|
||||
telegram_message=tg_message,
|
||||
)
|
||||
finally:
|
||||
await bot.session.close()
|
||||
except Exception as e:
|
||||
logger.error('Failed to send withdrawal rejection notification', error=e)
|
||||
|
||||
return {'success': True}
|
||||
|
||||
|
||||
@router.post('/{withdrawal_id}/complete')
|
||||
async def complete_withdrawal(
|
||||
withdrawal_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Mark a withdrawal as completed (money transferred)."""
|
||||
success, error = await referral_withdrawal_service.complete_request(
|
||||
db,
|
||||
request_id=withdrawal_id,
|
||||
admin_id=admin.id,
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=error or 'Не удалось завершить заявку',
|
||||
)
|
||||
|
||||
return {'success': True}
|
||||
+392
-52
@@ -2,23 +2,35 @@
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.campaign import (
|
||||
get_campaign_by_start_parameter,
|
||||
get_campaign_registration_by_user,
|
||||
)
|
||||
from app.database.crud.user import (
|
||||
clear_email_change_pending,
|
||||
create_user,
|
||||
create_user_by_email,
|
||||
get_user_by_id,
|
||||
get_user_by_referral_code,
|
||||
get_user_by_telegram_id,
|
||||
is_email_taken,
|
||||
set_email_change_pending,
|
||||
verify_and_apply_email_change,
|
||||
)
|
||||
from app.database.models import CabinetRefreshToken, User
|
||||
from app.services.campaign_service import AdvertisingCampaignService
|
||||
from app.services.disposable_email_service import disposable_email_service
|
||||
from app.services.referral_service import process_referral_registration
|
||||
from app.utils.timezone import panel_datetime_to_utc
|
||||
|
||||
from ..auth import (
|
||||
create_access_token,
|
||||
@@ -30,8 +42,10 @@ from ..auth import (
|
||||
verify_password,
|
||||
)
|
||||
from ..auth.email_verification import (
|
||||
generate_email_change_code,
|
||||
generate_password_reset_token,
|
||||
generate_verification_token,
|
||||
get_email_change_expires_at,
|
||||
get_password_reset_expires_at,
|
||||
get_verification_expires_at,
|
||||
is_token_expired,
|
||||
@@ -40,6 +54,10 @@ from ..auth.jwt_handler import get_refresh_token_expires_at
|
||||
from ..dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
from ..schemas.auth import (
|
||||
AuthResponse,
|
||||
CampaignBonusInfo,
|
||||
EmailChangeRequest,
|
||||
EmailChangeResponse,
|
||||
EmailChangeVerifyRequest,
|
||||
EmailLoginRequest,
|
||||
EmailRegisterRequest,
|
||||
EmailRegisterStandaloneRequest,
|
||||
@@ -57,7 +75,7 @@ from ..services.email_service import email_service
|
||||
from ..services.email_template_overrides import get_rendered_override
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/auth', tags=['Cabinet Auth'])
|
||||
|
||||
@@ -106,12 +124,6 @@ async def _store_refresh_token(
|
||||
token_hash = hashlib.sha256(refresh_token.encode()).hexdigest()
|
||||
expires_at = get_refresh_token_expires_at()
|
||||
|
||||
# Check if token already exists (handles race conditions)
|
||||
existing = await db.execute(select(CabinetRefreshToken).where(CabinetRefreshToken.token_hash == token_hash))
|
||||
if existing.scalar_one_or_none():
|
||||
# Token already stored, skip
|
||||
return
|
||||
|
||||
token_record = CabinetRefreshToken(
|
||||
user_id=user_id,
|
||||
token_hash=token_hash,
|
||||
@@ -121,9 +133,71 @@ async def _store_refresh_token(
|
||||
db.add(token_record)
|
||||
try:
|
||||
await db.commit()
|
||||
except Exception:
|
||||
# Handle race condition if token was inserted between check and insert
|
||||
except IntegrityError:
|
||||
await db.rollback()
|
||||
logger.debug('Refresh token already exists (duplicate)', user_id=user_id)
|
||||
|
||||
|
||||
async def _process_campaign_bonus(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
campaign_slug: str | None,
|
||||
) -> CampaignBonusInfo | None:
|
||||
"""Process campaign bonus for user during auth. Never raises."""
|
||||
if not campaign_slug:
|
||||
return None
|
||||
try:
|
||||
campaign = await get_campaign_by_start_parameter(db, campaign_slug, only_active=True)
|
||||
if not campaign:
|
||||
return None
|
||||
|
||||
# Lock user row to prevent concurrent bonus application (race condition)
|
||||
await db.execute(select(User).where(User.id == user.id).with_for_update())
|
||||
|
||||
existing = await get_campaign_registration_by_user(db, user.id)
|
||||
if existing:
|
||||
logger.debug('User already has campaign registration', user_id=user.id)
|
||||
return None
|
||||
|
||||
# Привязать реферала к партнёру кампании (если партнёр назначен и юзер ещё не привязан)
|
||||
if campaign.partner_user_id and not user.referred_by_id:
|
||||
user.referred_by_id = campaign.partner_user_id
|
||||
await db.flush()
|
||||
try:
|
||||
await process_referral_registration(db, user.id, campaign.partner_user_id, bot=None)
|
||||
logger.info(
|
||||
'Referral set from campaign partner',
|
||||
user_id=user.id,
|
||||
partner_user_id=campaign.partner_user_id,
|
||||
campaign_id=campaign.id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error('Failed to process referral from campaign partner', error=e)
|
||||
|
||||
service = AdvertisingCampaignService()
|
||||
result = await service.apply_campaign_bonus(db, user, campaign)
|
||||
if not result.success:
|
||||
return None
|
||||
|
||||
# Refresh user to get updated balance after bonus
|
||||
await db.refresh(user)
|
||||
|
||||
return CampaignBonusInfo(
|
||||
campaign_name=campaign.name,
|
||||
bonus_type=result.bonus_type or campaign.bonus_type,
|
||||
balance_kopeks=result.balance_kopeks,
|
||||
subscription_days=result.subscription_days,
|
||||
tariff_name=result.tariff_name,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception('Failed to process campaign bonus', user_id=user.id, campaign_slug=campaign_slug)
|
||||
try:
|
||||
await db.rollback()
|
||||
# Re-fetch user so session stays usable for the caller
|
||||
await db.refresh(user)
|
||||
except Exception:
|
||||
logger.exception('Failed to rollback after campaign bonus error', user_id=user.id)
|
||||
return None
|
||||
|
||||
|
||||
async def _sync_subscription_from_panel_by_email(db: AsyncSession, user: User) -> None:
|
||||
@@ -146,12 +220,12 @@ async def _sync_subscription_from_panel_by_email(db: AsyncSession, user: User) -
|
||||
panel_users = await api.get_user_by_email(user.email)
|
||||
|
||||
if not panel_users:
|
||||
logger.debug(f'No subscription found in panel for email: {user.email}')
|
||||
logger.debug('No subscription found in panel for email', email=user.email)
|
||||
return
|
||||
|
||||
# Take first user if multiple found
|
||||
panel_user = panel_users[0]
|
||||
logger.info(f'Found subscription in panel for email {user.email}: {panel_user.uuid}')
|
||||
logger.info('Found subscription in panel for email', email=user.email, uuid=panel_user.uuid)
|
||||
|
||||
# Link user to panel
|
||||
user.remnawave_uuid = panel_user.uuid
|
||||
@@ -162,8 +236,8 @@ async def _sync_subscription_from_panel_by_email(db: AsyncSession, user: User) -
|
||||
|
||||
existing_sub = await get_subscription_by_user_id(db, user.id)
|
||||
|
||||
# Parse panel data
|
||||
expire_at = panel_user.expire_at
|
||||
# Parse panel data — panel returns local time with misleading +00:00 offset
|
||||
expire_at = panel_datetime_to_utc(panel_user.expire_at)
|
||||
traffic_limit_gb = panel_user.traffic_limit_bytes // (1024**3) if panel_user.traffic_limit_bytes > 0 else 0
|
||||
traffic_used_gb = panel_user.used_traffic_bytes / (1024**3) if panel_user.used_traffic_bytes > 0 else 0
|
||||
|
||||
@@ -173,11 +247,8 @@ async def _sync_subscription_from_panel_by_email(db: AsyncSession, user: User) -
|
||||
# Device limit from panel
|
||||
device_limit = panel_user.hwid_device_limit or 1
|
||||
|
||||
# Determine status - use timezone-aware datetime for comparison
|
||||
# Determine status — expire_at is now naive UTC
|
||||
current_time = datetime.now(UTC)
|
||||
# Make expire_at timezone-aware if it's naive
|
||||
if expire_at.tzinfo is None:
|
||||
expire_at = expire_at.replace(tzinfo=UTC)
|
||||
|
||||
if panel_user.status.value == 'ACTIVE' and expire_at > current_time:
|
||||
sub_status = SubscriptionStatus.ACTIVE
|
||||
@@ -187,10 +258,8 @@ async def _sync_subscription_from_panel_by_email(db: AsyncSession, user: User) -
|
||||
sub_status = SubscriptionStatus.DISABLED
|
||||
|
||||
if existing_sub:
|
||||
# Update existing subscription
|
||||
# Convert to naive datetime for database storage
|
||||
end_date_naive = expire_at.replace(tzinfo=None) if expire_at.tzinfo else expire_at
|
||||
existing_sub.end_date = end_date_naive
|
||||
# Update existing subscription (expire_at already naive UTC)
|
||||
existing_sub.end_date = expire_at
|
||||
existing_sub.traffic_limit_gb = traffic_limit_gb
|
||||
existing_sub.traffic_used_gb = traffic_used_gb
|
||||
existing_sub.status = sub_status.value
|
||||
@@ -201,17 +270,17 @@ async def _sync_subscription_from_panel_by_email(db: AsyncSession, user: User) -
|
||||
existing_sub.device_limit = device_limit
|
||||
existing_sub.is_trial = False # Panel subscription is not trial
|
||||
logger.info(
|
||||
f'Updated subscription for email user {user.email}, squads: {connected_squads}, devices: {device_limit}'
|
||||
'Updated subscription for email user squads: devices',
|
||||
email=user.email,
|
||||
connected_squads=connected_squads,
|
||||
device_limit=device_limit,
|
||||
)
|
||||
else:
|
||||
# Create new subscription
|
||||
# Convert current_time to naive for database storage if needed
|
||||
start_date_naive = current_time.replace(tzinfo=None)
|
||||
end_date_naive = expire_at.replace(tzinfo=None) if expire_at.tzinfo else expire_at
|
||||
# Create new subscription (expire_at and current_time already naive UTC)
|
||||
new_sub = Subscription(
|
||||
user_id=user.id,
|
||||
start_date=start_date_naive,
|
||||
end_date=end_date_naive,
|
||||
start_date=current_time,
|
||||
end_date=expire_at,
|
||||
traffic_limit_gb=traffic_limit_gb,
|
||||
traffic_used_gb=traffic_used_gb,
|
||||
status=sub_status.value,
|
||||
@@ -224,13 +293,16 @@ async def _sync_subscription_from_panel_by_email(db: AsyncSession, user: User) -
|
||||
)
|
||||
db.add(new_sub)
|
||||
logger.info(
|
||||
f'Created subscription for email user {user.email}, squads: {connected_squads}, devices: {device_limit}'
|
||||
'Created subscription for email user squads: devices',
|
||||
email=user.email,
|
||||
connected_squads=connected_squads,
|
||||
device_limit=device_limit,
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f'Failed to sync subscription from panel for {user.email}: {e}')
|
||||
logger.warning('Failed to sync subscription from panel for', email=user.email, error=e)
|
||||
# Don't rollback - it detaches user object and breaks subsequent operations
|
||||
# The sync is non-critical, main verification already succeeded
|
||||
|
||||
@@ -271,7 +343,7 @@ async def auth_telegram(
|
||||
|
||||
if not user:
|
||||
# Create new user from Telegram initData
|
||||
logger.info(f'Creating new user from cabinet (initData): telegram_id={telegram_id}')
|
||||
logger.info('Creating new user from cabinet (initData): telegram_id', telegram_id=telegram_id)
|
||||
user = await create_user(
|
||||
db=db,
|
||||
telegram_id=telegram_id,
|
||||
@@ -280,7 +352,7 @@ async def auth_telegram(
|
||||
last_name=tg_last_name,
|
||||
language=tg_language,
|
||||
)
|
||||
logger.info(f'User created successfully: id={user.id}, telegram_id={user.telegram_id}')
|
||||
logger.info('User created successfully: id=, telegram_id', user_id=user.id, telegram_id=user.telegram_id)
|
||||
else:
|
||||
# Update user info from initData (like bot middleware does)
|
||||
updated = False
|
||||
@@ -294,7 +366,7 @@ async def auth_telegram(
|
||||
user.last_name = tg_last_name
|
||||
updated = True
|
||||
if updated:
|
||||
logger.info(f'User {user.id} profile updated from initData')
|
||||
logger.info('User profile updated from initData', user_id=user.id)
|
||||
|
||||
if user.status != 'active':
|
||||
raise HTTPException(
|
||||
@@ -303,7 +375,7 @@ async def auth_telegram(
|
||||
)
|
||||
|
||||
# Update last login
|
||||
user.cabinet_last_login = datetime.utcnow()
|
||||
user.cabinet_last_login = datetime.now(UTC)
|
||||
await db.commit()
|
||||
|
||||
response = _create_auth_response(user)
|
||||
@@ -311,6 +383,11 @@ async def auth_telegram(
|
||||
# Store refresh token
|
||||
await _store_refresh_token(db, user.id, response.refresh_token)
|
||||
|
||||
# Process campaign bonus
|
||||
response.campaign_bonus = await _process_campaign_bonus(db, user, request.campaign_slug)
|
||||
if response.campaign_bonus:
|
||||
response.user = _user_to_response(user)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@@ -325,7 +402,7 @@ async def auth_telegram_widget(
|
||||
This endpoint validates data from Telegram Login Widget and returns
|
||||
JWT tokens for authenticated access.
|
||||
"""
|
||||
widget_data = request.model_dump()
|
||||
widget_data = request.model_dump(exclude={'campaign_slug'})
|
||||
|
||||
if not validate_telegram_login_widget(widget_data):
|
||||
raise HTTPException(
|
||||
@@ -337,7 +414,9 @@ async def auth_telegram_widget(
|
||||
|
||||
if not user:
|
||||
# Create new user from Telegram data
|
||||
logger.info(f'Creating new user from cabinet: telegram_id={request.id}, username={request.username}')
|
||||
logger.info(
|
||||
'Creating new user from cabinet: telegram_id=, username', request_id=request.id, username=request.username
|
||||
)
|
||||
user = await create_user(
|
||||
db=db,
|
||||
telegram_id=request.id,
|
||||
@@ -346,7 +425,7 @@ async def auth_telegram_widget(
|
||||
last_name=request.last_name,
|
||||
language='ru',
|
||||
)
|
||||
logger.info(f'User created successfully: id={user.id}, telegram_id={user.telegram_id}')
|
||||
logger.info('User created successfully: id=, telegram_id', user_id=user.id, telegram_id=user.telegram_id)
|
||||
|
||||
if user.status != 'active':
|
||||
raise HTTPException(
|
||||
@@ -362,12 +441,17 @@ async def auth_telegram_widget(
|
||||
if request.last_name != user.last_name:
|
||||
user.last_name = request.last_name
|
||||
|
||||
user.cabinet_last_login = datetime.utcnow()
|
||||
user.cabinet_last_login = datetime.now(UTC)
|
||||
await db.commit()
|
||||
|
||||
response = _create_auth_response(user)
|
||||
await _store_refresh_token(db, user.id, response.refresh_token)
|
||||
|
||||
# Process campaign bonus
|
||||
response.campaign_bonus = await _process_campaign_bonus(db, user, request.campaign_slug)
|
||||
if response.campaign_bonus:
|
||||
response.user = _user_to_response(user)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@@ -383,6 +467,13 @@ async def register_email(
|
||||
Requires valid JWT token from Telegram authentication.
|
||||
Sends verification email to the provided address.
|
||||
"""
|
||||
# Check for disposable email
|
||||
if disposable_email_service.is_disposable(request.email):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Disposable email addresses are not allowed',
|
||||
)
|
||||
|
||||
# Check if email already exists
|
||||
existing_user = await db.execute(select(User).where(User.email == request.email))
|
||||
if existing_user.scalar_one_or_none():
|
||||
@@ -474,7 +565,14 @@ async def register_email_standalone(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Invalid test email password',
|
||||
)
|
||||
logger.info(f'Test email registration: {request.email}')
|
||||
logger.info('Test email registration', email=request.email)
|
||||
|
||||
# Check for disposable email
|
||||
if disposable_email_service.is_disposable(request.email):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Disposable email addresses are not allowed',
|
||||
)
|
||||
|
||||
# Проверить что email не занят
|
||||
existing = await db.execute(select(User).where(User.email == request.email))
|
||||
@@ -494,11 +592,17 @@ async def register_email_standalone(
|
||||
if referrer:
|
||||
# Защита от самореферала - нельзя регистрироваться по своему же коду
|
||||
if referrer.email and referrer.email.lower() == request.email.lower():
|
||||
logger.warning(f'Self-referral attempt blocked: email={request.email}, code={request.referral_code}')
|
||||
logger.warning(
|
||||
'Self-referral attempt blocked: email=, code',
|
||||
email=request.email,
|
||||
referral_code=request.referral_code,
|
||||
)
|
||||
referrer = None
|
||||
else:
|
||||
logger.info(
|
||||
f'Found referrer for email registration: referrer_id={referrer.id}, code={request.referral_code}'
|
||||
'Found referrer for email registration: referrer_id=, code',
|
||||
referrer_id=referrer.id,
|
||||
referral_code=request.referral_code,
|
||||
)
|
||||
|
||||
# Создать пользователя
|
||||
@@ -514,9 +618,9 @@ async def register_email_standalone(
|
||||
# Для тестового email - автоматически верифицировать
|
||||
if is_test_email:
|
||||
user.email_verified = True
|
||||
user.email_verified_at = datetime.utcnow()
|
||||
user.email_verified_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
logger.info(f'Test email auto-verified: {request.email}, user_id={user.id}')
|
||||
logger.info('Test email auto-verified: user_id', email=request.email, user_id=user.id)
|
||||
else:
|
||||
# Сгенерировать токен верификации
|
||||
verification_token = generate_verification_token()
|
||||
@@ -561,9 +665,11 @@ async def register_email_standalone(
|
||||
if referrer:
|
||||
try:
|
||||
await process_referral_registration(db, user.id, referrer.id, bot=None)
|
||||
logger.info(f'Processed referral registration: user_id={user.id}, referrer_id={referrer.id}')
|
||||
logger.info(
|
||||
'Processed referral registration: user_id=, referrer_id', user_id=user.id, referrer_id=referrer.id
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to process referral registration: {e}')
|
||||
logger.error('Failed to process referral registration', error=e)
|
||||
# Не прерываем регистрацию из-за ошибки реферальной системы
|
||||
|
||||
# Для тестового email - сразу можно логиниться (уже verified)
|
||||
@@ -599,10 +705,10 @@ async def verify_email(
|
||||
|
||||
# Mark email as verified
|
||||
user.email_verified = True
|
||||
user.email_verified_at = datetime.utcnow()
|
||||
user.email_verified_at = datetime.now(UTC)
|
||||
user.email_verification_token = None
|
||||
user.email_verification_expires = None
|
||||
user.cabinet_last_login = datetime.utcnow()
|
||||
user.cabinet_last_login = datetime.now(UTC)
|
||||
|
||||
await db.commit()
|
||||
|
||||
@@ -613,6 +719,11 @@ async def verify_email(
|
||||
response = _create_auth_response(user)
|
||||
await _store_refresh_token(db, user.id, response.refresh_token)
|
||||
|
||||
# Process campaign bonus
|
||||
response.campaign_bonus = await _process_campaign_bonus(db, user, request.campaign_slug)
|
||||
if response.campaign_bonus:
|
||||
response.user = _user_to_response(user)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@@ -706,7 +817,7 @@ async def login_email(
|
||||
if not user:
|
||||
# For test email - auto-create user if not exists
|
||||
if is_test_email and settings.validate_test_email_password(request.email, request.password):
|
||||
logger.info(f'Test email login - creating new user: {request.email}')
|
||||
logger.info('Test email login creating new user', email=request.email)
|
||||
password_hash = hash_password(request.password)
|
||||
user = await create_user_by_email(
|
||||
db=db,
|
||||
@@ -716,7 +827,7 @@ async def login_email(
|
||||
language='ru',
|
||||
)
|
||||
user.email_verified = True
|
||||
user.email_verified_at = datetime.utcnow()
|
||||
user.email_verified_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
else:
|
||||
raise HTTPException(
|
||||
@@ -749,12 +860,17 @@ async def login_email(
|
||||
detail='User account is not active',
|
||||
)
|
||||
|
||||
user.cabinet_last_login = datetime.utcnow()
|
||||
user.cabinet_last_login = datetime.now(UTC)
|
||||
await db.commit()
|
||||
|
||||
response = _create_auth_response(user)
|
||||
await _store_refresh_token(db, user.id, response.refresh_token)
|
||||
|
||||
# Process campaign bonus
|
||||
response.campaign_bonus = await _process_campaign_bonus(db, user, request.campaign_slug)
|
||||
if response.campaign_bonus:
|
||||
response.user = _user_to_response(user)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@@ -837,7 +953,7 @@ async def logout(
|
||||
token_record = result.scalar_one_or_none()
|
||||
|
||||
if token_record:
|
||||
token_record.revoked_at = datetime.utcnow()
|
||||
token_record.revoked_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
|
||||
return {'message': 'Logged out successfully'}
|
||||
@@ -941,3 +1057,227 @@ async def check_is_admin(
|
||||
"""Check if current user is an admin."""
|
||||
is_admin = settings.is_admin(telegram_id=user.telegram_id, email=user.email if user.email_verified else None)
|
||||
return {'is_admin': is_admin}
|
||||
|
||||
|
||||
@router.post('/email/change', response_model=EmailChangeResponse)
|
||||
async def request_email_change(
|
||||
request: EmailChangeRequest,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""
|
||||
Request email change.
|
||||
|
||||
For verified emails: sends a 6-digit verification code to the new email.
|
||||
For unverified emails: replaces the email directly and sends verification to the new address.
|
||||
"""
|
||||
if not user.email:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='No email address to change',
|
||||
)
|
||||
|
||||
# Check if new email is the same as current
|
||||
if request.new_email.lower() == user.email.lower():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='New email is the same as current email',
|
||||
)
|
||||
|
||||
# Check for disposable email
|
||||
if disposable_email_service.is_disposable(request.new_email):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Disposable email addresses are not allowed',
|
||||
)
|
||||
|
||||
# Check if new email is already taken
|
||||
if await is_email_taken(db, request.new_email, exclude_user_id=user.id):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='This email is already registered',
|
||||
)
|
||||
|
||||
# Unverified email: replace directly and send verification to new address
|
||||
if not user.email_verified:
|
||||
old_email = user.email
|
||||
user.email = request.new_email.lower()
|
||||
user.email_verified = False
|
||||
|
||||
verification_token = generate_verification_token()
|
||||
verification_expires = get_verification_expires_at()
|
||||
user.email_verification_token = verification_token
|
||||
user.email_verification_expires = verification_expires
|
||||
|
||||
try:
|
||||
await db.commit()
|
||||
except IntegrityError:
|
||||
await db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='This email is already registered',
|
||||
)
|
||||
|
||||
if settings.is_cabinet_email_verification_enabled() and email_service.is_configured():
|
||||
cabinet_url = settings.CABINET_URL
|
||||
verification_url = f'{cabinet_url}/verify-email'
|
||||
lang = user.language or 'ru'
|
||||
full_url = f'{verification_url}?token={verification_token}'
|
||||
expire_hours = settings.get_cabinet_email_verification_expire_hours()
|
||||
|
||||
override = await get_rendered_override(
|
||||
'email_verification',
|
||||
lang,
|
||||
context={
|
||||
'username': user.first_name or '',
|
||||
'verification_url': full_url,
|
||||
'expire_hours': str(expire_hours),
|
||||
},
|
||||
db=db,
|
||||
)
|
||||
custom_subject, custom_body = override if override else (None, None)
|
||||
|
||||
try:
|
||||
await asyncio.to_thread(
|
||||
email_service.send_verification_email,
|
||||
to_email=request.new_email,
|
||||
verification_token=verification_token,
|
||||
verification_url=verification_url,
|
||||
username=user.first_name,
|
||||
language=lang,
|
||||
custom_subject=custom_subject,
|
||||
custom_body_html=custom_body,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
'Failed to send verification email to for user',
|
||||
new_email=request.new_email,
|
||||
user_id=user.id,
|
||||
error=e,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'Unverified email replaced for user', user_id=user.id, old_email=old_email, new_email=request.new_email
|
||||
)
|
||||
|
||||
return EmailChangeResponse(
|
||||
message='Email replaced, verification sent to new address',
|
||||
new_email=request.new_email,
|
||||
expires_in_minutes=0,
|
||||
)
|
||||
|
||||
# Verified email: send code to new address for confirmation
|
||||
# Generate verification code
|
||||
code = generate_email_change_code()
|
||||
expires_at = get_email_change_expires_at()
|
||||
expire_minutes = settings.get_cabinet_email_change_code_expire_minutes()
|
||||
|
||||
# Save pending email change
|
||||
await set_email_change_pending(db, user, request.new_email, code, expires_at)
|
||||
|
||||
# Send verification email to new address
|
||||
if email_service.is_configured():
|
||||
lang = user.language or 'ru'
|
||||
|
||||
# Check for admin template override
|
||||
override = await get_rendered_override(
|
||||
'email_change_code',
|
||||
lang,
|
||||
context={
|
||||
'username': user.first_name or '',
|
||||
'code': code,
|
||||
'expire_minutes': str(expire_minutes),
|
||||
},
|
||||
db=db,
|
||||
)
|
||||
custom_subject, custom_body = override if override else (None, None)
|
||||
|
||||
await asyncio.to_thread(
|
||||
email_service.send_email_change_code,
|
||||
to_email=request.new_email,
|
||||
code=code,
|
||||
username=user.first_name,
|
||||
language=lang,
|
||||
custom_subject=custom_subject,
|
||||
custom_body_html=custom_body,
|
||||
)
|
||||
else:
|
||||
# Clear pending change if email service is not configured
|
||||
await clear_email_change_pending(db, user)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail='Email service is not configured',
|
||||
)
|
||||
|
||||
logger.info('Email change requested for user', user_id=user.id, email=user.email, new_email=request.new_email)
|
||||
|
||||
return EmailChangeResponse(
|
||||
message='Verification code sent to new email',
|
||||
new_email=request.new_email,
|
||||
expires_in_minutes=expire_minutes,
|
||||
)
|
||||
|
||||
|
||||
@router.post('/email/change/verify')
|
||||
async def verify_email_change(
|
||||
request: EmailChangeVerifyRequest,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""
|
||||
Verify email change with code.
|
||||
|
||||
Completes the email change process if the code is valid.
|
||||
"""
|
||||
success, message = await verify_and_apply_email_change(db, user, request.code)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=message,
|
||||
)
|
||||
|
||||
return {
|
||||
'message': message,
|
||||
'new_email': user.email,
|
||||
}
|
||||
|
||||
|
||||
@router.post('/email/change/cancel')
|
||||
async def cancel_email_change(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""
|
||||
Cancel pending email change.
|
||||
"""
|
||||
if not user.email_change_new:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='No pending email change',
|
||||
)
|
||||
|
||||
await clear_email_change_pending(db, user)
|
||||
|
||||
return {'message': 'Email change cancelled'}
|
||||
|
||||
|
||||
@router.get('/email/change/status')
|
||||
async def get_email_change_status(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
):
|
||||
"""
|
||||
Get pending email change status.
|
||||
"""
|
||||
if not user.email_change_new:
|
||||
return {
|
||||
'pending': False,
|
||||
'new_email': None,
|
||||
'expires_at': None,
|
||||
}
|
||||
|
||||
return {
|
||||
'pending': True,
|
||||
'new_email': user.email_change_new,
|
||||
'expires_at': user.email_change_expires.isoformat() if user.email_change_expires else None,
|
||||
}
|
||||
|
||||
+166
-162
@@ -1,10 +1,11 @@
|
||||
"""Balance and payment routes for cabinet."""
|
||||
|
||||
import logging
|
||||
import math
|
||||
import time
|
||||
from decimal import ROUND_HALF_UP, Decimal, InvalidOperation
|
||||
|
||||
import httpx
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import desc, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -12,7 +13,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.config import settings
|
||||
from app.database.crud.user import get_user_by_id
|
||||
from app.database.models import PaymentMethod, Transaction, User
|
||||
from app.external.cryptobot import CryptoBotService
|
||||
from app.services.payment_method_config_service import get_enabled_methods_for_user
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.services.payment_verification_service import (
|
||||
SUPPORTED_MANUAL_CHECK_METHODS,
|
||||
@@ -22,7 +23,7 @@ from app.services.payment_verification_service import (
|
||||
method_display_name,
|
||||
run_manual_check,
|
||||
)
|
||||
from app.services.yookassa_service import YooKassaService
|
||||
from app.utils.currency_converter import currency_converter
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
from ..schemas.balance import (
|
||||
@@ -40,7 +41,7 @@ from ..schemas.balance import (
|
||||
)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/balance', tags=['Cabinet Balance'])
|
||||
|
||||
@@ -127,134 +128,82 @@ async def get_transactions(
|
||||
)
|
||||
|
||||
|
||||
async def _get_available_payment_methods(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
) -> list[PaymentMethodResponse]:
|
||||
"""Get available payment methods filtered by DB config and user context.
|
||||
@router.get('/payment-methods', response_model=list[PaymentMethodResponse])
|
||||
async def get_payment_methods(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get available payment methods for the current user.
|
||||
|
||||
Combines env-var availability with DB-based admin config (ordering, display conditions).
|
||||
Uses PaymentMethodConfig from database for:
|
||||
- Sort order (sort_order)
|
||||
- Enabled/disabled status (is_enabled)
|
||||
- Display names (display_name with fallback to env)
|
||||
- Min/max amounts (with fallback to env defaults)
|
||||
- Sub-options filtering (sub_options)
|
||||
- User filters (user_type_filter, first_topup_filter, promo_group_filter)
|
||||
"""
|
||||
from app.services.payment_method_config_service import (
|
||||
_get_method_defaults,
|
||||
get_all_configs,
|
||||
# Check if this is user's first topup
|
||||
from sqlalchemy import exists
|
||||
|
||||
has_completed_topup = await db.execute(
|
||||
select(
|
||||
exists().where(
|
||||
Transaction.user_id == user.id,
|
||||
Transaction.type == 'deposit',
|
||||
Transaction.is_completed == True,
|
||||
)
|
||||
)
|
||||
)
|
||||
is_first_topup = not has_completed_topup.scalar()
|
||||
|
||||
configs = await get_all_configs(db)
|
||||
defaults = _get_method_defaults()
|
||||
# Get enabled methods from database config
|
||||
enabled_methods = await get_enabled_methods_for_user(db, user=user, is_first_topup=is_first_topup)
|
||||
|
||||
# Provider availability checks from env vars
|
||||
provider_enabled = {
|
||||
'telegram_stars': settings.TELEGRAM_STARS_ENABLED,
|
||||
'tribute': settings.TRIBUTE_ENABLED and bool(getattr(settings, 'TRIBUTE_DONATE_LINK', '')),
|
||||
'cryptobot': settings.is_cryptobot_enabled(),
|
||||
'heleket': settings.is_heleket_enabled(),
|
||||
'yookassa': settings.is_yookassa_enabled(),
|
||||
'mulenpay': settings.is_mulenpay_enabled(),
|
||||
'pal24': settings.is_pal24_enabled(),
|
||||
'platega': settings.is_platega_enabled(),
|
||||
'wata': settings.is_wata_enabled(),
|
||||
'freekassa': settings.is_freekassa_enabled(),
|
||||
'cloudpayments': settings.is_cloudpayments_enabled(),
|
||||
}
|
||||
# Build response with additional options formatting
|
||||
methods = []
|
||||
for method_data in enabled_methods:
|
||||
method_id = method_data['id']
|
||||
|
||||
# Default options builder (for methods with sub-options)
|
||||
def _build_options(method_id: str, config_sub_options: dict | None) -> list[dict] | None:
|
||||
if method_id == 'yookassa':
|
||||
all_opts = [
|
||||
{'id': 'card', 'name': '💳 Карта', 'description': 'Банковская карта'},
|
||||
{'id': 'sbp', 'name': '🏦 СБП', 'description': 'Система быстрых платежей (QR)'},
|
||||
]
|
||||
elif method_id == 'pal24':
|
||||
all_opts = [
|
||||
{'id': 'sbp', 'name': '🏦 СБП', 'description': 'Система быстрых платежей'},
|
||||
{'id': 'card', 'name': '💳 Карта', 'description': 'Банковская карта'},
|
||||
]
|
||||
elif method_id == 'platega':
|
||||
platega_methods = settings.get_platega_active_methods()
|
||||
definitions = settings.get_platega_method_definitions()
|
||||
all_opts = []
|
||||
for method_code in platega_methods:
|
||||
info = definitions.get(method_code, {})
|
||||
all_opts.append(
|
||||
# Format options with descriptions for specific methods
|
||||
options = method_data.get('options')
|
||||
if options:
|
||||
formatted_options = []
|
||||
for opt in options:
|
||||
opt_id = opt['id']
|
||||
opt_name = opt.get('name', opt_id)
|
||||
description = ''
|
||||
|
||||
# Add descriptions based on method and option
|
||||
if method_id in ('yookassa', 'pal24', 'cloudpayments', 'freekassa'):
|
||||
if opt_id == 'card':
|
||||
opt_name = f'💳 {opt_name}'
|
||||
description = 'Банковская карта'
|
||||
elif opt_id == 'sbp':
|
||||
opt_name = f'🏦 {opt_name}'
|
||||
description = 'Система быстрых платежей'
|
||||
elif method_id == 'platega':
|
||||
# Platega options already have descriptions from config
|
||||
definitions = settings.get_platega_method_definitions()
|
||||
info = definitions.get(int(opt_id), {}) if opt_id.isdigit() else {}
|
||||
description = info.get('description') or info.get('name') or ''
|
||||
|
||||
formatted_options.append(
|
||||
{
|
||||
'id': str(method_code),
|
||||
'name': info.get('title') or info.get('name') or f'Platega {method_code}',
|
||||
'description': info.get('description') or info.get('name') or '',
|
||||
'id': opt_id,
|
||||
'name': opt_name,
|
||||
'description': description,
|
||||
}
|
||||
)
|
||||
elif method_id == 'freekassa':
|
||||
all_opts = [
|
||||
{'id': 'sbp', 'name': '🏦 NSPK СБП', 'description': 'Система быстрых платежей'},
|
||||
{'id': 'card', 'name': '💳 Карта', 'description': 'Банковская карта'},
|
||||
]
|
||||
elif method_id == 'cloudpayments':
|
||||
all_opts = [
|
||||
{'id': 'card', 'name': '💳 Карта', 'description': 'Банковская карта'},
|
||||
{'id': 'sbp', 'name': '🏦 СБП', 'description': 'Система быстрых платежей'},
|
||||
]
|
||||
else:
|
||||
return None
|
||||
|
||||
if not all_opts:
|
||||
return None
|
||||
|
||||
# Filter by sub_options config from DB
|
||||
if config_sub_options:
|
||||
all_opts = [o for o in all_opts if config_sub_options.get(o['id'], True)]
|
||||
|
||||
return all_opts if all_opts else None
|
||||
|
||||
# User promo group IDs for filtering
|
||||
user_promo_group_ids: set[int] = set()
|
||||
if hasattr(user, 'user_promo_groups') and user.user_promo_groups:
|
||||
for upg in user.user_promo_groups:
|
||||
user_promo_group_ids.add(upg.promo_group_id)
|
||||
if hasattr(user, 'promo_group_id') and user.promo_group_id:
|
||||
user_promo_group_ids.add(user.promo_group_id)
|
||||
|
||||
methods = []
|
||||
for config in configs:
|
||||
mid = config.method_id
|
||||
|
||||
# 1. Check env-var provider availability AND DB admin toggle
|
||||
if not provider_enabled.get(mid, False):
|
||||
continue
|
||||
if not config.is_enabled:
|
||||
continue
|
||||
|
||||
# 2. Check user type filter
|
||||
if config.user_type_filter == 'telegram' and user.auth_type != 'telegram':
|
||||
continue
|
||||
if config.user_type_filter == 'email' and user.auth_type != 'email':
|
||||
continue
|
||||
|
||||
# 3. Check first topup filter
|
||||
if config.first_topup_filter == 'yes' and not user.has_made_first_topup:
|
||||
continue
|
||||
if config.first_topup_filter == 'no' and user.has_made_first_topup:
|
||||
continue
|
||||
|
||||
# 4. Check promo group filter
|
||||
if config.promo_group_filter_mode == 'selected' and config.allowed_promo_groups:
|
||||
allowed_ids = {pg.id for pg in config.allowed_promo_groups}
|
||||
if not user_promo_group_ids.intersection(allowed_ids):
|
||||
continue
|
||||
|
||||
# Build the response
|
||||
method_def = defaults.get(mid, {})
|
||||
display_name = config.display_name or method_def.get('default_display_name', mid)
|
||||
min_amount = config.min_amount_kopeks or method_def.get('default_min', 1000)
|
||||
max_amount = config.max_amount_kopeks or method_def.get('default_max', 10000000)
|
||||
options = _build_options(mid, config.sub_options)
|
||||
options = formatted_options if formatted_options else None
|
||||
|
||||
methods.append(
|
||||
PaymentMethodResponse(
|
||||
id=mid,
|
||||
name=display_name,
|
||||
id=method_id,
|
||||
name=method_data['name'],
|
||||
description=None,
|
||||
min_amount_kopeks=min_amount,
|
||||
max_amount_kopeks=max_amount,
|
||||
min_amount_kopeks=method_data['min_amount_kopeks'],
|
||||
max_amount_kopeks=method_data['max_amount_kopeks'],
|
||||
is_available=True,
|
||||
options=options,
|
||||
)
|
||||
@@ -263,15 +212,6 @@ async def _get_available_payment_methods(
|
||||
return methods
|
||||
|
||||
|
||||
@router.get('/payment-methods', response_model=list[PaymentMethodResponse])
|
||||
async def get_payment_methods(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get available payment methods."""
|
||||
return await _get_available_payment_methods(db, user)
|
||||
|
||||
|
||||
@router.post('/stars-invoice', response_model=StarsInvoiceResponse)
|
||||
async def create_stars_invoice(
|
||||
request: StarsInvoiceRequest,
|
||||
@@ -309,7 +249,7 @@ async def create_stars_invoice(
|
||||
if stars_amount <= 0:
|
||||
stars_amount = 1
|
||||
except Exception as e:
|
||||
logger.error(f'Error calculating Stars amount: {e}')
|
||||
logger.error('Error calculating Stars amount', error=e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to calculate Stars amount',
|
||||
@@ -339,7 +279,7 @@ async def create_stars_invoice(
|
||||
result = response.json()
|
||||
|
||||
if not result.get('ok'):
|
||||
logger.error(f'Telegram API error: {result}')
|
||||
logger.error('Telegram API error', result=result)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to create Stars invoice',
|
||||
@@ -347,8 +287,10 @@ async def create_stars_invoice(
|
||||
|
||||
invoice_url = result['result']
|
||||
logger.info(
|
||||
f'Created Stars invoice for balance top-up: user={user.id}, '
|
||||
f'amount={request.amount_kopeks} kopeks, stars={stars_amount}'
|
||||
'Created Stars invoice for balance top-up: user=, amount= kopeks, stars',
|
||||
user_id=user.id,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
stars_amount=stars_amount,
|
||||
)
|
||||
|
||||
return StarsInvoiceResponse(
|
||||
@@ -358,7 +300,7 @@ async def create_stars_invoice(
|
||||
)
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f'HTTP error creating Stars invoice: {e}')
|
||||
logger.error('HTTP error creating Stars invoice', error=e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to connect to Telegram API',
|
||||
@@ -373,7 +315,7 @@ async def create_topup(
|
||||
):
|
||||
"""Create payment for balance top-up."""
|
||||
# Validate payment method
|
||||
methods = await _get_available_payment_methods(db, user)
|
||||
methods = await get_payment_methods(user=user, db=db)
|
||||
method = next((m for m in methods if m.id == request.payment_method), None)
|
||||
|
||||
if not method or not method.is_available:
|
||||
@@ -401,13 +343,11 @@ async def create_topup(
|
||||
|
||||
try:
|
||||
if request.payment_method == 'yookassa':
|
||||
yookassa_service = YooKassaService()
|
||||
payment_service = PaymentService()
|
||||
yookassa_metadata = {
|
||||
'user_id': str(user.id),
|
||||
'user_telegram_id': str(user.telegram_id) if user.telegram_id else '',
|
||||
'user_username': user.username or '',
|
||||
'amount_kopeks': str(request.amount_kopeks),
|
||||
'type': 'balance_topup',
|
||||
'purpose': 'balance_topup',
|
||||
'source': 'cabinet',
|
||||
}
|
||||
|
||||
@@ -418,25 +358,25 @@ async def create_topup(
|
||||
request.amount_kopeks, telegram_user_id=user.telegram_id
|
||||
)
|
||||
if option == 'sbp':
|
||||
# Create SBP payment with QR code
|
||||
result = await yookassa_service.create_sbp_payment(
|
||||
amount=amount_rubles,
|
||||
currency='RUB',
|
||||
result = await payment_service.create_yookassa_sbp_payment(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
description=description,
|
||||
metadata=yookassa_metadata,
|
||||
)
|
||||
else:
|
||||
# Default: card payment
|
||||
result = await yookassa_service.create_payment(
|
||||
amount=amount_rubles,
|
||||
currency='RUB',
|
||||
result = await payment_service.create_yookassa_payment(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
description=description,
|
||||
metadata=yookassa_metadata,
|
||||
)
|
||||
|
||||
if result and not result.get('error'):
|
||||
if result:
|
||||
payment_url = result.get('confirmation_url')
|
||||
payment_id = result.get('id')
|
||||
payment_id = result.get('yookassa_payment_id')
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
@@ -444,24 +384,49 @@ async def create_topup(
|
||||
)
|
||||
|
||||
elif request.payment_method == 'cryptobot':
|
||||
cryptobot_service = CryptoBotService()
|
||||
# Convert RUB to USDT (approximate)
|
||||
usdt_amount = amount_rubles / 100 # Approximate rate
|
||||
result = await cryptobot_service.create_invoice(
|
||||
amount=usdt_amount,
|
||||
asset='USDT',
|
||||
description=f'Balance top-up {amount_rubles:.2f} RUB',
|
||||
if not settings.is_cryptobot_enabled():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='CryptoBot payment method is unavailable',
|
||||
)
|
||||
|
||||
try:
|
||||
rate = await currency_converter.get_usd_to_rub_rate()
|
||||
except Exception:
|
||||
rate = 0.0
|
||||
if not rate or rate <= 0:
|
||||
rate = 95.0
|
||||
|
||||
try:
|
||||
amount_usd = float(
|
||||
(Decimal(request.amount_kopeks) / Decimal(100) / Decimal(str(rate))).quantize(
|
||||
Decimal('0.01'), rounding=ROUND_HALF_UP
|
||||
)
|
||||
)
|
||||
except (InvalidOperation, ValueError):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Unable to convert amount to USD',
|
||||
)
|
||||
|
||||
payment_service = PaymentService()
|
||||
result = await payment_service.create_cryptobot_payment(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
amount_usd=amount_usd,
|
||||
asset=settings.CRYPTOBOT_DEFAULT_ASSET,
|
||||
description=settings.get_balance_payment_description(
|
||||
request.amount_kopeks, telegram_user_id=user.telegram_id
|
||||
),
|
||||
payload=f'cabinet_topup_{user.id}_{request.amount_kopeks}',
|
||||
)
|
||||
if result:
|
||||
# Priority: web_app for desktop/browser, mini_app for mobile, bot as fallback
|
||||
payment_url = (
|
||||
result.get('web_app_invoice_url')
|
||||
result.get('bot_invoice_url')
|
||||
or result.get('mini_app_invoice_url')
|
||||
or result.get('bot_invoice_url')
|
||||
or result.get('pay_url')
|
||||
or result.get('web_app_invoice_url')
|
||||
)
|
||||
payment_id = str(result.get('invoice_id'))
|
||||
payment_id = result.get('invoice_id') or str(result.get('local_payment_id', 'pending'))
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
@@ -700,6 +665,32 @@ async def create_topup(
|
||||
detail='Failed to create FreeKassa payment',
|
||||
)
|
||||
|
||||
elif request.payment_method == 'kassa_ai':
|
||||
if not settings.is_kassa_ai_enabled():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='KassaAI payment method is unavailable',
|
||||
)
|
||||
|
||||
payment_service = PaymentService()
|
||||
result = await payment_service.create_kassa_ai_payment(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
description=settings.get_balance_payment_description(request.amount_kopeks),
|
||||
email=getattr(user, 'email', None),
|
||||
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
|
||||
)
|
||||
|
||||
if result and result.get('payment_url'):
|
||||
payment_url = result.get('payment_url')
|
||||
payment_id = str(result.get('local_payment_id') or result.get('order_id') or 'pending')
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to create KassaAI payment',
|
||||
)
|
||||
|
||||
elif request.payment_method == 'tribute':
|
||||
if not settings.TRIBUTE_ENABLED or not settings.TRIBUTE_DONATE_LINK:
|
||||
raise HTTPException(
|
||||
@@ -721,7 +712,7 @@ async def create_topup(
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f'Payment creation error: {e}')
|
||||
logger.error('Payment creation error', error=e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to create payment. Please try again later.',
|
||||
@@ -840,6 +831,17 @@ def _get_status_info(record: PendingPayment) -> tuple[str, str]:
|
||||
}
|
||||
return mapping.get(status, ('❓', 'Неизвестно'))
|
||||
|
||||
if record.method == PaymentMethod.KASSA_AI:
|
||||
mapping = {
|
||||
'pending': ('⏳', 'Ожидает оплаты'),
|
||||
'success': ('✅', 'Оплачено'),
|
||||
'paid': ('✅', 'Оплачено'),
|
||||
'canceled': ('❌', 'Отменено'),
|
||||
'failed': ('❌', 'Ошибка'),
|
||||
'expired': ('⌛', 'Истёк'),
|
||||
}
|
||||
return mapping.get(status, ('❓', 'Неизвестно'))
|
||||
|
||||
return '❓', 'Неизвестно'
|
||||
|
||||
|
||||
@@ -868,6 +870,8 @@ def _is_checkable(record: PendingPayment) -> bool:
|
||||
return status in {'pending', 'authorized'}
|
||||
if record.method == PaymentMethod.FREEKASSA:
|
||||
return status in {'pending', 'created', 'processing'}
|
||||
if record.method == PaymentMethod.KASSA_AI:
|
||||
return status in {'pending', 'created', 'processing'}
|
||||
return False
|
||||
|
||||
|
||||
@@ -891,7 +895,7 @@ def _get_payment_url(record: PendingPayment) -> str | None:
|
||||
)
|
||||
elif record.method == PaymentMethod.PLATEGA:
|
||||
payment_url = getattr(payment, 'redirect_url', None) or payment_url
|
||||
elif record.method == PaymentMethod.CLOUDPAYMENTS or record.method == PaymentMethod.FREEKASSA:
|
||||
elif record.method in (PaymentMethod.CLOUDPAYMENTS, PaymentMethod.FREEKASSA, PaymentMethod.KASSA_AI):
|
||||
payment_url = getattr(payment, 'payment_url', None) or payment_url
|
||||
|
||||
return payment_url
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"""Branding routes for cabinet - logo, project name, and theme colors management."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel
|
||||
@@ -17,7 +17,7 @@ from app.database.models import SystemSetting, User
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/branding', tags=['Branding'])
|
||||
|
||||
@@ -36,6 +36,7 @@ EMAIL_AUTH_ENABLED_KEY = 'CABINET_EMAIL_AUTH_ENABLED' # Stores "true" or "false
|
||||
YANDEX_METRIKA_ID_KEY = 'CABINET_YANDEX_METRIKA_ID' # Stores counter ID (numeric string)
|
||||
GOOGLE_ADS_ID_KEY = 'CABINET_GOOGLE_ADS_ID' # Stores conversion ID (e.g. "AW-123456789")
|
||||
GOOGLE_ADS_LABEL_KEY = 'CABINET_GOOGLE_ADS_LABEL' # Stores conversion label (alphanumeric)
|
||||
LITE_MODE_ENABLED_KEY = 'CABINET_LITE_MODE_ENABLED' # Stores "true" or "false"
|
||||
|
||||
# Allowed image types
|
||||
ALLOWED_CONTENT_TYPES = {'image/png', 'image/jpeg', 'image/jpg', 'image/webp', 'image/svg+xml'}
|
||||
@@ -144,6 +145,18 @@ class EmailAuthEnabledUpdate(BaseModel):
|
||||
enabled: bool
|
||||
|
||||
|
||||
class LiteModeEnabledResponse(BaseModel):
|
||||
"""Lite mode enabled setting."""
|
||||
|
||||
enabled: bool = False
|
||||
|
||||
|
||||
class LiteModeEnabledUpdate(BaseModel):
|
||||
"""Request to update lite mode setting."""
|
||||
|
||||
enabled: bool
|
||||
|
||||
|
||||
class AnalyticsCountersResponse(BaseModel):
|
||||
"""Analytics counter settings."""
|
||||
|
||||
@@ -294,7 +307,7 @@ async def update_branding_name(
|
||||
|
||||
await set_setting_value(db, BRANDING_NAME_KEY, name)
|
||||
|
||||
logger.info(f'Admin {admin.telegram_id} updated branding name to: {name}')
|
||||
logger.info('Admin updated branding name to', telegram_id=admin.telegram_id, name=name)
|
||||
|
||||
# Return updated branding
|
||||
custom_logo = has_custom_logo()
|
||||
@@ -355,7 +368,7 @@ async def upload_logo(
|
||||
# Mark that we have a custom logo
|
||||
await set_setting_value(db, BRANDING_LOGO_KEY, 'custom')
|
||||
|
||||
logger.info(f'Admin {admin.telegram_id} uploaded new logo: {logo_path}')
|
||||
logger.info('Admin uploaded new logo', telegram_id=admin.telegram_id, logo_path=logo_path)
|
||||
|
||||
# Get current name for response
|
||||
name = await get_setting_value(db, BRANDING_NAME_KEY)
|
||||
@@ -385,7 +398,7 @@ async def delete_logo(
|
||||
# Update setting
|
||||
await set_setting_value(db, BRANDING_LOGO_KEY, 'default')
|
||||
|
||||
logger.info(f'Admin {admin.telegram_id} deleted custom logo')
|
||||
logger.info('Admin deleted custom logo', telegram_id=admin.telegram_id)
|
||||
|
||||
# Get current name for response
|
||||
name = await get_setting_value(db, BRANDING_NAME_KEY)
|
||||
@@ -473,7 +486,7 @@ async def update_theme_colors(
|
||||
# Save to database
|
||||
await set_setting_value(db, THEME_COLORS_KEY, json.dumps(current_colors))
|
||||
|
||||
logger.info(f'Admin {admin.telegram_id} updated theme colors: {list(update_data.keys())}')
|
||||
logger.info('Admin updated theme colors', telegram_id=admin.telegram_id, value=list(update_data.keys()))
|
||||
|
||||
return ThemeColorsResponse(**current_colors)
|
||||
|
||||
@@ -487,7 +500,7 @@ async def reset_theme_colors(
|
||||
# Save default colors
|
||||
await set_setting_value(db, THEME_COLORS_KEY, json.dumps(DEFAULT_THEME_COLORS))
|
||||
|
||||
logger.info(f'Admin {admin.telegram_id} reset theme colors to defaults')
|
||||
logger.info('Admin reset theme colors to defaults', telegram_id=admin.telegram_id)
|
||||
|
||||
return ThemeColorsResponse(**DEFAULT_THEME_COLORS)
|
||||
|
||||
@@ -545,7 +558,7 @@ async def update_enabled_themes(
|
||||
# Save to database
|
||||
await set_setting_value(db, ENABLED_THEMES_KEY, json.dumps(current_themes))
|
||||
|
||||
logger.info(f'Admin {admin.telegram_id} updated enabled themes: {current_themes}')
|
||||
logger.info('Admin updated enabled themes', telegram_id=admin.telegram_id, current_themes=current_themes)
|
||||
|
||||
return EnabledThemesResponse(**current_themes)
|
||||
|
||||
@@ -580,7 +593,7 @@ async def update_animation_enabled(
|
||||
"""Update animation enabled setting. Admin only."""
|
||||
await set_setting_value(db, ANIMATION_ENABLED_KEY, str(payload.enabled).lower())
|
||||
|
||||
logger.info(f'Admin {admin.telegram_id} set animation enabled: {payload.enabled}')
|
||||
logger.info('Admin set animation enabled', telegram_id=admin.telegram_id, enabled=payload.enabled)
|
||||
|
||||
return AnimationEnabledResponse(enabled=payload.enabled)
|
||||
|
||||
@@ -615,7 +628,7 @@ async def update_fullscreen_enabled(
|
||||
"""Update fullscreen enabled setting. Admin only."""
|
||||
await set_setting_value(db, FULLSCREEN_ENABLED_KEY, str(payload.enabled).lower())
|
||||
|
||||
logger.info(f'Admin {admin.telegram_id} set fullscreen enabled: {payload.enabled}')
|
||||
logger.info('Admin set fullscreen enabled', telegram_id=admin.telegram_id, enabled=payload.enabled)
|
||||
|
||||
return FullscreenEnabledResponse(enabled=payload.enabled)
|
||||
|
||||
@@ -651,7 +664,7 @@ async def update_email_auth_enabled(
|
||||
"""Update email auth enabled setting. Admin only."""
|
||||
await set_setting_value(db, EMAIL_AUTH_ENABLED_KEY, str(payload.enabled).lower())
|
||||
|
||||
logger.info(f'Admin {admin.telegram_id} set email auth enabled: {payload.enabled}')
|
||||
logger.info('Admin set email auth enabled', telegram_id=admin.telegram_id, enabled=payload.enabled)
|
||||
|
||||
return EmailAuthEnabledResponse(enabled=payload.enabled)
|
||||
|
||||
@@ -706,7 +719,7 @@ async def update_analytics_counters(
|
||||
if payload.google_ads_label is not None:
|
||||
await set_setting_value(db, GOOGLE_ADS_LABEL_KEY, payload.google_ads_label.strip())
|
||||
|
||||
logger.info(f'Admin {admin.telegram_id} updated analytics counters')
|
||||
logger.info('Admin updated analytics counters', telegram_id=admin.telegram_id)
|
||||
|
||||
# Return current state
|
||||
yandex_id = await get_setting_value(db, YANDEX_METRIKA_ID_KEY) or ''
|
||||
@@ -718,3 +731,39 @@ async def update_analytics_counters(
|
||||
google_ads_id=google_id,
|
||||
google_ads_label=google_label,
|
||||
)
|
||||
|
||||
|
||||
# ============ Lite Mode Routes ============
|
||||
|
||||
|
||||
@router.get('/lite-mode', response_model=LiteModeEnabledResponse)
|
||||
async def get_lite_mode_enabled(
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""
|
||||
Get lite mode enabled setting.
|
||||
This is a public endpoint - no authentication required.
|
||||
When enabled, shows simplified dashboard with minimal features.
|
||||
"""
|
||||
lite_mode_value = await get_setting_value(db, LITE_MODE_ENABLED_KEY)
|
||||
|
||||
if lite_mode_value is not None:
|
||||
enabled = lite_mode_value.lower() == 'true'
|
||||
return LiteModeEnabledResponse(enabled=enabled)
|
||||
|
||||
# Default: disabled
|
||||
return LiteModeEnabledResponse(enabled=False)
|
||||
|
||||
|
||||
@router.patch('/lite-mode', response_model=LiteModeEnabledResponse)
|
||||
async def update_lite_mode_enabled(
|
||||
payload: LiteModeEnabledUpdate,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update lite mode enabled setting. Admin only."""
|
||||
await set_setting_value(db, LITE_MODE_ENABLED_KEY, str(payload.enabled).lower())
|
||||
|
||||
logger.info('Admin set lite mode enabled', telegram_id=admin.telegram_id, enabled=payload.enabled)
|
||||
|
||||
return LiteModeEnabledResponse(enabled=payload.enabled)
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"""Contests routes for cabinet - user participation in games/contests."""
|
||||
|
||||
import logging
|
||||
import random
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -30,7 +30,7 @@ from app.services.contest_rotation_service import (
|
||||
from ..dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/contests', tags=['Cabinet Contests'])
|
||||
|
||||
@@ -102,11 +102,11 @@ async def _award_prize(db: AsyncSession, user_id: int, prize_type: str, prize_va
|
||||
return 'Error: subscription not found'
|
||||
|
||||
subscription.end_date = subscription.end_date + timedelta(days=days)
|
||||
subscription.updated_at = datetime.utcnow()
|
||||
subscription.updated_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
logger.info(f'🎁 Extended subscription for user {user_id} by {days} days (contest prize)')
|
||||
logger.info('🎁 Extended subscription for user by days (contest prize)', user_id=user_id, days=days)
|
||||
return f'Subscription extended by {days} days'
|
||||
|
||||
if prize_type == 'balance':
|
||||
@@ -125,10 +125,10 @@ async def _award_prize(db: AsyncSession, user_id: int, prize_type: str, prize_va
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
logger.info(f'🎁 Added {amount} to balance for user {user_id} (contest prize)')
|
||||
logger.info('🎁 Added to balance for user (contest prize)', amount=amount, user_id=user_id)
|
||||
return f'Balance increased by {amount}'
|
||||
|
||||
logger.warning(f'Unknown prize type: {prize_type}')
|
||||
logger.warning('Unknown prize type', prize_type=prize_type)
|
||||
return f"Prize type '{prize_type}' not supported"
|
||||
|
||||
|
||||
|
||||
+41
-12
@@ -1,7 +1,6 @@
|
||||
"""Info pages routes for cabinet - FAQ, rules, privacy policy, etc."""
|
||||
|
||||
import logging
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -16,10 +15,34 @@ from app.services.public_offer_service import PublicOfferService
|
||||
from ..dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/info', tags=['Cabinet Info'])
|
||||
|
||||
_LANGUAGE_META: dict[str, tuple[str, str]] = {
|
||||
'ru': ('Русский', '🇷🇺'),
|
||||
'en': ('English', '🇬🇧'),
|
||||
'ua': ('Українська', '🇺🇦'),
|
||||
'zh': ('中文', '🇨🇳'),
|
||||
'fa': ('فارسی', '🇮🇷'),
|
||||
}
|
||||
|
||||
|
||||
def _normalize_language_code(value: str | None) -> str:
|
||||
return (value or '').strip().lower().split('-', 1)[0]
|
||||
|
||||
|
||||
def _get_available_language_codes() -> list[str]:
|
||||
codes: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for code in settings.get_available_languages():
|
||||
normalized = _normalize_language_code(code)
|
||||
if not normalized or normalized in seen:
|
||||
continue
|
||||
seen.add(normalized)
|
||||
codes.append(normalized)
|
||||
return codes
|
||||
|
||||
|
||||
# ============ Schemas ============
|
||||
|
||||
@@ -212,12 +235,19 @@ async def get_service_info():
|
||||
@router.get('/languages')
|
||||
async def get_available_languages():
|
||||
"""Get list of available languages."""
|
||||
codes = _get_available_language_codes()
|
||||
default_language = _normalize_language_code(getattr(settings, 'DEFAULT_LANGUAGE', 'ru') or 'ru')
|
||||
|
||||
return {
|
||||
'languages': [
|
||||
{'code': 'ru', 'name': 'Русский', 'flag': '🇷🇺'},
|
||||
{'code': 'en', 'name': 'English', 'flag': '🇬🇧'},
|
||||
{
|
||||
'code': code,
|
||||
'name': _LANGUAGE_META.get(code, (code.upper(), '🌐'))[0],
|
||||
'flag': _LANGUAGE_META.get(code, (code.upper(), '🌐'))[1],
|
||||
}
|
||||
for code in codes
|
||||
],
|
||||
'default': getattr(settings, 'DEFAULT_LANGUAGE', 'ru') or 'ru',
|
||||
'default': default_language,
|
||||
}
|
||||
|
||||
|
||||
@@ -236,16 +266,15 @@ async def update_user_language(
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update user's language preference."""
|
||||
language = request.get('language', 'ru')
|
||||
|
||||
valid_languages = ['ru', 'en']
|
||||
if language not in valid_languages:
|
||||
requested_language = _normalize_language_code(request.get('language', 'ru'))
|
||||
available_languages = _get_available_language_codes()
|
||||
if requested_language not in available_languages:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Invalid language. Supported: {", ".join(valid_languages)}',
|
||||
detail=f'Invalid language. Supported: {", ".join(available_languages)}',
|
||||
)
|
||||
|
||||
user.language = language
|
||||
user.language = requested_language
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""Media upload/download routes for cabinet tickets."""
|
||||
|
||||
import logging
|
||||
import mimetypes
|
||||
|
||||
import structlog
|
||||
from aiogram import Bot
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
@@ -16,7 +16,7 @@ from app.database.models import User
|
||||
from ..dependencies import get_current_cabinet_user
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/media', tags=['Cabinet Media'])
|
||||
|
||||
@@ -125,7 +125,12 @@ async def upload_media(
|
||||
|
||||
media_url = _build_media_url(request, media.file_id)
|
||||
|
||||
logger.info(f'User {user.telegram_id} uploaded {media_type_normalized}: {media.file_id}')
|
||||
logger.info(
|
||||
'User uploaded',
|
||||
telegram_id=user.telegram_id,
|
||||
media_type_normalized=media_type_normalized,
|
||||
file_id=media.file_id,
|
||||
)
|
||||
|
||||
return MediaUploadResponse(
|
||||
media_type=media_type_normalized,
|
||||
@@ -136,7 +141,7 @@ async def upload_media(
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as error:
|
||||
logger.error(f'Failed to upload media for user {user.telegram_id}: {error}')
|
||||
logger.error('Failed to upload media for user', telegram_id=user.telegram_id, error=error)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to upload media',
|
||||
@@ -187,7 +192,7 @@ async def download_media(
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as error:
|
||||
logger.error(f'Failed to download media {file_id}: {error}')
|
||||
logger.error('Failed to download media', file_id=file_id, error=error)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to download media',
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"""Notification settings routes for cabinet."""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -13,7 +13,7 @@ from app.database.models import User
|
||||
from ..dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/notifications', tags=['Cabinet Notifications'])
|
||||
|
||||
@@ -112,7 +112,7 @@ async def update_notification_settings(
|
||||
user.notification_settings = {}
|
||||
|
||||
user.notification_settings = new_settings
|
||||
user.updated_at = datetime.utcnow()
|
||||
user.updated_at = datetime.now(UTC)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
"""OAuth 2.0 authentication routes for cabinet."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.user import (
|
||||
create_user_by_oauth,
|
||||
get_user_by_email,
|
||||
get_user_by_oauth_provider,
|
||||
set_user_oauth_provider_id,
|
||||
)
|
||||
from app.database.models import User
|
||||
|
||||
from ..auth.oauth_providers import (
|
||||
OAuthUserInfo,
|
||||
generate_oauth_state,
|
||||
get_provider,
|
||||
validate_oauth_state,
|
||||
)
|
||||
from ..dependencies import get_cabinet_db
|
||||
from ..schemas.auth import AuthResponse
|
||||
from .auth import _create_auth_response, _process_campaign_bonus, _store_refresh_token
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/auth/oauth', tags=['Cabinet OAuth'])
|
||||
|
||||
|
||||
async def _finalize_oauth_login(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
provider: str,
|
||||
campaign_slug: str | None = None,
|
||||
) -> AuthResponse:
|
||||
"""Update last login, create tokens, store refresh token."""
|
||||
user.cabinet_last_login = datetime.now(UTC)
|
||||
await db.commit()
|
||||
auth_response = _create_auth_response(user)
|
||||
await _store_refresh_token(db, user.id, auth_response.refresh_token, device_info=f'oauth:{provider}')
|
||||
auth_response.campaign_bonus = await _process_campaign_bonus(db, user, campaign_slug)
|
||||
if auth_response.campaign_bonus:
|
||||
from .auth import _user_to_response
|
||||
|
||||
auth_response.user = _user_to_response(user)
|
||||
return auth_response
|
||||
|
||||
|
||||
# --- Schemas ---
|
||||
|
||||
|
||||
class OAuthProviderInfo(BaseModel):
|
||||
name: str
|
||||
display_name: str
|
||||
|
||||
|
||||
class OAuthProvidersResponse(BaseModel):
|
||||
providers: list[OAuthProviderInfo]
|
||||
|
||||
|
||||
class OAuthAuthorizeResponse(BaseModel):
|
||||
authorize_url: str
|
||||
state: str
|
||||
|
||||
|
||||
class OAuthCallbackRequest(BaseModel):
|
||||
code: str = Field(..., description='Authorization code from provider')
|
||||
state: str = Field(..., description='CSRF state token')
|
||||
campaign_slug: str | None = Field(
|
||||
None, min_length=1, max_length=64, pattern=r'^[a-zA-Z0-9_-]+$', description='Campaign slug from web link'
|
||||
)
|
||||
|
||||
|
||||
# --- Endpoints ---
|
||||
|
||||
|
||||
@router.get('/providers', response_model=OAuthProvidersResponse)
|
||||
async def get_oauth_providers():
|
||||
"""Get list of enabled OAuth providers."""
|
||||
providers_config = settings.get_oauth_providers_config()
|
||||
providers = [
|
||||
OAuthProviderInfo(name=name, display_name=cfg['display_name'])
|
||||
for name, cfg in providers_config.items()
|
||||
if cfg['enabled']
|
||||
]
|
||||
return OAuthProvidersResponse(providers=providers)
|
||||
|
||||
|
||||
@router.get('/{provider}/authorize', response_model=OAuthAuthorizeResponse)
|
||||
async def get_oauth_authorize_url(provider: str):
|
||||
"""Get authorization URL for an OAuth provider."""
|
||||
oauth_provider = get_provider(provider)
|
||||
if not oauth_provider:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'OAuth provider "{provider}" is not enabled',
|
||||
)
|
||||
|
||||
state = await generate_oauth_state(provider)
|
||||
authorize_url = oauth_provider.get_authorization_url(state)
|
||||
|
||||
return OAuthAuthorizeResponse(authorize_url=authorize_url, state=state)
|
||||
|
||||
|
||||
@router.post('/{provider}/callback', response_model=AuthResponse)
|
||||
async def oauth_callback(
|
||||
provider: str,
|
||||
request: OAuthCallbackRequest,
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Handle OAuth callback: exchange code, find/create user, return JWT."""
|
||||
# 1. Validate CSRF state
|
||||
if not await validate_oauth_state(request.state, provider):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Invalid or expired OAuth state',
|
||||
)
|
||||
|
||||
# 2. Get provider instance
|
||||
oauth_provider = get_provider(provider)
|
||||
if not oauth_provider:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'OAuth provider "{provider}" is not enabled',
|
||||
)
|
||||
|
||||
# 3. Exchange code for tokens
|
||||
try:
|
||||
token_data = await oauth_provider.exchange_code(request.code)
|
||||
except Exception as exc:
|
||||
logger.error('OAuth code exchange failed for', provider=provider, exc=exc)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Failed to exchange authorization code',
|
||||
) from exc
|
||||
|
||||
# 4. Fetch user info from provider
|
||||
try:
|
||||
user_info: OAuthUserInfo = await oauth_provider.get_user_info(token_data)
|
||||
except Exception as exc:
|
||||
logger.error('OAuth user info fetch failed for', provider=provider, exc=exc)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Failed to fetch user information from provider',
|
||||
) from exc
|
||||
|
||||
# 5. Find user by provider ID
|
||||
user = await get_user_by_oauth_provider(db, provider, user_info.provider_id)
|
||||
if user:
|
||||
logger.info('OAuth login via for existing user', provider=provider, user_id=user.id)
|
||||
return await _finalize_oauth_login(db, user, provider, request.campaign_slug)
|
||||
|
||||
# 6. Find user by email (if verified) and link provider
|
||||
if user_info.email and user_info.email_verified:
|
||||
user = await get_user_by_email(db, user_info.email)
|
||||
if user:
|
||||
await set_user_oauth_provider_id(db, user, provider, user_info.provider_id)
|
||||
logger.info('OAuth login via linked to existing email user', provider=provider, user_id=user.id)
|
||||
return await _finalize_oauth_login(db, user, provider, request.campaign_slug)
|
||||
|
||||
# 7. Create new user
|
||||
user = await create_user_by_oauth(
|
||||
db=db,
|
||||
provider=provider,
|
||||
provider_id=user_info.provider_id,
|
||||
email=user_info.email if user_info.email_verified else None,
|
||||
email_verified=user_info.email_verified,
|
||||
first_name=user_info.first_name,
|
||||
last_name=user_info.last_name,
|
||||
username=user_info.username,
|
||||
)
|
||||
logger.info('OAuth new user created via with id', provider=provider, user_id=user.id)
|
||||
return await _finalize_oauth_login(db, user, provider, request.campaign_slug)
|
||||
@@ -0,0 +1,162 @@
|
||||
"""User-facing partner application routes for cabinet."""
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import AdvertisingCampaign, User
|
||||
from app.services.partner_application_service import partner_application_service
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
from ..schemas.partners import (
|
||||
PartnerApplicationInfo,
|
||||
PartnerApplicationRequest,
|
||||
PartnerCampaignInfo,
|
||||
PartnerStatusResponse,
|
||||
)
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/referral/partner', tags=['Cabinet Partner'])
|
||||
|
||||
|
||||
def _get_campaign_deep_link(start_parameter: str) -> str | None:
|
||||
"""Generate Telegram deep link for campaign."""
|
||||
bot_username = settings.get_bot_username()
|
||||
if bot_username:
|
||||
return f'https://t.me/{bot_username}?start={start_parameter}'
|
||||
return None
|
||||
|
||||
|
||||
def _get_campaign_web_link(start_parameter: str) -> str | None:
|
||||
"""Generate web link for campaign."""
|
||||
base_url = (settings.MINIAPP_CUSTOM_URL or '').rstrip('/')
|
||||
if base_url:
|
||||
return f'{base_url}/?campaign={start_parameter}'
|
||||
return None
|
||||
|
||||
|
||||
@router.get('/status', response_model=PartnerStatusResponse)
|
||||
async def get_partner_status(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get partner status and latest application for current user."""
|
||||
latest_app = await partner_application_service.get_latest_application(db, user.id)
|
||||
|
||||
app_info = None
|
||||
if latest_app:
|
||||
app_info = PartnerApplicationInfo(
|
||||
id=latest_app.id,
|
||||
status=latest_app.status,
|
||||
company_name=latest_app.company_name,
|
||||
website_url=latest_app.website_url,
|
||||
telegram_channel=latest_app.telegram_channel,
|
||||
description=latest_app.description,
|
||||
expected_monthly_referrals=latest_app.expected_monthly_referrals,
|
||||
admin_comment=latest_app.admin_comment,
|
||||
approved_commission_percent=latest_app.approved_commission_percent,
|
||||
created_at=latest_app.created_at,
|
||||
processed_at=latest_app.processed_at,
|
||||
)
|
||||
|
||||
commission = user.referral_commission_percent
|
||||
if commission is None and user.is_partner:
|
||||
commission = settings.REFERRAL_COMMISSION_PERCENT
|
||||
|
||||
# Fetch campaigns assigned to this partner
|
||||
campaigns: list[PartnerCampaignInfo] = []
|
||||
if user.is_partner:
|
||||
result = await db.execute(
|
||||
select(AdvertisingCampaign).where(
|
||||
AdvertisingCampaign.partner_user_id == user.id,
|
||||
AdvertisingCampaign.is_active.is_(True),
|
||||
)
|
||||
)
|
||||
for c in result.scalars().all():
|
||||
campaigns.append(
|
||||
PartnerCampaignInfo(
|
||||
id=c.id,
|
||||
name=c.name,
|
||||
start_parameter=c.start_parameter,
|
||||
bonus_type=c.bonus_type,
|
||||
balance_bonus_kopeks=c.balance_bonus_kopeks or 0,
|
||||
subscription_duration_days=c.subscription_duration_days,
|
||||
subscription_traffic_gb=c.subscription_traffic_gb,
|
||||
deep_link=_get_campaign_deep_link(c.start_parameter),
|
||||
web_link=_get_campaign_web_link(c.start_parameter),
|
||||
)
|
||||
)
|
||||
|
||||
return PartnerStatusResponse(
|
||||
partner_status=user.partner_status,
|
||||
commission_percent=commission,
|
||||
latest_application=app_info,
|
||||
campaigns=campaigns,
|
||||
)
|
||||
|
||||
|
||||
@router.post('/apply', response_model=PartnerApplicationInfo)
|
||||
async def apply_for_partner(
|
||||
request: PartnerApplicationRequest,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Submit partner application."""
|
||||
application, error = await partner_application_service.submit_application(
|
||||
db,
|
||||
user_id=user.id,
|
||||
company_name=request.company_name,
|
||||
website_url=request.website_url,
|
||||
telegram_channel=request.telegram_channel,
|
||||
description=request.description,
|
||||
expected_monthly_referrals=request.expected_monthly_referrals,
|
||||
)
|
||||
|
||||
if not application:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=error,
|
||||
)
|
||||
|
||||
# Уведомляем админов о новой заявке
|
||||
try:
|
||||
from aiogram import Bot
|
||||
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
if getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) and settings.BOT_TOKEN:
|
||||
bot = Bot(token=settings.BOT_TOKEN)
|
||||
try:
|
||||
notification_service = AdminNotificationService(bot)
|
||||
await notification_service.send_partner_application_notification(
|
||||
user=user,
|
||||
application_data={
|
||||
'company_name': request.company_name,
|
||||
'telegram_channel': request.telegram_channel,
|
||||
'website_url': request.website_url,
|
||||
'description': request.description,
|
||||
'expected_monthly_referrals': request.expected_monthly_referrals,
|
||||
},
|
||||
)
|
||||
finally:
|
||||
await bot.session.close()
|
||||
except Exception as e:
|
||||
logger.error('Failed to send admin notification for partner application', error=e)
|
||||
|
||||
return PartnerApplicationInfo(
|
||||
id=application.id,
|
||||
status=application.status,
|
||||
company_name=application.company_name,
|
||||
website_url=application.website_url,
|
||||
telegram_channel=application.telegram_channel,
|
||||
description=application.description,
|
||||
expected_monthly_referrals=application.expected_monthly_referrals,
|
||||
admin_comment=application.admin_comment,
|
||||
approved_commission_percent=application.approved_commission_percent,
|
||||
created_at=application.created_at,
|
||||
processed_at=application.processed_at,
|
||||
)
|
||||
@@ -1,8 +1,8 @@
|
||||
"""Polls routes for cabinet - user participation in polls/surveys."""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
@@ -20,7 +20,7 @@ from app.services.poll_service import get_next_question, get_question_option, re
|
||||
from ..dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/polls', tags=['Cabinet Polls'])
|
||||
|
||||
@@ -247,7 +247,7 @@ async def start_poll(
|
||||
|
||||
# Mark as started if not already
|
||||
if not response.started_at:
|
||||
response.started_at = datetime.utcnow()
|
||||
response.started_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
|
||||
# Get next unanswered question
|
||||
@@ -346,7 +346,7 @@ async def answer_question(
|
||||
)
|
||||
|
||||
# Poll completed
|
||||
response.completed_at = datetime.utcnow()
|
||||
response.completed_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
|
||||
# Award reward if any
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"""Promo offers routes for cabinet - personal discounts and offers."""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import and_, select
|
||||
@@ -22,7 +22,7 @@ from app.services.promo_offer_service import promo_offer_service
|
||||
from ..dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/promo', tags=['Cabinet Promo'])
|
||||
|
||||
@@ -112,7 +112,7 @@ async def get_promo_offers(
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get list of available promo offers for the user."""
|
||||
now = datetime.utcnow()
|
||||
now = datetime.now(UTC)
|
||||
|
||||
result = await db.execute(
|
||||
select(DiscountOffer)
|
||||
@@ -151,7 +151,7 @@ async def get_active_discount(
|
||||
expires_at = user.promo_offer_discount_expires_at
|
||||
source = user.promo_offer_discount_source
|
||||
|
||||
now = datetime.utcnow()
|
||||
now = datetime.now(UTC)
|
||||
is_active = discount_percent > 0 and (expires_at is None or expires_at > now)
|
||||
|
||||
return ActiveDiscountInfo(
|
||||
@@ -284,7 +284,7 @@ async def claim_promo_offer(
|
||||
detail='Offer not found',
|
||||
)
|
||||
|
||||
now = datetime.utcnow()
|
||||
now = datetime.now(UTC)
|
||||
|
||||
if offer.claimed_at is not None:
|
||||
raise HTTPException(
|
||||
@@ -408,7 +408,7 @@ async def clear_active_discount(
|
||||
user.promo_offer_discount_percent = 0
|
||||
user.promo_offer_discount_source = None
|
||||
user.promo_offer_discount_expires_at = None
|
||||
user.updated_at = datetime.utcnow()
|
||||
user.updated_at = datetime.now(UTC)
|
||||
|
||||
await db.commit()
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Promo code routes for cabinet."""
|
||||
|
||||
import logging
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -12,7 +11,7 @@ from app.services.promocode_service import PromoCodeService
|
||||
from ..dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/promocode', tags=['Cabinet Promocode'])
|
||||
|
||||
@@ -33,6 +32,15 @@ class PromocodeActivateResponse(BaseModel):
|
||||
bonus_description: str | None = None
|
||||
|
||||
|
||||
class PromocodeDeactivateResponse(BaseModel):
|
||||
"""Response after deactivating a discount promo code."""
|
||||
|
||||
success: bool
|
||||
message: str
|
||||
deactivated_code: str | None = None
|
||||
discount_percent: int = 0
|
||||
|
||||
|
||||
@router.post('/activate', response_model=PromocodeActivateResponse)
|
||||
async def activate_promocode(
|
||||
request: PromocodeActivateRequest,
|
||||
@@ -62,6 +70,8 @@ async def activate_promocode(
|
||||
'expired': 'Promo code has expired',
|
||||
'used': 'Promo code has been fully used',
|
||||
'already_used_by_user': 'You have already used this promo code',
|
||||
'active_discount_exists': 'You already have an active discount. Deactivate it first via /deactivate-discount',
|
||||
'not_first_purchase': 'This promo code is only available for first purchase',
|
||||
'user_not_found': 'User not found',
|
||||
'server_error': 'Server error occurred',
|
||||
}
|
||||
@@ -73,3 +83,41 @@ async def activate_promocode(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=error_message,
|
||||
)
|
||||
|
||||
|
||||
@router.post('/deactivate-discount', response_model=PromocodeDeactivateResponse)
|
||||
async def deactivate_discount_promocode(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> PromocodeDeactivateResponse:
|
||||
"""Deactivate the currently active discount promo code for the current user."""
|
||||
promocode_service = PromoCodeService()
|
||||
|
||||
result = await promocode_service.deactivate_discount_promocode(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
admin_initiated=False,
|
||||
)
|
||||
|
||||
if result['success']:
|
||||
return PromocodeDeactivateResponse(
|
||||
success=True,
|
||||
message='Discount promo code deactivated successfully',
|
||||
deactivated_code=result.get('deactivated_code'),
|
||||
discount_percent=result.get('discount_percent', 0),
|
||||
)
|
||||
|
||||
error_messages = {
|
||||
'user_not_found': 'User not found',
|
||||
'no_active_discount_promocode': 'No active discount promo code found',
|
||||
'discount_already_expired': 'Discount has already expired',
|
||||
'server_error': 'Server error occurred',
|
||||
}
|
||||
|
||||
error_code = result.get('error', 'server_error')
|
||||
error_message = error_messages.get(error_code, 'Failed to deactivate promo code')
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=error_message,
|
||||
)
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
"""Referral program routes for cabinet."""
|
||||
|
||||
import logging
|
||||
import math
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import desc, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import ReferralEarning, User
|
||||
from app.database.models import AdvertisingCampaign, ReferralEarning, User
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
from ..schemas.referral import (
|
||||
@@ -22,7 +22,7 @@ from ..schemas.referral import (
|
||||
)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/referral', tags=['Cabinet Referral'])
|
||||
|
||||
@@ -150,12 +150,26 @@ async def get_referral_earnings(
|
||||
result = await db.execute(query)
|
||||
earnings = result.scalars().all()
|
||||
|
||||
# Batch-fetch referral users to avoid N+1
|
||||
referral_ids = list({e.referral_id for e in earnings if e.referral_id})
|
||||
if referral_ids:
|
||||
referral_users_result = await db.execute(select(User).where(User.id.in_(referral_ids)))
|
||||
referral_users_map = {u.id: u for u in referral_users_result.scalars().all()}
|
||||
else:
|
||||
referral_users_map = {}
|
||||
|
||||
# Batch-fetch campaigns to avoid N+1
|
||||
campaign_ids = list({e.campaign_id for e in earnings if e.campaign_id})
|
||||
if campaign_ids:
|
||||
campaigns_result = await db.execute(select(AdvertisingCampaign).where(AdvertisingCampaign.id.in_(campaign_ids)))
|
||||
campaigns_map = {c.id: c for c in campaigns_result.scalars().all()}
|
||||
else:
|
||||
campaigns_map = {}
|
||||
|
||||
items = []
|
||||
for e in earnings:
|
||||
# Get referral user info
|
||||
referral_query = select(User).where(User.id == e.referral_id)
|
||||
referral_result = await db.execute(referral_query)
|
||||
referral_user = referral_result.scalar_one_or_none()
|
||||
referral_user = referral_users_map.get(e.referral_id) if e.referral_id else None
|
||||
campaign = campaigns_map.get(e.campaign_id) if e.campaign_id else None
|
||||
|
||||
items.append(
|
||||
ReferralEarningResponse(
|
||||
@@ -165,6 +179,7 @@ async def get_referral_earnings(
|
||||
reason=e.reason or 'Referral commission',
|
||||
referral_username=referral_user.username if referral_user else None,
|
||||
referral_first_name=referral_user.first_name if referral_user else None,
|
||||
campaign_name=campaign.name if campaign else None,
|
||||
created_at=e.created_at,
|
||||
)
|
||||
)
|
||||
@@ -194,4 +209,5 @@ async def get_referral_terms():
|
||||
first_topup_bonus_rubles=settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS / 100,
|
||||
inviter_bonus_kopeks=settings.REFERRAL_INVITER_BONUS_KOPEKS,
|
||||
inviter_bonus_rubles=settings.REFERRAL_INVITER_BONUS_KOPEKS / 100,
|
||||
partner_section_visible=settings.REFERRAL_PARTNER_SECTION_VISIBLE,
|
||||
)
|
||||
|
||||
+1417
-249
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,8 @@
|
||||
"""Ticket notifications routes for cabinet."""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -13,7 +13,7 @@ from app.database.models import User
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user, get_current_cabinet_user
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/tickets/notifications', tags=['Cabinet Ticket Notifications'])
|
||||
admin_router = APIRouter(prefix='/admin/tickets/notifications', tags=['Cabinet Admin Ticket Notifications'])
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"""Support tickets routes for cabinet."""
|
||||
|
||||
import logging
|
||||
import math
|
||||
from datetime import datetime
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import desc, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -26,7 +26,7 @@ from ..schemas.tickets import (
|
||||
)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/tickets', tags=['Cabinet Tickets'])
|
||||
|
||||
@@ -137,8 +137,8 @@ async def create_ticket(
|
||||
title=request.title,
|
||||
status='open',
|
||||
priority='normal',
|
||||
created_at=datetime.utcnow(),
|
||||
updated_at=datetime.utcnow(),
|
||||
created_at=datetime.now(UTC),
|
||||
updated_at=datetime.now(UTC),
|
||||
)
|
||||
db.add(ticket)
|
||||
await db.flush()
|
||||
@@ -152,7 +152,7 @@ async def create_ticket(
|
||||
media_type=request.media_type,
|
||||
media_file_id=request.media_file_id,
|
||||
media_caption=request.media_caption,
|
||||
created_at=datetime.utcnow(),
|
||||
created_at=datetime.now(UTC),
|
||||
)
|
||||
db.add(message)
|
||||
await db.commit()
|
||||
@@ -164,7 +164,7 @@ async def create_ticket(
|
||||
try:
|
||||
await notify_admins_about_new_ticket(ticket, db)
|
||||
except Exception as e:
|
||||
logger.error(f'Error notifying admins about new ticket from cabinet: {e}')
|
||||
logger.error('Error notifying admins about new ticket from cabinet', error=e)
|
||||
|
||||
# Уведомить админов в кабинете
|
||||
try:
|
||||
@@ -173,7 +173,7 @@ async def create_ticket(
|
||||
# Отправить WebSocket уведомление
|
||||
await notify_admins_new_ticket(ticket.id, ticket.title, user.id)
|
||||
except Exception as e:
|
||||
logger.error(f'Error creating cabinet notification for new ticket: {e}')
|
||||
logger.error('Error creating cabinet notification for new ticket', error=e)
|
||||
|
||||
messages = [_message_to_response(m) for m in ticket.messages]
|
||||
|
||||
@@ -268,14 +268,14 @@ async def add_ticket_message(
|
||||
media_type=request.media_type,
|
||||
media_file_id=request.media_file_id,
|
||||
media_caption=request.media_caption,
|
||||
created_at=datetime.utcnow(),
|
||||
created_at=datetime.now(UTC),
|
||||
)
|
||||
db.add(message)
|
||||
|
||||
# Update ticket status and timestamp
|
||||
if ticket.status == 'answered':
|
||||
ticket.status = 'pending'
|
||||
ticket.updated_at = datetime.utcnow()
|
||||
ticket.updated_at = datetime.now(UTC)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(message)
|
||||
@@ -284,7 +284,7 @@ async def add_ticket_message(
|
||||
try:
|
||||
await notify_admins_about_ticket_reply(ticket, request.message, db)
|
||||
except Exception as e:
|
||||
logger.error(f'Error notifying admins about ticket reply from cabinet: {e}')
|
||||
logger.error('Error notifying admins about ticket reply from cabinet', error=e)
|
||||
|
||||
# Уведомить админов в кабинете
|
||||
try:
|
||||
@@ -295,6 +295,6 @@ async def add_ticket_message(
|
||||
# Отправить WebSocket уведомление
|
||||
await notify_admins_ticket_reply(ticket.id, (request.message or '')[:100], user.id)
|
||||
except Exception as e:
|
||||
logger.error(f'Error creating cabinet notification for user reply: {e}')
|
||||
logger.error('Error creating cabinet notification for user reply', error=e)
|
||||
|
||||
return _message_to_response(message)
|
||||
|
||||
@@ -4,8 +4,8 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
|
||||
from app.cabinet.auth.jwt_handler import get_token_payload
|
||||
@@ -14,7 +14,7 @@ from app.database.crud.user import get_user_by_id
|
||||
from app.database.database import AsyncSessionLocal
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -42,10 +42,10 @@ class CabinetConnectionManager:
|
||||
self._admin_connections[user_id].add(websocket)
|
||||
|
||||
logger.debug(
|
||||
'Cabinet WS connected: user_id=%d, is_admin=%s, total_users=%d',
|
||||
user_id,
|
||||
is_admin,
|
||||
len(self._user_connections),
|
||||
'Cabinet WS connected: user_id is_admin total_users',
|
||||
user_id=user_id,
|
||||
is_admin=is_admin,
|
||||
user_connections_count=len(self._user_connections),
|
||||
)
|
||||
|
||||
async def disconnect(self, websocket: WebSocket, user_id: int) -> None:
|
||||
@@ -61,7 +61,7 @@ class CabinetConnectionManager:
|
||||
if not self._admin_connections[user_id]:
|
||||
del self._admin_connections[user_id]
|
||||
|
||||
logger.debug('Cabinet WS disconnected: user_id=%d', user_id)
|
||||
logger.debug('Cabinet WS disconnected: user_id', user_id=user_id)
|
||||
|
||||
async def send_to_user(self, user_id: int, message: dict) -> None:
|
||||
"""Отправить сообщение конкретному пользователю."""
|
||||
@@ -79,7 +79,7 @@ class CabinetConnectionManager:
|
||||
try:
|
||||
await ws.send_text(data)
|
||||
except Exception as e:
|
||||
logger.warning('Failed to send to user %d: %s', user_id, e)
|
||||
logger.warning('Failed to send to user', user_id=user_id, e=e)
|
||||
disconnected.add(ws)
|
||||
|
||||
# Cleanup disconnected
|
||||
@@ -105,7 +105,7 @@ class CabinetConnectionManager:
|
||||
try:
|
||||
await ws.send_text(data)
|
||||
except Exception as e:
|
||||
logger.warning('Failed to send to admin %d: %s', user_id, e)
|
||||
logger.warning('Failed to send to admin', user_id=user_id, e=e)
|
||||
if user_id not in disconnected_by_user:
|
||||
disconnected_by_user[user_id] = set()
|
||||
disconnected_by_user[user_id].add(ws)
|
||||
@@ -152,7 +152,7 @@ async def verify_cabinet_ws_token(token: str) -> tuple[int | None, bool]:
|
||||
)
|
||||
return user_id, is_admin
|
||||
except (TimeoutError, OSError, ConnectionRefusedError) as e:
|
||||
logger.error('Database connection error in WS token verification: %s', str(e)[:200])
|
||||
logger.error('Database connection error in WS token verification', e=str(e)[:200])
|
||||
return None, False
|
||||
|
||||
|
||||
@@ -165,7 +165,7 @@ async def cabinet_websocket_endpoint(websocket: WebSocket):
|
||||
token = websocket.query_params.get('token')
|
||||
|
||||
if not token:
|
||||
logger.debug('Cabinet WS: No token from %s', client_host)
|
||||
logger.debug('Cabinet WS: No token from', client_host=client_host)
|
||||
# Принимаем и сразу закрываем с кодом ошибки
|
||||
await websocket.accept()
|
||||
await websocket.close(code=1008, reason='Unauthorized: No token')
|
||||
@@ -175,7 +175,7 @@ async def cabinet_websocket_endpoint(websocket: WebSocket):
|
||||
user_id, is_admin = await verify_cabinet_ws_token(token)
|
||||
|
||||
if not user_id:
|
||||
logger.debug('Cabinet WS: Invalid token from %s', client_host)
|
||||
logger.debug('Cabinet WS: Invalid token from', client_host=client_host)
|
||||
# Принимаем и сразу закрываем с кодом ошибки
|
||||
await websocket.accept()
|
||||
await websocket.close(code=1008, reason='Unauthorized: Invalid token')
|
||||
@@ -184,9 +184,9 @@ async def cabinet_websocket_endpoint(websocket: WebSocket):
|
||||
# Принимаем соединение
|
||||
try:
|
||||
await websocket.accept()
|
||||
logger.debug('Cabinet WS accepted: user_id=%d, is_admin=%s', user_id, is_admin)
|
||||
logger.debug('Cabinet WS accepted: user_id is_admin', user_id=user_id, is_admin=is_admin)
|
||||
except Exception as e:
|
||||
logger.error('Cabinet WS: Failed to accept from %s: %s', client_host, e)
|
||||
logger.error('Cabinet WS: Failed to accept from', client_host=client_host, e=e)
|
||||
return
|
||||
|
||||
# Регистрируем подключение
|
||||
@@ -213,17 +213,17 @@ async def cabinet_websocket_endpoint(websocket: WebSocket):
|
||||
await websocket.send_json({'type': 'pong'})
|
||||
|
||||
except json.JSONDecodeError:
|
||||
logger.warning('Cabinet WS: Invalid JSON from user %d', user_id)
|
||||
logger.warning('Cabinet WS: Invalid JSON from user', user_id=user_id)
|
||||
except WebSocketDisconnect:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.exception('Cabinet WS error for user %d: %s', user_id, e)
|
||||
logger.exception('Cabinet WS error for user', user_id=user_id, e=e)
|
||||
break
|
||||
|
||||
except WebSocketDisconnect:
|
||||
logger.debug('Cabinet WS disconnected: user_id=%d', user_id)
|
||||
logger.debug('Cabinet WS disconnected: user_id', user_id=user_id)
|
||||
except Exception as e:
|
||||
logger.exception('Cabinet WS error: %s', e)
|
||||
logger.exception('Cabinet WS error', e=e)
|
||||
finally:
|
||||
await cabinet_ws_manager.disconnect(websocket, user_id)
|
||||
|
||||
@@ -374,6 +374,44 @@ async def notify_user_subscription_renewed(
|
||||
)
|
||||
|
||||
|
||||
async def notify_user_devices_purchased(
|
||||
user_id: int,
|
||||
devices_added: int,
|
||||
new_device_limit: int,
|
||||
amount_kopeks: int,
|
||||
) -> None:
|
||||
"""Уведомить пользователя о покупке устройств."""
|
||||
await cabinet_ws_manager.send_to_user(
|
||||
user_id,
|
||||
{
|
||||
'type': 'subscription.devices_purchased',
|
||||
'devices_added': devices_added,
|
||||
'new_device_limit': new_device_limit,
|
||||
'amount_kopeks': amount_kopeks,
|
||||
'amount_rubles': amount_kopeks / 100,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def notify_user_traffic_purchased(
|
||||
user_id: int,
|
||||
traffic_gb_added: int,
|
||||
new_traffic_limit_gb: int,
|
||||
amount_kopeks: int,
|
||||
) -> None:
|
||||
"""Уведомить пользователя о покупке трафика."""
|
||||
await cabinet_ws_manager.send_to_user(
|
||||
user_id,
|
||||
{
|
||||
'type': 'subscription.traffic_purchased',
|
||||
'traffic_gb_added': traffic_gb_added,
|
||||
'new_traffic_limit_gb': new_traffic_limit_gb,
|
||||
'amount_kopeks': amount_kopeks,
|
||||
'amount_rubles': amount_kopeks / 100,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Уведомления об автопродлении
|
||||
# ============================================================================
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
API роуты колеса удачи для пользователей.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import math
|
||||
import time
|
||||
|
||||
import httpx
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -32,7 +32,7 @@ from app.database.models import User
|
||||
from app.services.wheel_service import wheel_service
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/wheel', tags=['Fortune Wheel'])
|
||||
|
||||
@@ -253,14 +253,16 @@ async def create_stars_invoice(
|
||||
result = response.json()
|
||||
|
||||
if not result.get('ok'):
|
||||
logger.error(f'Telegram API error: {result}')
|
||||
logger.error('Telegram API error', result=result)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Ошибка создания инвойса',
|
||||
)
|
||||
|
||||
invoice_url = result['result']
|
||||
logger.info(f'Created Stars invoice for wheel spin: user={user.id}, stars={stars_amount}')
|
||||
logger.info(
|
||||
'Created Stars invoice for wheel spin: user=, stars', user_id=user.id, stars_amount=stars_amount
|
||||
)
|
||||
|
||||
return StarsInvoiceResponse(
|
||||
invoice_url=invoice_url,
|
||||
@@ -268,7 +270,7 @@ async def create_stars_invoice(
|
||||
)
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f'HTTP error creating invoice: {e}')
|
||||
logger.error('HTTP error creating invoice', error=e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Ошибка соединения с Telegram',
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
"""User-facing withdrawal routes for cabinet."""
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import desc, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User, WithdrawalRequest, WithdrawalRequestStatus
|
||||
from app.services.referral_withdrawal_service import referral_withdrawal_service
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
from ..schemas.withdrawals import (
|
||||
WithdrawalBalanceResponse,
|
||||
WithdrawalCreateRequest,
|
||||
WithdrawalCreateResponse,
|
||||
WithdrawalItemResponse,
|
||||
WithdrawalListResponse,
|
||||
)
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/referral/withdrawal', tags=['Cabinet Withdrawal'])
|
||||
|
||||
|
||||
@router.get('/balance', response_model=WithdrawalBalanceResponse)
|
||||
async def get_withdrawal_balance(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get withdrawal balance stats for current user."""
|
||||
can_request, reason, stats = await referral_withdrawal_service.can_request_withdrawal(db, user.id)
|
||||
|
||||
return WithdrawalBalanceResponse(
|
||||
total_earned=stats['total_earned'],
|
||||
referral_spent=stats['referral_spent'],
|
||||
withdrawn=stats['withdrawn'],
|
||||
pending=stats['pending'],
|
||||
available_referral=stats['available_referral'],
|
||||
available_total=stats['available_total'],
|
||||
only_referral_mode=stats['only_referral_mode'],
|
||||
min_amount_kopeks=settings.REFERRAL_WITHDRAWAL_MIN_AMOUNT_KOPEKS,
|
||||
is_withdrawal_enabled=settings.is_referral_withdrawal_enabled(),
|
||||
can_request=can_request,
|
||||
cannot_request_reason=reason if not can_request else None,
|
||||
requisites_text=settings.REFERRAL_WITHDRAWAL_REQUISITES_TEXT,
|
||||
)
|
||||
|
||||
|
||||
@router.post('/create', response_model=WithdrawalCreateResponse)
|
||||
async def create_withdrawal(
|
||||
request: WithdrawalCreateRequest,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Create a withdrawal request."""
|
||||
withdrawal, error = await referral_withdrawal_service.create_withdrawal_request(
|
||||
db,
|
||||
user_id=user.id,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
payment_details=request.payment_details,
|
||||
)
|
||||
|
||||
if not withdrawal:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=error,
|
||||
)
|
||||
|
||||
# Уведомляем админов о запросе на вывод
|
||||
try:
|
||||
from aiogram import Bot
|
||||
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
if getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) and settings.BOT_TOKEN:
|
||||
bot = Bot(token=settings.BOT_TOKEN)
|
||||
try:
|
||||
notification_service = AdminNotificationService(bot)
|
||||
await notification_service.send_withdrawal_request_notification(
|
||||
user=user,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
payment_details=request.payment_details,
|
||||
)
|
||||
finally:
|
||||
await bot.session.close()
|
||||
except Exception as e:
|
||||
logger.error('Failed to send admin notification for withdrawal request', error=e)
|
||||
|
||||
return WithdrawalCreateResponse(
|
||||
id=withdrawal.id,
|
||||
amount_kopeks=withdrawal.amount_kopeks,
|
||||
status=withdrawal.status,
|
||||
)
|
||||
|
||||
|
||||
@router.get('/history', response_model=WithdrawalListResponse)
|
||||
async def get_withdrawal_history(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get user's withdrawal request history."""
|
||||
count_result = await db.execute(
|
||||
select(func.count()).select_from(WithdrawalRequest).where(WithdrawalRequest.user_id == user.id)
|
||||
)
|
||||
total = count_result.scalar() or 0
|
||||
|
||||
result = await db.execute(
|
||||
select(WithdrawalRequest)
|
||||
.where(WithdrawalRequest.user_id == user.id)
|
||||
.order_by(desc(WithdrawalRequest.created_at))
|
||||
.limit(50)
|
||||
)
|
||||
requests = result.scalars().all()
|
||||
|
||||
items = [
|
||||
WithdrawalItemResponse(
|
||||
id=r.id,
|
||||
amount_kopeks=r.amount_kopeks,
|
||||
amount_rubles=r.amount_kopeks / 100,
|
||||
status=r.status,
|
||||
payment_details=r.payment_details,
|
||||
admin_comment=r.admin_comment,
|
||||
created_at=r.created_at,
|
||||
processed_at=r.processed_at,
|
||||
)
|
||||
for r in requests
|
||||
]
|
||||
|
||||
return WithdrawalListResponse(items=items, total=total)
|
||||
|
||||
|
||||
@router.post('/{request_id}/cancel')
|
||||
async def cancel_withdrawal(
|
||||
request_id: int,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Cancel a pending withdrawal request."""
|
||||
result = await db.execute(
|
||||
select(WithdrawalRequest)
|
||||
.where(
|
||||
WithdrawalRequest.id == request_id,
|
||||
WithdrawalRequest.user_id == user.id,
|
||||
)
|
||||
.with_for_update()
|
||||
)
|
||||
withdrawal = result.scalar_one_or_none()
|
||||
|
||||
if not withdrawal:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Заявка не найдена',
|
||||
)
|
||||
|
||||
if withdrawal.status != WithdrawalRequestStatus.PENDING.value:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Можно отменить только заявку в ожидании',
|
||||
)
|
||||
|
||||
withdrawal.status = WithdrawalRequestStatus.CANCELLED.value
|
||||
await db.commit()
|
||||
|
||||
return {'success': True}
|
||||
@@ -9,6 +9,9 @@ class TelegramAuthRequest(BaseModel):
|
||||
"""Request for Telegram WebApp initData authentication."""
|
||||
|
||||
init_data: str = Field(..., description='Telegram WebApp initData string')
|
||||
campaign_slug: str | None = Field(
|
||||
None, min_length=1, max_length=64, pattern=r'^[a-zA-Z0-9_-]+$', description='Campaign slug from web link'
|
||||
)
|
||||
|
||||
|
||||
class TelegramWidgetAuthRequest(BaseModel):
|
||||
@@ -21,6 +24,9 @@ class TelegramWidgetAuthRequest(BaseModel):
|
||||
photo_url: str | None = Field(None, description="User's photo URL")
|
||||
auth_date: int = Field(..., description='Unix timestamp of authentication')
|
||||
hash: str = Field(..., description='Authentication hash')
|
||||
campaign_slug: str | None = Field(
|
||||
None, min_length=1, max_length=64, pattern=r'^[a-zA-Z0-9_-]+$', description='Campaign slug from web link'
|
||||
)
|
||||
|
||||
|
||||
class EmailRegisterRequest(BaseModel):
|
||||
@@ -34,6 +40,9 @@ class EmailVerifyRequest(BaseModel):
|
||||
"""Request to verify email with token."""
|
||||
|
||||
token: str = Field(..., description='Email verification token')
|
||||
campaign_slug: str | None = Field(
|
||||
None, min_length=1, max_length=64, pattern=r'^[a-zA-Z0-9_-]+$', description='Campaign slug from web link'
|
||||
)
|
||||
|
||||
|
||||
class EmailLoginRequest(BaseModel):
|
||||
@@ -41,6 +50,9 @@ class EmailLoginRequest(BaseModel):
|
||||
|
||||
email: EmailStr = Field(..., description='Email address')
|
||||
password: str = Field(..., description='Password')
|
||||
campaign_slug: str | None = Field(
|
||||
None, min_length=1, max_length=64, pattern=r'^[a-zA-Z0-9_-]+$', description='Campaign slug from web link'
|
||||
)
|
||||
|
||||
|
||||
class RefreshTokenRequest(BaseModel):
|
||||
@@ -102,6 +114,16 @@ class EmailRegisterStandaloneRequest(BaseModel):
|
||||
referral_code: str | None = Field(None, max_length=32, description='Referral code of inviter')
|
||||
|
||||
|
||||
class CampaignBonusInfo(BaseModel):
|
||||
"""Info about campaign bonus applied during auth."""
|
||||
|
||||
campaign_name: str
|
||||
bonus_type: str
|
||||
balance_kopeks: int = 0
|
||||
subscription_days: int | None = None
|
||||
tariff_name: str | None = None
|
||||
|
||||
|
||||
class AuthResponse(BaseModel):
|
||||
"""Full authentication response with tokens and user."""
|
||||
|
||||
@@ -110,6 +132,7 @@ class AuthResponse(BaseModel):
|
||||
token_type: str = 'bearer'
|
||||
expires_in: int
|
||||
user: UserResponse
|
||||
campaign_bonus: CampaignBonusInfo | None = None
|
||||
|
||||
|
||||
class RegisterResponse(BaseModel):
|
||||
@@ -118,3 +141,23 @@ class RegisterResponse(BaseModel):
|
||||
message: str = Field(..., description='Success message')
|
||||
email: str = Field(..., description='Email address to verify')
|
||||
requires_verification: bool = Field(True, description='Whether email verification is required')
|
||||
|
||||
|
||||
class EmailChangeRequest(BaseModel):
|
||||
"""Request to initiate email change."""
|
||||
|
||||
new_email: EmailStr = Field(..., description='New email address')
|
||||
|
||||
|
||||
class EmailChangeVerifyRequest(BaseModel):
|
||||
"""Request to verify email change with code."""
|
||||
|
||||
code: str = Field(..., min_length=6, max_length=6, description='6-digit verification code')
|
||||
|
||||
|
||||
class EmailChangeResponse(BaseModel):
|
||||
"""Response for email change initiation."""
|
||||
|
||||
message: str = Field(..., description='Success message')
|
||||
new_email: str = Field(..., description='New email address pending verification')
|
||||
expires_in_minutes: int = Field(..., description='Code expiration time in minutes')
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
"""Pydantic schemas for cabinet broadcasts."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# ============ Channel Types ============
|
||||
|
||||
BroadcastChannel = Literal['telegram', 'email', 'both']
|
||||
|
||||
|
||||
# ============ Filters ============
|
||||
|
||||
|
||||
@@ -100,7 +106,7 @@ class BroadcastResponse(BaseModel):
|
||||
|
||||
id: int
|
||||
target_type: str
|
||||
message_text: str
|
||||
message_text: str | None = None
|
||||
has_media: bool
|
||||
media_type: str | None = None
|
||||
media_file_id: str | None = None
|
||||
@@ -108,6 +114,7 @@ class BroadcastResponse(BaseModel):
|
||||
total_count: int
|
||||
sent_count: int
|
||||
failed_count: int
|
||||
blocked_count: int = 0
|
||||
status: str # queued|in_progress|completed|partial|failed|cancelled|cancelling
|
||||
admin_id: int | None = None
|
||||
admin_name: str | None = None
|
||||
@@ -115,6 +122,11 @@ class BroadcastResponse(BaseModel):
|
||||
completed_at: datetime | None = None
|
||||
progress_percent: float = 0.0
|
||||
|
||||
# Email/channel fields
|
||||
channel: str = 'telegram' # telegram|email|both
|
||||
email_subject: str | None = None
|
||||
email_html_content: str | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
@@ -142,3 +154,57 @@ class BroadcastPreviewResponse(BaseModel):
|
||||
|
||||
target: str
|
||||
count: int
|
||||
|
||||
|
||||
# ============ Email Filters ============
|
||||
|
||||
|
||||
class EmailFilterItem(BaseModel):
|
||||
"""Single email filter with count."""
|
||||
|
||||
key: str
|
||||
label: str
|
||||
count: int
|
||||
group: str | None = None
|
||||
|
||||
|
||||
class EmailFiltersResponse(BaseModel):
|
||||
"""Response with all email filters and their counts."""
|
||||
|
||||
filters: list[EmailFilterItem]
|
||||
total_with_email: int
|
||||
|
||||
|
||||
# ============ Combined Broadcast ============
|
||||
|
||||
|
||||
class CombinedBroadcastCreateRequest(BaseModel):
|
||||
"""Request to create a combined (telegram/email/both) broadcast."""
|
||||
|
||||
channel: BroadcastChannel
|
||||
target: str
|
||||
|
||||
# Telegram-specific fields
|
||||
message_text: str | None = Field(default=None, max_length=4000)
|
||||
selected_buttons: list[str] = Field(default_factory=lambda: ['home'])
|
||||
media: BroadcastMediaRequest | None = None
|
||||
|
||||
# Email-specific fields
|
||||
email_subject: str | None = Field(default=None, max_length=255)
|
||||
email_html_content: str | None = Field(default=None, max_length=100000)
|
||||
|
||||
|
||||
# ============ Email Preview ============
|
||||
|
||||
|
||||
class EmailPreviewRequest(BaseModel):
|
||||
"""Request to preview email broadcast recipients."""
|
||||
|
||||
target: str
|
||||
|
||||
|
||||
class EmailPreviewResponse(BaseModel):
|
||||
"""Preview response for email broadcast."""
|
||||
|
||||
target: str
|
||||
count: int
|
||||
|
||||
@@ -27,6 +27,8 @@ class CampaignListItem(BaseModel):
|
||||
registrations_count: int
|
||||
total_revenue_kopeks: int = 0
|
||||
conversion_rate: float = 0.0
|
||||
partner_user_id: int | None = None
|
||||
partner_name: str | None = None
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
@@ -60,12 +62,16 @@ class CampaignDetailResponse(BaseModel):
|
||||
tariff_id: int | None = None
|
||||
tariff_duration_days: int | None = None
|
||||
tariff: TariffInfo | None = None
|
||||
# Partner
|
||||
partner_user_id: int | None = None
|
||||
partner_name: str | None = None
|
||||
# Meta
|
||||
created_by: int | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime | None = None
|
||||
# Deep link
|
||||
deep_link: str | None = None
|
||||
web_link: str | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -75,7 +81,7 @@ class CampaignCreateRequest(BaseModel):
|
||||
"""Request to create a campaign."""
|
||||
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
start_parameter: str = Field(..., min_length=1, max_length=100, pattern=r'^[a-zA-Z0-9_-]+$')
|
||||
start_parameter: str = Field(..., min_length=1, max_length=64, pattern=r'^[a-zA-Z0-9_-]+$')
|
||||
bonus_type: CampaignBonusType
|
||||
is_active: bool = True
|
||||
# Balance bonus
|
||||
@@ -88,13 +94,15 @@ class CampaignCreateRequest(BaseModel):
|
||||
# Tariff bonus
|
||||
tariff_id: int | None = None
|
||||
tariff_duration_days: int | None = Field(None, ge=1)
|
||||
# Partner
|
||||
partner_user_id: int | None = None
|
||||
|
||||
|
||||
class CampaignUpdateRequest(BaseModel):
|
||||
"""Request to update a campaign."""
|
||||
|
||||
name: str | None = Field(None, min_length=1, max_length=255)
|
||||
start_parameter: str | None = Field(None, min_length=1, max_length=100, pattern=r'^[a-zA-Z0-9_-]+$')
|
||||
start_parameter: str | None = Field(None, min_length=1, max_length=64, pattern=r'^[a-zA-Z0-9_-]+$')
|
||||
bonus_type: CampaignBonusType | None = None
|
||||
is_active: bool | None = None
|
||||
# Balance bonus
|
||||
@@ -107,6 +115,8 @@ class CampaignUpdateRequest(BaseModel):
|
||||
# Tariff bonus
|
||||
tariff_id: int | None = None
|
||||
tariff_duration_days: int | None = Field(None, ge=1)
|
||||
# Partner
|
||||
partner_user_id: int | None = None
|
||||
|
||||
|
||||
class CampaignToggleResponse(BaseModel):
|
||||
@@ -147,6 +157,7 @@ class CampaignStatisticsResponse(BaseModel):
|
||||
trial_conversion_rate: float = 0.0
|
||||
# Deep link
|
||||
deep_link: str | None = None
|
||||
web_link: str | None = None
|
||||
|
||||
|
||||
class CampaignRegistrationItem(BaseModel):
|
||||
@@ -194,6 +205,14 @@ class CampaignsOverviewResponse(BaseModel):
|
||||
total_tariff_issued: int = 0
|
||||
|
||||
|
||||
class AvailablePartnerItem(BaseModel):
|
||||
"""Partner item for campaign partner selector."""
|
||||
|
||||
user_id: int
|
||||
username: str | None = None
|
||||
first_name: str | None = None
|
||||
|
||||
|
||||
class ServerSquadInfo(BaseModel):
|
||||
"""Server squad info for campaign selection."""
|
||||
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
"""Partner system schemas for cabinet."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# ==================== User-facing ====================
|
||||
|
||||
|
||||
class PartnerApplicationRequest(BaseModel):
|
||||
"""Request to apply for partner status."""
|
||||
|
||||
company_name: str | None = Field(None, max_length=255)
|
||||
website_url: str | None = Field(None, max_length=500)
|
||||
telegram_channel: str | None = Field(None, max_length=255)
|
||||
description: str | None = Field(None, max_length=2000)
|
||||
expected_monthly_referrals: int | None = Field(None, ge=0)
|
||||
|
||||
|
||||
class PartnerApplicationInfo(BaseModel):
|
||||
"""Application info for the user."""
|
||||
|
||||
id: int
|
||||
status: str
|
||||
company_name: str | None = None
|
||||
website_url: str | None = None
|
||||
telegram_channel: str | None = None
|
||||
description: str | None = None
|
||||
expected_monthly_referrals: int | None = None
|
||||
admin_comment: str | None = None
|
||||
approved_commission_percent: int | None = None
|
||||
created_at: datetime
|
||||
processed_at: datetime | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class PartnerCampaignInfo(BaseModel):
|
||||
"""Campaign info visible to the partner."""
|
||||
|
||||
id: int
|
||||
name: str
|
||||
start_parameter: str
|
||||
bonus_type: str
|
||||
balance_bonus_kopeks: int = 0
|
||||
subscription_duration_days: int | None = None
|
||||
subscription_traffic_gb: int | None = None
|
||||
deep_link: str | None = None
|
||||
web_link: str | None = None
|
||||
|
||||
|
||||
class PartnerStatusResponse(BaseModel):
|
||||
"""Partner status for current user."""
|
||||
|
||||
partner_status: str
|
||||
commission_percent: int | None = None
|
||||
latest_application: PartnerApplicationInfo | None = None
|
||||
campaigns: list[PartnerCampaignInfo] = []
|
||||
|
||||
|
||||
# ==================== Admin-facing ====================
|
||||
|
||||
|
||||
class AdminPartnerApplicationItem(BaseModel):
|
||||
"""Partner application in admin list."""
|
||||
|
||||
id: int
|
||||
user_id: int
|
||||
username: str | None = None
|
||||
first_name: str | None = None
|
||||
telegram_id: int | None = None
|
||||
company_name: str | None = None
|
||||
website_url: str | None = None
|
||||
telegram_channel: str | None = None
|
||||
description: str | None = None
|
||||
expected_monthly_referrals: int | None = None
|
||||
status: str
|
||||
admin_comment: str | None = None
|
||||
approved_commission_percent: int | None = None
|
||||
created_at: datetime
|
||||
processed_at: datetime | None = None
|
||||
|
||||
|
||||
class AdminPartnerApplicationsResponse(BaseModel):
|
||||
"""List of partner applications."""
|
||||
|
||||
items: list[AdminPartnerApplicationItem]
|
||||
total: int
|
||||
|
||||
|
||||
class AdminApproveRequest(BaseModel):
|
||||
"""Request to approve a partner application."""
|
||||
|
||||
commission_percent: int = Field(..., ge=1, le=100)
|
||||
comment: str | None = Field(None, max_length=2000)
|
||||
|
||||
|
||||
class AdminRejectRequest(BaseModel):
|
||||
"""Request to reject a partner application."""
|
||||
|
||||
comment: str | None = Field(None, max_length=2000)
|
||||
|
||||
|
||||
class AdminPartnerItem(BaseModel):
|
||||
"""Partner in admin list."""
|
||||
|
||||
user_id: int
|
||||
username: str | None = None
|
||||
first_name: str | None = None
|
||||
telegram_id: int | None = None
|
||||
commission_percent: int | None = None
|
||||
total_referrals: int = 0
|
||||
total_earnings_kopeks: int = 0
|
||||
balance_kopeks: int = 0
|
||||
partner_status: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class AdminPartnerListResponse(BaseModel):
|
||||
"""List of partners for admin."""
|
||||
|
||||
items: list[AdminPartnerItem]
|
||||
total: int
|
||||
|
||||
|
||||
class CampaignSummary(BaseModel):
|
||||
"""Campaign summary for partner detail."""
|
||||
|
||||
id: int
|
||||
name: str
|
||||
start_parameter: str
|
||||
is_active: bool
|
||||
|
||||
|
||||
class AdminPartnerDetailResponse(BaseModel):
|
||||
"""Detailed partner info for admin."""
|
||||
|
||||
user_id: int
|
||||
username: str | None = None
|
||||
first_name: str | None = None
|
||||
telegram_id: int | None = None
|
||||
commission_percent: int | None = None
|
||||
partner_status: str
|
||||
balance_kopeks: int = 0
|
||||
total_referrals: int = 0
|
||||
paid_referrals: int = 0
|
||||
active_referrals: int = 0
|
||||
earnings_all_time: int = 0
|
||||
earnings_today: int = 0
|
||||
earnings_week: int = 0
|
||||
earnings_month: int = 0
|
||||
conversion_to_paid: float = 0.0
|
||||
campaigns: list[CampaignSummary] = []
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class AdminUpdateCommissionRequest(BaseModel):
|
||||
"""Request to update partner commission."""
|
||||
|
||||
commission_percent: int = Field(..., ge=1, le=100)
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Pydantic schemas for cabinet pinned messages."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class PinnedMessageMedia(BaseModel):
|
||||
type: str = Field(pattern=r'^(photo|video)$')
|
||||
file_id: str = Field(..., min_length=1, max_length=255)
|
||||
|
||||
|
||||
class PinnedMessageCreateRequest(BaseModel):
|
||||
content: str = Field(..., min_length=1, max_length=4000)
|
||||
media: PinnedMessageMedia | None = None
|
||||
send_before_menu: bool = True
|
||||
send_on_every_start: bool = True
|
||||
broadcast: bool = False
|
||||
|
||||
|
||||
class PinnedMessageUpdateRequest(BaseModel):
|
||||
content: str | None = Field(None, max_length=4000)
|
||||
send_before_menu: bool | None = None
|
||||
send_on_every_start: bool | None = None
|
||||
media: PinnedMessageMedia | None = None
|
||||
|
||||
|
||||
class PinnedMessageSettingsRequest(BaseModel):
|
||||
send_before_menu: bool | None = None
|
||||
send_on_every_start: bool | None = None
|
||||
|
||||
|
||||
class PinnedMessageResponse(BaseModel):
|
||||
id: int
|
||||
content: str | None
|
||||
media_type: str | None = None
|
||||
media_file_id: str | None = None
|
||||
send_before_menu: bool
|
||||
send_on_every_start: bool
|
||||
is_active: bool
|
||||
created_by: int | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime | None = None
|
||||
|
||||
|
||||
class PinnedMessageBroadcastResponse(BaseModel):
|
||||
message: PinnedMessageResponse
|
||||
sent_count: int
|
||||
failed_count: int
|
||||
|
||||
|
||||
class PinnedMessageUnpinResponse(BaseModel):
|
||||
unpinned_count: int
|
||||
failed_count: int
|
||||
was_active: bool
|
||||
|
||||
|
||||
class PinnedMessageListResponse(BaseModel):
|
||||
items: list[PinnedMessageResponse]
|
||||
total: int
|
||||
limit: int
|
||||
offset: int
|
||||
@@ -47,6 +47,7 @@ class ReferralEarningResponse(BaseModel):
|
||||
reason: str
|
||||
referral_username: str | None = None
|
||||
referral_first_name: str | None = None
|
||||
campaign_name: str | None = None
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
@@ -76,3 +77,4 @@ class ReferralTermsResponse(BaseModel):
|
||||
first_topup_bonus_rubles: float
|
||||
inviter_bonus_kopeks: int
|
||||
inviter_bonus_rubles: float
|
||||
partner_section_visible: bool = True
|
||||
|
||||
@@ -24,7 +24,7 @@ class TrafficPurchaseInfo(BaseModel):
|
||||
progress_percent: float
|
||||
|
||||
|
||||
class SubscriptionResponse(BaseModel):
|
||||
class SubscriptionData(BaseModel):
|
||||
"""User subscription data."""
|
||||
|
||||
id: int
|
||||
@@ -56,11 +56,23 @@ class SubscriptionResponse(BaseModel):
|
||||
next_daily_charge_at: datetime | None = None # When next daily charge will happen
|
||||
tariff_id: int | None = None
|
||||
tariff_name: str | None = None
|
||||
traffic_reset_mode: str | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# Backward compatibility alias
|
||||
SubscriptionResponse = SubscriptionData
|
||||
|
||||
|
||||
class SubscriptionStatusResponse(BaseModel):
|
||||
"""Response for subscription status endpoint - handles users with and without subscription."""
|
||||
|
||||
has_subscription: bool
|
||||
subscription: SubscriptionData | None = None
|
||||
|
||||
|
||||
class RenewalOptionResponse(BaseModel):
|
||||
"""Available subscription renewal option."""
|
||||
|
||||
|
||||
@@ -194,6 +194,12 @@ class TariffUpdateRequest(BaseModel):
|
||||
traffic_reset_mode: str | None = None # DAY, WEEK, MONTH, NO_RESET, None = глобальная настройка
|
||||
|
||||
|
||||
class TariffSortOrderRequest(BaseModel):
|
||||
"""Request to reorder tariffs."""
|
||||
|
||||
tariff_ids: list[int] = Field(..., min_length=1, description='Ordered list of tariff IDs')
|
||||
|
||||
|
||||
class TariffToggleResponse(BaseModel):
|
||||
"""Response after toggling tariff."""
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Schemas for admin traffic usage."""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class TrafficNodeInfo(BaseModel):
|
||||
node_uuid: str
|
||||
node_name: str
|
||||
country_code: str
|
||||
|
||||
|
||||
class UserTrafficItem(BaseModel):
|
||||
user_id: int
|
||||
telegram_id: int | None
|
||||
username: str | None
|
||||
email: str | None
|
||||
full_name: str
|
||||
tariff_name: str | None
|
||||
subscription_status: str | None
|
||||
traffic_limit_gb: float
|
||||
device_limit: int
|
||||
node_traffic: dict[str, int] # {node_uuid: total_bytes}
|
||||
total_bytes: int
|
||||
|
||||
|
||||
class TrafficUsageResponse(BaseModel):
|
||||
items: list[UserTrafficItem]
|
||||
nodes: list[TrafficNodeInfo]
|
||||
total: int
|
||||
offset: int
|
||||
limit: int
|
||||
period_days: int
|
||||
available_tariffs: list[str]
|
||||
available_statuses: list[str]
|
||||
|
||||
|
||||
class UserTrafficEnrichment(BaseModel):
|
||||
devices_connected: int = 0
|
||||
total_spent_kopeks: int = 0
|
||||
subscription_start_date: str | None = None
|
||||
subscription_end_date: str | None = None
|
||||
last_node_name: str | None = None
|
||||
|
||||
|
||||
class TrafficEnrichmentResponse(BaseModel):
|
||||
data: dict[int, UserTrafficEnrichment]
|
||||
|
||||
|
||||
class ExportCsvRequest(BaseModel):
|
||||
period: int = Field(30, ge=1, le=30)
|
||||
start_date: str | None = None
|
||||
end_date: str | None = None
|
||||
tariffs: str | None = None
|
||||
statuses: str | None = None
|
||||
nodes: str | None = None
|
||||
total_threshold_gb: float | None = Field(None, ge=0, description='Total GB/day threshold for risk column')
|
||||
node_threshold_gb: float | None = Field(None, ge=0, description='Per-node GB/day threshold for risk column')
|
||||
|
||||
|
||||
class ExportCsvResponse(BaseModel):
|
||||
success: bool
|
||||
message: str
|
||||
@@ -39,6 +39,17 @@ class SortByEnum(str, Enum):
|
||||
# === User Subscription Info ===
|
||||
|
||||
|
||||
class TrafficPurchaseItem(BaseModel):
|
||||
"""Individual traffic purchase record."""
|
||||
|
||||
id: int
|
||||
traffic_gb: int
|
||||
expires_at: datetime
|
||||
created_at: datetime
|
||||
days_remaining: int
|
||||
is_expired: bool
|
||||
|
||||
|
||||
class UserSubscriptionInfo(BaseModel):
|
||||
"""User subscription information."""
|
||||
|
||||
@@ -55,6 +66,8 @@ class UserSubscriptionInfo(BaseModel):
|
||||
autopay_enabled: bool = False
|
||||
is_active: bool = False
|
||||
days_remaining: int = 0
|
||||
purchased_traffic_gb: int = 0
|
||||
traffic_purchases: list[TrafficPurchaseItem] = []
|
||||
|
||||
|
||||
class UserPromoGroupInfo(BaseModel):
|
||||
@@ -189,9 +202,58 @@ class UserDetailResponse(BaseModel):
|
||||
promo_offer_discount_source: str | None = None
|
||||
promo_offer_discount_expires_at: datetime | None = None
|
||||
|
||||
# Campaign
|
||||
campaign_name: str | None = None
|
||||
campaign_id: int | None = None
|
||||
|
||||
# Recent transactions
|
||||
recent_transactions: list[UserTransactionItem] = []
|
||||
|
||||
# Remnawave UUID
|
||||
remnawave_uuid: str | None = None
|
||||
|
||||
|
||||
# === Panel Info ===
|
||||
|
||||
|
||||
class UserPanelInfoResponse(BaseModel):
|
||||
"""Panel info for user from Remnawave."""
|
||||
|
||||
found: bool = False
|
||||
trojan_password: str | None = None
|
||||
vless_uuid: str | None = None
|
||||
ss_password: str | None = None
|
||||
subscription_url: str | None = None
|
||||
happ_link: str | None = None
|
||||
used_traffic_bytes: int = 0
|
||||
lifetime_used_traffic_bytes: int = 0
|
||||
traffic_limit_bytes: int = 0
|
||||
first_connected_at: datetime | None = None
|
||||
online_at: datetime | None = None
|
||||
last_connected_node_uuid: str | None = None
|
||||
last_connected_node_name: str | None = None
|
||||
|
||||
|
||||
# === Node Usage ===
|
||||
|
||||
|
||||
class UserNodeUsageItem(BaseModel):
|
||||
"""Per-node traffic usage item."""
|
||||
|
||||
node_uuid: str
|
||||
node_name: str
|
||||
country_code: str = ''
|
||||
total_bytes: int
|
||||
daily_bytes: list[int] = []
|
||||
|
||||
|
||||
class UserNodeUsageResponse(BaseModel):
|
||||
"""Node usage response with 30-day daily breakdown."""
|
||||
|
||||
items: list[UserNodeUsageItem]
|
||||
categories: list[str] = []
|
||||
period_days: int = 30
|
||||
|
||||
|
||||
# === User Actions ===
|
||||
|
||||
@@ -236,6 +298,12 @@ class UpdateSubscriptionRequest(BaseModel):
|
||||
# For toggle_autopay
|
||||
autopay_enabled: bool | None = Field(None, description='Enable/disable autopay')
|
||||
|
||||
# For add_traffic action
|
||||
traffic_gb: int | None = Field(None, ge=1, description='Traffic GB to add')
|
||||
|
||||
# For remove_traffic action
|
||||
traffic_purchase_id: int | None = Field(None, description='Traffic purchase ID to remove')
|
||||
|
||||
# For create new subscription
|
||||
is_trial: bool | None = Field(None, description='Is trial subscription')
|
||||
device_limit: int | None = Field(None, ge=1, description='Device limit')
|
||||
@@ -299,6 +367,56 @@ class UpdatePromoGroupResponse(BaseModel):
|
||||
message: str
|
||||
|
||||
|
||||
class UpdateReferralCommissionRequest(BaseModel):
|
||||
"""Request to update user referral commission percent."""
|
||||
|
||||
commission_percent: int | None = Field(
|
||||
None, ge=0, le=100, description='Referral commission percent (null for default)'
|
||||
)
|
||||
|
||||
|
||||
class UpdateReferralCommissionResponse(BaseModel):
|
||||
"""Response after referral commission update."""
|
||||
|
||||
success: bool
|
||||
old_commission_percent: int | None = None
|
||||
new_commission_percent: int | None = None
|
||||
message: str
|
||||
|
||||
|
||||
class DeviceInfo(BaseModel):
|
||||
"""Individual device info."""
|
||||
|
||||
hwid: str
|
||||
platform: str = ''
|
||||
device_model: str = ''
|
||||
created_at: str | None = None
|
||||
|
||||
|
||||
class UserDevicesResponse(BaseModel):
|
||||
"""User devices from panel."""
|
||||
|
||||
devices: list[DeviceInfo] = []
|
||||
total: int = 0
|
||||
device_limit: int = 0
|
||||
|
||||
|
||||
class DeleteDeviceResponse(BaseModel):
|
||||
"""Response after device deletion."""
|
||||
|
||||
success: bool
|
||||
message: str
|
||||
deleted_hwid: str | None = None
|
||||
|
||||
|
||||
class ResetDevicesResponse(BaseModel):
|
||||
"""Response after resetting all devices."""
|
||||
|
||||
success: bool
|
||||
message: str
|
||||
deleted_count: int = 0
|
||||
|
||||
|
||||
class DeleteUserRequest(BaseModel):
|
||||
"""Request to delete user."""
|
||||
|
||||
@@ -392,6 +510,15 @@ class UserAvailableTariffItem(BaseModel):
|
||||
min_days: int = 1
|
||||
max_days: int = 365
|
||||
|
||||
# Device limits
|
||||
device_price_kopeks: int | None = None
|
||||
max_device_limit: int | None = None
|
||||
|
||||
# Traffic topup
|
||||
traffic_topup_enabled: bool = False
|
||||
traffic_topup_packages: dict[str, int] = {}
|
||||
max_topup_traffic_gb: int = 0
|
||||
|
||||
# Access info
|
||||
is_available: bool = True # Available for this user's promo group
|
||||
requires_promo_group: bool = False # Requires specific promo group
|
||||
@@ -498,3 +625,72 @@ class PanelSyncStatusResponse(BaseModel):
|
||||
# Differences
|
||||
has_differences: bool = False
|
||||
differences: list[str] = []
|
||||
|
||||
|
||||
# === Admin User Management Actions ===
|
||||
|
||||
|
||||
class FullDeleteUserRequest(BaseModel):
|
||||
"""Request for full user deletion (bot + panel)."""
|
||||
|
||||
delete_from_panel: bool = Field(default=True, description='Also delete user from Remnawave panel')
|
||||
reason: str | None = Field(None, max_length=500, description='Reason for deletion')
|
||||
|
||||
|
||||
class FullDeleteUserResponse(BaseModel):
|
||||
"""Response after full user deletion."""
|
||||
|
||||
success: bool
|
||||
message: str
|
||||
deleted_from_bot: bool = False
|
||||
deleted_from_panel: bool = False
|
||||
panel_error: str | None = None
|
||||
|
||||
|
||||
class ResetTrialRequest(BaseModel):
|
||||
"""Request to reset user trial."""
|
||||
|
||||
reason: str | None = Field(None, max_length=500, description='Reason for trial reset')
|
||||
|
||||
|
||||
class ResetTrialResponse(BaseModel):
|
||||
"""Response after trial reset."""
|
||||
|
||||
success: bool
|
||||
message: str
|
||||
subscription_deleted: bool = False
|
||||
has_used_trial_reset: bool = False
|
||||
|
||||
|
||||
class ResetSubscriptionRequest(BaseModel):
|
||||
"""Request to reset user subscription."""
|
||||
|
||||
deactivate_in_panel: bool = Field(default=True, description='Also deactivate in Remnawave panel')
|
||||
reason: str | None = Field(None, max_length=500, description='Reason for subscription reset')
|
||||
|
||||
|
||||
class ResetSubscriptionResponse(BaseModel):
|
||||
"""Response after subscription reset."""
|
||||
|
||||
success: bool
|
||||
message: str
|
||||
subscription_deleted: bool = False
|
||||
panel_deactivated: bool = False
|
||||
panel_error: str | None = None
|
||||
|
||||
|
||||
class DisableUserRequest(BaseModel):
|
||||
"""Request to disable user."""
|
||||
|
||||
reason: str | None = Field(None, max_length=500, description='Reason for disabling')
|
||||
|
||||
|
||||
class DisableUserResponse(BaseModel):
|
||||
"""Response after user disable."""
|
||||
|
||||
success: bool
|
||||
message: str
|
||||
subscription_deactivated: bool = False
|
||||
panel_deactivated: bool = False
|
||||
user_blocked: bool = False
|
||||
panel_error: str | None = None
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
"""Withdrawal system schemas for cabinet."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# ==================== User-facing ====================
|
||||
|
||||
|
||||
class WithdrawalBalanceResponse(BaseModel):
|
||||
"""Withdrawal balance info for user."""
|
||||
|
||||
total_earned: int
|
||||
referral_spent: int
|
||||
withdrawn: int
|
||||
pending: int
|
||||
available_referral: int
|
||||
available_total: int
|
||||
only_referral_mode: bool
|
||||
min_amount_kopeks: int
|
||||
is_withdrawal_enabled: bool
|
||||
can_request: bool
|
||||
cannot_request_reason: str | None = None
|
||||
requisites_text: str = ''
|
||||
|
||||
|
||||
class WithdrawalCreateRequest(BaseModel):
|
||||
"""Request to create a withdrawal."""
|
||||
|
||||
amount_kopeks: int = Field(..., gt=0, le=10_000_000)
|
||||
payment_details: str = Field(..., min_length=5, max_length=1000)
|
||||
|
||||
|
||||
class WithdrawalItemResponse(BaseModel):
|
||||
"""Withdrawal request item."""
|
||||
|
||||
id: int
|
||||
amount_kopeks: int
|
||||
amount_rubles: float
|
||||
status: str
|
||||
payment_details: str | None = None
|
||||
admin_comment: str | None = None
|
||||
created_at: datetime
|
||||
processed_at: datetime | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class WithdrawalListResponse(BaseModel):
|
||||
"""List of user's withdrawal requests."""
|
||||
|
||||
items: list[WithdrawalItemResponse]
|
||||
total: int
|
||||
|
||||
|
||||
class WithdrawalCreateResponse(BaseModel):
|
||||
"""Response after creating withdrawal."""
|
||||
|
||||
id: int
|
||||
amount_kopeks: int
|
||||
status: str
|
||||
|
||||
|
||||
# ==================== Admin-facing ====================
|
||||
|
||||
|
||||
class AdminWithdrawalItem(BaseModel):
|
||||
"""Withdrawal request in admin list."""
|
||||
|
||||
id: int
|
||||
user_id: int
|
||||
username: str | None = None
|
||||
first_name: str | None = None
|
||||
telegram_id: int | None = None
|
||||
amount_kopeks: int
|
||||
amount_rubles: float
|
||||
status: str
|
||||
risk_score: int = 0
|
||||
risk_level: str = 'low'
|
||||
payment_details: str | None = None
|
||||
admin_comment: str | None = None
|
||||
created_at: datetime
|
||||
processed_at: datetime | None = None
|
||||
|
||||
|
||||
class AdminWithdrawalListResponse(BaseModel):
|
||||
"""List of withdrawal requests for admin."""
|
||||
|
||||
items: list[AdminWithdrawalItem]
|
||||
total: int
|
||||
pending_count: int = 0
|
||||
pending_total_kopeks: int = 0
|
||||
|
||||
|
||||
class AdminWithdrawalDetailResponse(BaseModel):
|
||||
"""Detailed withdrawal request for admin."""
|
||||
|
||||
id: int
|
||||
user_id: int
|
||||
username: str | None = None
|
||||
first_name: str | None = None
|
||||
telegram_id: int | None = None
|
||||
amount_kopeks: int
|
||||
amount_rubles: float
|
||||
status: str
|
||||
risk_score: int = 0
|
||||
risk_level: str = 'low'
|
||||
risk_analysis: dict | None = None
|
||||
payment_details: str | None = None
|
||||
admin_comment: str | None = None
|
||||
balance_kopeks: int = 0
|
||||
total_referrals: int = 0
|
||||
total_earnings_kopeks: int = 0
|
||||
created_at: datetime
|
||||
processed_at: datetime | None = None
|
||||
|
||||
|
||||
class AdminApproveWithdrawalRequest(BaseModel):
|
||||
"""Request to approve a withdrawal."""
|
||||
|
||||
comment: str | None = Field(None, max_length=2000)
|
||||
|
||||
|
||||
class AdminRejectWithdrawalRequest(BaseModel):
|
||||
"""Request to reject a withdrawal."""
|
||||
|
||||
comment: str | None = Field(None, max_length=2000)
|
||||
@@ -1,14 +1,15 @@
|
||||
"""Email service for sending verification and password reset emails."""
|
||||
|
||||
import logging
|
||||
import smtplib
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
|
||||
import structlog
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class EmailService:
|
||||
@@ -41,7 +42,7 @@ class EmailService:
|
||||
if smtp.has_extn('auth'):
|
||||
smtp.login(self.user, self.password)
|
||||
else:
|
||||
logger.debug(f'SMTP server {self.host} does not support AUTH, skipping authentication')
|
||||
logger.debug('SMTP server does not support AUTH, skipping authentication', host=self.host)
|
||||
|
||||
return smtp
|
||||
|
||||
@@ -94,11 +95,11 @@ class EmailService:
|
||||
with self._get_smtp_connection() as smtp:
|
||||
smtp.sendmail(self.from_email, to_email, msg.as_string())
|
||||
|
||||
logger.info(f'Email sent successfully to {to_email}')
|
||||
logger.info('Email sent successfully to', to_email=to_email)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to send email to {to_email}: {e}')
|
||||
logger.error('Failed to send email to', to_email=to_email, error=e)
|
||||
return False
|
||||
|
||||
def send_verification_email(
|
||||
@@ -119,7 +120,7 @@ class EmailService:
|
||||
verification_token: Verification token
|
||||
verification_url: Base URL for verification (token will be appended)
|
||||
username: User's name for personalization
|
||||
language: Language code (ru, en, zh, ua)
|
||||
language: Language code (ru, en, zh, ua, fa)
|
||||
custom_subject: Override subject from admin template
|
||||
custom_body_html: Override body HTML from admin template (already wrapped in base template)
|
||||
|
||||
@@ -174,6 +175,16 @@ class EmailService:
|
||||
'ignore': 'Якщо ви не створювали акаунт, просто проігноруйте цей лист.',
|
||||
'regards': 'З повагою,',
|
||||
},
|
||||
'fa': {
|
||||
'greeting': f'سلام{", " + username if username else ""}!',
|
||||
'subject': 'تایید آدرس ایمیل',
|
||||
'intro': 'از ثبتنام شما سپاسگزاریم! لطفاً با کلیک روی دکمه زیر ایمیل خود را تایید کنید:',
|
||||
'button': 'تایید ایمیل',
|
||||
'or_copy': 'یا این لینک را در مرورگر خود کپی و باز کنید:',
|
||||
'expires': f'این لینک تا {expire_hours} ساعت معتبر است.',
|
||||
'ignore': 'اگر شما این حساب را ایجاد نکردهاید، این ایمیل را نادیده بگیرید.',
|
||||
'regards': 'با احترام،',
|
||||
},
|
||||
}
|
||||
|
||||
t = texts.get(language, texts['ru'])
|
||||
@@ -236,7 +247,7 @@ class EmailService:
|
||||
reset_token: Password reset token
|
||||
reset_url: Base URL for password reset (token will be appended)
|
||||
username: User's name for personalization
|
||||
language: Language code (ru, en, zh, ua)
|
||||
language: Language code (ru, en, zh, ua, fa)
|
||||
custom_subject: Override subject from admin template
|
||||
custom_body_html: Override body HTML from admin template (already wrapped in base template)
|
||||
|
||||
@@ -291,6 +302,16 @@ class EmailService:
|
||||
'warning': "Якщо ви не запитували скидання пароля, проігноруйте цей лист або зв'яжіться з підтримкою.",
|
||||
'regards': 'З повагою,',
|
||||
},
|
||||
'fa': {
|
||||
'greeting': f'سلام{", " + username if username else ""}!',
|
||||
'subject': 'بازنشانی رمز عبور',
|
||||
'intro': 'درخواستی برای بازنشانی رمز عبور شما دریافت شد. برای تعیین رمز جدید روی دکمه زیر بزنید:',
|
||||
'button': 'بازنشانی رمز عبور',
|
||||
'or_copy': 'یا این لینک را در مرورگر خود کپی و باز کنید:',
|
||||
'expires': f'این لینک تا {expire_hours} ساعت معتبر است.',
|
||||
'warning': 'اگر شما درخواست بازنشانی رمز عبور ندادهاید، این ایمیل را نادیده بگیرید یا با پشتیبانی تماس بگیرید.',
|
||||
'regards': 'با احترام،',
|
||||
},
|
||||
}
|
||||
|
||||
t = texts.get(language, texts['ru'])
|
||||
@@ -336,6 +357,131 @@ class EmailService:
|
||||
|
||||
return self.send_email(to_email, subject, body_html)
|
||||
|
||||
def send_email_change_code(
|
||||
self,
|
||||
to_email: str,
|
||||
code: str,
|
||||
username: str | None = None,
|
||||
language: str = 'ru',
|
||||
custom_subject: str | None = None,
|
||||
custom_body_html: str | None = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Send email change verification code.
|
||||
|
||||
Args:
|
||||
to_email: New email address
|
||||
code: 6-digit verification code
|
||||
username: User's name for personalization
|
||||
language: Language code (ru, en, zh, ua, fa)
|
||||
custom_subject: Override subject from admin template
|
||||
custom_body_html: Override body HTML from admin template
|
||||
|
||||
Returns:
|
||||
True if email was sent successfully, False otherwise
|
||||
"""
|
||||
if custom_subject and custom_body_html:
|
||||
return self.send_email(to_email, custom_subject, custom_body_html)
|
||||
|
||||
expire_minutes = settings.get_cabinet_email_change_code_expire_minutes()
|
||||
|
||||
texts = {
|
||||
'ru': {
|
||||
'greeting': f'Здравствуйте{", " + username if username else ""}!',
|
||||
'subject': 'Код подтверждения для смены email',
|
||||
'intro': 'Вы запросили смену email адреса. Используйте код ниже для подтверждения:',
|
||||
'code_label': 'Ваш код подтверждения:',
|
||||
'expires': f'Код действителен в течение {expire_minutes} минут.',
|
||||
'ignore': 'Если вы не запрашивали смену email, просто проигнорируйте это письмо.',
|
||||
'regards': 'С уважением,',
|
||||
},
|
||||
'en': {
|
||||
'greeting': f'Hello{", " + username if username else ""}!',
|
||||
'subject': 'Email change verification code',
|
||||
'intro': 'You requested to change your email address. Use the code below to confirm:',
|
||||
'code_label': 'Your verification code:',
|
||||
'expires': f'This code will expire in {expire_minutes} minutes.',
|
||||
'ignore': "If you didn't request an email change, you can safely ignore this email.",
|
||||
'regards': 'Best regards,',
|
||||
},
|
||||
'zh': {
|
||||
'greeting': f'您好{", " + username if username else ""}!',
|
||||
'subject': '邮箱更换验证码',
|
||||
'intro': '您请求更换邮箱地址。请使用以下验证码确认:',
|
||||
'code_label': '您的验证码:',
|
||||
'expires': f'此验证码将在 {expire_minutes} 分钟后过期。',
|
||||
'ignore': '如果您没有请求更换邮箱,请忽略此邮件。',
|
||||
'regards': '此致,',
|
||||
},
|
||||
'ua': {
|
||||
'greeting': f'Вітаємо{", " + username if username else ""}!',
|
||||
'subject': 'Код підтвердження для зміни email',
|
||||
'intro': 'Ви запросили зміну email адреси. Використовуйте код нижче для підтвердження:',
|
||||
'code_label': 'Ваш код підтвердження:',
|
||||
'expires': f'Код дійсний протягом {expire_minutes} хвилин.',
|
||||
'ignore': 'Якщо ви не запитували зміну email, просто проігноруйте цей лист.',
|
||||
'regards': 'З повагою,',
|
||||
},
|
||||
'fa': {
|
||||
'greeting': f'سلام{", " + username if username else ""}!',
|
||||
'subject': 'کد تایید تغییر ایمیل',
|
||||
'intro': 'شما درخواست تغییر ایمیل دادهاید. برای تایید از کد زیر استفاده کنید:',
|
||||
'code_label': 'کد تایید شما:',
|
||||
'expires': f'این کد تا {expire_minutes} دقیقه معتبر است.',
|
||||
'ignore': 'اگر شما درخواست تغییر ایمیل ندادهاید، این ایمیل را نادیده بگیرید.',
|
||||
'regards': 'با احترام،',
|
||||
},
|
||||
}
|
||||
|
||||
t = texts.get(language, texts['ru'])
|
||||
|
||||
subject = t['subject']
|
||||
body_html = f"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
body {{ font-family: Arial, sans-serif; line-height: 1.6; color: #333; }}
|
||||
.container {{ max-width: 600px; margin: 0 auto; padding: 20px; }}
|
||||
.code-box {{
|
||||
background-color: #f8f9fa;
|
||||
border: 2px solid #007bff;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
margin: 20px 0;
|
||||
}}
|
||||
.code {{
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
letter-spacing: 8px;
|
||||
color: #007bff;
|
||||
font-family: monospace;
|
||||
}}
|
||||
.footer {{ margin-top: 30px; font-size: 12px; color: #666; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h2>{t['greeting']}</h2>
|
||||
<p>{t['intro']}</p>
|
||||
<div class="code-box">
|
||||
<p>{t['code_label']}</p>
|
||||
<p class="code">{code}</p>
|
||||
</div>
|
||||
<p>{t['expires']}</p>
|
||||
<p>{t['ignore']}</p>
|
||||
<div class="footer">
|
||||
<p>{t['regards']}<br>{self.from_name}</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
return self.send_email(to_email, subject, body_html)
|
||||
|
||||
|
||||
# Singleton instance
|
||||
email_service = EmailService()
|
||||
|
||||
@@ -4,17 +4,17 @@ Service for managing email template overrides stored in the database.
|
||||
Custom templates override the hardcoded defaults from email_templates.py.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.database import AsyncSessionLocal
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def get_template_override(
|
||||
@@ -56,7 +56,9 @@ async def get_template_override(
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.debug('Не удалось получить override шаблона %s/%s: %s', notification_type, language, e)
|
||||
logger.debug(
|
||||
'Не удалось получить override шаблона /', notification_type=notification_type, language=language, e=e
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
@@ -122,7 +124,7 @@ async def save_template_override(
|
||||
)
|
||||
row = existing.fetchone()
|
||||
|
||||
now = datetime.utcnow()
|
||||
now = datetime.now(UTC)
|
||||
|
||||
if row:
|
||||
# Update
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"""
|
||||
Email notification templates for different notification types.
|
||||
|
||||
Supports multiple languages: ru, en, zh, ua
|
||||
Supports multiple languages: ru, en, zh, ua, fa
|
||||
"""
|
||||
|
||||
import html
|
||||
from typing import Any
|
||||
|
||||
from app.config import settings
|
||||
@@ -27,7 +28,7 @@ class EmailNotificationTemplates:
|
||||
|
||||
Args:
|
||||
notification_type: Type of notification
|
||||
language: Language code (ru, en, zh, ua)
|
||||
language: Language code (ru, en, zh, ua, fa)
|
||||
context: Context data for template rendering
|
||||
|
||||
Returns:
|
||||
@@ -53,6 +54,10 @@ class EmailNotificationTemplates:
|
||||
NotificationType.WARNING_NOTIFICATION: self._warning_template,
|
||||
NotificationType.REFERRAL_BONUS: self._referral_bonus_template,
|
||||
NotificationType.REFERRAL_REGISTERED: self._referral_registered_template,
|
||||
NotificationType.PARTNER_APPLICATION_APPROVED: self._partner_approved_template,
|
||||
NotificationType.PARTNER_APPLICATION_REJECTED: self._partner_rejected_template,
|
||||
NotificationType.WITHDRAWAL_APPROVED: self._withdrawal_approved_template,
|
||||
NotificationType.WITHDRAWAL_REJECTED: self._withdrawal_rejected_template,
|
||||
NotificationType.TRAFFIC_RESET: self._traffic_reset_template,
|
||||
NotificationType.PAYMENT_RECEIVED: self._payment_received_template,
|
||||
NotificationType.EMAIL_VERIFICATION: self._email_verification_template,
|
||||
@@ -72,6 +77,7 @@ class EmailNotificationTemplates:
|
||||
'en': 'This is an automated message. Please do not reply to this email.',
|
||||
'zh': '这是一封自动发送的邮件,请勿回复。',
|
||||
'ua': 'Це автоматичне повідомлення. Будь ласка, не відповідайте на цей лист.',
|
||||
'fa': 'این یک پیام خودکار است. لطفاً به این ایمیل پاسخ ندهید.',
|
||||
}
|
||||
footer_text = footer_texts.get(language, footer_texts['ru'])
|
||||
|
||||
@@ -182,6 +188,7 @@ class EmailNotificationTemplates:
|
||||
'en': 'Open Dashboard',
|
||||
'zh': '打开控制面板',
|
||||
'ua': 'Відкрити особистий кабінет',
|
||||
'fa': 'باز کردن پنل کاربری',
|
||||
}
|
||||
text = texts.get(language, texts['en'])
|
||||
|
||||
@@ -526,7 +533,7 @@ class EmailNotificationTemplates:
|
||||
|
||||
def _autopay_failed_template(self, language: str, context: dict[str, Any]) -> dict[str, str]:
|
||||
"""Template for failed autopay notification."""
|
||||
reason = context.get('reason', '')
|
||||
reason = html.escape(context.get('reason', ''))
|
||||
|
||||
subjects = {
|
||||
'ru': 'Ошибка автопродления',
|
||||
@@ -713,7 +720,7 @@ class EmailNotificationTemplates:
|
||||
|
||||
def _ban_template(self, language: str, context: dict[str, Any]) -> dict[str, str]:
|
||||
"""Template for ban notification."""
|
||||
reason = context.get('reason', '')
|
||||
reason = html.escape(context.get('reason', ''))
|
||||
|
||||
subjects = {
|
||||
'ru': 'Аккаунт заблокирован',
|
||||
@@ -781,7 +788,7 @@ class EmailNotificationTemplates:
|
||||
|
||||
def _warning_template(self, language: str, context: dict[str, Any]) -> dict[str, str]:
|
||||
"""Template for warning notification."""
|
||||
message = context.get('message', '')
|
||||
message = html.escape(context.get('message', ''))
|
||||
|
||||
subjects = {
|
||||
'ru': 'Предупреждение',
|
||||
@@ -817,7 +824,7 @@ class EmailNotificationTemplates:
|
||||
def _referral_bonus_template(self, language: str, context: dict[str, Any]) -> dict[str, str]:
|
||||
"""Template for referral bonus notification."""
|
||||
bonus = context.get('formatted_bonus', f'{context.get("bonus_rubles", 0):.2f} ₽')
|
||||
referral_name = context.get('referral_name', '')
|
||||
referral_name = html.escape(context.get('referral_name', ''))
|
||||
|
||||
subjects = {
|
||||
'ru': f'Реферальный бонус: +{bonus}',
|
||||
@@ -854,7 +861,7 @@ class EmailNotificationTemplates:
|
||||
|
||||
def _referral_registered_template(self, language: str, context: dict[str, Any]) -> dict[str, str]:
|
||||
"""Template for new referral registered notification."""
|
||||
referral_name = context.get('referral_name', '')
|
||||
referral_name = html.escape(context.get('referral_name', ''))
|
||||
|
||||
subjects = {
|
||||
'ru': 'Новый реферал зарегистрирован',
|
||||
@@ -887,6 +894,249 @@ class EmailNotificationTemplates:
|
||||
'body_html': self._get_base_template(bodies.get(language, bodies['ru']), language),
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Partner Templates
|
||||
# ============================================================================
|
||||
|
||||
def _partner_approved_template(self, language: str, context: dict[str, Any]) -> dict[str, str]:
|
||||
"""Template for partner application approved notification."""
|
||||
commission = context.get('commission_percent', 0)
|
||||
comment = html.escape(context.get('comment', ''))
|
||||
|
||||
subjects = {
|
||||
'ru': 'Заявка на партнёрство одобрена',
|
||||
'en': 'Partner Application Approved',
|
||||
'zh': '合作伙伴申请已批准',
|
||||
'ua': 'Заявка на партнерство схвалена',
|
||||
}
|
||||
|
||||
bodies = {
|
||||
'ru': f"""
|
||||
<h2>Заявка на партнёрство одобрена!</h2>
|
||||
<div class="highlight success">
|
||||
<p>Ваша заявка на партнёрство была одобрена.</p>
|
||||
<p>Ваша комиссия: <strong>{commission}%</strong></p>
|
||||
{f'<p>Комментарий: {comment}</p>' if comment else ''}
|
||||
</div>
|
||||
<p>Теперь вы можете приглашать пользователей и получать вознаграждение!</p>
|
||||
{self._get_cabinet_button(language)}
|
||||
""",
|
||||
'en': f"""
|
||||
<h2>Partner Application Approved!</h2>
|
||||
<div class="highlight success">
|
||||
<p>Your partner application has been approved.</p>
|
||||
<p>Your commission rate: <strong>{commission}%</strong></p>
|
||||
{f'<p>Comment: {comment}</p>' if comment else ''}
|
||||
</div>
|
||||
<p>You can now invite users and earn rewards!</p>
|
||||
{self._get_cabinet_button(language)}
|
||||
""",
|
||||
'zh': f"""
|
||||
<h2>合作伙伴申请已批准!</h2>
|
||||
<div class="highlight success">
|
||||
<p>您的合作伙伴申请已获批准。</p>
|
||||
<p>您的佣金比例: <strong>{commission}%</strong></p>
|
||||
{f'<p>备注: {comment}</p>' if comment else ''}
|
||||
</div>
|
||||
<p>您现在可以邀请用户并获得奖励!</p>
|
||||
{self._get_cabinet_button(language)}
|
||||
""",
|
||||
'ua': f"""
|
||||
<h2>Заявка на партнерство схвалена!</h2>
|
||||
<div class="highlight success">
|
||||
<p>Вашу заявку на партнерство було схвалено.</p>
|
||||
<p>Ваша комісія: <strong>{commission}%</strong></p>
|
||||
{f'<p>Коментар: {comment}</p>' if comment else ''}
|
||||
</div>
|
||||
<p>Тепер ви можете запрошувати користувачів та отримувати винагороду!</p>
|
||||
{self._get_cabinet_button(language)}
|
||||
""",
|
||||
}
|
||||
|
||||
return {
|
||||
'subject': subjects.get(language, subjects['ru']),
|
||||
'body_html': self._get_base_template(bodies.get(language, bodies['ru']), language),
|
||||
}
|
||||
|
||||
def _partner_rejected_template(self, language: str, context: dict[str, Any]) -> dict[str, str]:
|
||||
"""Template for partner application rejected notification."""
|
||||
comment = html.escape(context.get('comment', ''))
|
||||
|
||||
subjects = {
|
||||
'ru': 'Заявка на партнёрство отклонена',
|
||||
'en': 'Partner Application Rejected',
|
||||
'zh': '合作伙伴申请被拒绝',
|
||||
'ua': 'Заявка на партнерство відхилена',
|
||||
}
|
||||
|
||||
bodies = {
|
||||
'ru': f"""
|
||||
<h2>Заявка на партнёрство отклонена</h2>
|
||||
<div class="highlight danger">
|
||||
<p>К сожалению, ваша заявка на партнёрство была отклонена.</p>
|
||||
{f'<p>Причина: {comment}</p>' if comment else ''}
|
||||
</div>
|
||||
<p>Вы можете подать новую заявку позже.</p>
|
||||
{self._get_cabinet_button(language)}
|
||||
""",
|
||||
'en': f"""
|
||||
<h2>Partner Application Rejected</h2>
|
||||
<div class="highlight danger">
|
||||
<p>Unfortunately, your partner application has been rejected.</p>
|
||||
{f'<p>Reason: {comment}</p>' if comment else ''}
|
||||
</div>
|
||||
<p>You can submit a new application later.</p>
|
||||
{self._get_cabinet_button(language)}
|
||||
""",
|
||||
'zh': f"""
|
||||
<h2>合作伙伴申请被拒绝</h2>
|
||||
<div class="highlight danger">
|
||||
<p>很抱歉,您的合作伙伴申请已被拒绝。</p>
|
||||
{f'<p>原因: {comment}</p>' if comment else ''}
|
||||
</div>
|
||||
<p>您可以稍后提交新的申请。</p>
|
||||
{self._get_cabinet_button(language)}
|
||||
""",
|
||||
'ua': f"""
|
||||
<h2>Заявка на партнерство відхилена</h2>
|
||||
<div class="highlight danger">
|
||||
<p>На жаль, вашу заявку на партнерство було відхилено.</p>
|
||||
{f'<p>Причина: {comment}</p>' if comment else ''}
|
||||
</div>
|
||||
<p>Ви можете подати нову заявку пізніше.</p>
|
||||
{self._get_cabinet_button(language)}
|
||||
""",
|
||||
}
|
||||
|
||||
return {
|
||||
'subject': subjects.get(language, subjects['ru']),
|
||||
'body_html': self._get_base_template(bodies.get(language, bodies['ru']), language),
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Withdrawal Templates
|
||||
# ============================================================================
|
||||
|
||||
def _withdrawal_approved_template(self, language: str, context: dict[str, Any]) -> dict[str, str]:
|
||||
"""Template for withdrawal approved notification."""
|
||||
amount = context.get('formatted_amount', f'{context.get("amount_rubles", 0):.2f} ₽')
|
||||
comment = html.escape(context.get('comment', ''))
|
||||
|
||||
subjects = {
|
||||
'ru': f'Запрос на вывод {amount} одобрен',
|
||||
'en': f'Withdrawal request for {amount} approved',
|
||||
'zh': f'提现请求 {amount} 已批准',
|
||||
'ua': f'Запит на виведення {amount} схвалено',
|
||||
}
|
||||
|
||||
bodies = {
|
||||
'ru': f"""
|
||||
<h2>Запрос на вывод одобрен!</h2>
|
||||
<div class="highlight success">
|
||||
<p>Ваш запрос на вывод средств одобрен.</p>
|
||||
<p>Сумма: <span class="amount">{amount}</span></p>
|
||||
{f'<p>Комментарий: {comment}</p>' if comment else ''}
|
||||
</div>
|
||||
<p>Средства будут переведены в ближайшее время.</p>
|
||||
{self._get_cabinet_button(language)}
|
||||
""",
|
||||
'en': f"""
|
||||
<h2>Withdrawal Request Approved!</h2>
|
||||
<div class="highlight success">
|
||||
<p>Your withdrawal request has been approved.</p>
|
||||
<p>Amount: <span class="amount">{amount}</span></p>
|
||||
{f'<p>Comment: {comment}</p>' if comment else ''}
|
||||
</div>
|
||||
<p>Funds will be transferred shortly.</p>
|
||||
{self._get_cabinet_button(language)}
|
||||
""",
|
||||
'zh': f"""
|
||||
<h2>提现请求已批准!</h2>
|
||||
<div class="highlight success">
|
||||
<p>您的提现请求已获批准。</p>
|
||||
<p>金额: <span class="amount">{amount}</span></p>
|
||||
{f'<p>备注: {comment}</p>' if comment else ''}
|
||||
</div>
|
||||
<p>资金将很快转入。</p>
|
||||
{self._get_cabinet_button(language)}
|
||||
""",
|
||||
'ua': f"""
|
||||
<h2>Запит на виведення схвалено!</h2>
|
||||
<div class="highlight success">
|
||||
<p>Ваш запит на виведення коштів було схвалено.</p>
|
||||
<p>Сума: <span class="amount">{amount}</span></p>
|
||||
{f'<p>Коментар: {comment}</p>' if comment else ''}
|
||||
</div>
|
||||
<p>Кошти будуть переведені найближчим часом.</p>
|
||||
{self._get_cabinet_button(language)}
|
||||
""",
|
||||
}
|
||||
|
||||
return {
|
||||
'subject': subjects.get(language, subjects['ru']),
|
||||
'body_html': self._get_base_template(bodies.get(language, bodies['ru']), language),
|
||||
}
|
||||
|
||||
def _withdrawal_rejected_template(self, language: str, context: dict[str, Any]) -> dict[str, str]:
|
||||
"""Template for withdrawal rejected notification."""
|
||||
amount = context.get('formatted_amount', f'{context.get("amount_rubles", 0):.2f} ₽')
|
||||
comment = html.escape(context.get('comment', ''))
|
||||
|
||||
subjects = {
|
||||
'ru': f'Запрос на вывод {amount} отклонён',
|
||||
'en': f'Withdrawal request for {amount} rejected',
|
||||
'zh': f'提现请求 {amount} 被拒绝',
|
||||
'ua': f'Запит на виведення {amount} відхилено',
|
||||
}
|
||||
|
||||
bodies = {
|
||||
'ru': f"""
|
||||
<h2>Запрос на вывод отклонён</h2>
|
||||
<div class="highlight danger">
|
||||
<p>Ваш запрос на вывод средств был отклонён.</p>
|
||||
<p>Сумма: <strong>{amount}</strong></p>
|
||||
{f'<p>Причина: {comment}</p>' if comment else ''}
|
||||
</div>
|
||||
<p>Средства возвращены на ваш баланс.</p>
|
||||
{self._get_cabinet_button(language)}
|
||||
""",
|
||||
'en': f"""
|
||||
<h2>Withdrawal Request Rejected</h2>
|
||||
<div class="highlight danger">
|
||||
<p>Your withdrawal request has been rejected.</p>
|
||||
<p>Amount: <strong>{amount}</strong></p>
|
||||
{f'<p>Reason: {comment}</p>' if comment else ''}
|
||||
</div>
|
||||
<p>Funds have been returned to your balance.</p>
|
||||
{self._get_cabinet_button(language)}
|
||||
""",
|
||||
'zh': f"""
|
||||
<h2>提现请求被拒绝</h2>
|
||||
<div class="highlight danger">
|
||||
<p>您的提现请求已被拒绝。</p>
|
||||
<p>金额: <strong>{amount}</strong></p>
|
||||
{f'<p>原因: {comment}</p>' if comment else ''}
|
||||
</div>
|
||||
<p>资金已退回您的余额。</p>
|
||||
{self._get_cabinet_button(language)}
|
||||
""",
|
||||
'ua': f"""
|
||||
<h2>Запит на виведення відхилено</h2>
|
||||
<div class="highlight danger">
|
||||
<p>Ваш запит на виведення коштів було відхилено.</p>
|
||||
<p>Сума: <strong>{amount}</strong></p>
|
||||
{f'<p>Причина: {comment}</p>' if comment else ''}
|
||||
</div>
|
||||
<p>Кошти повернуто на ваш баланс.</p>
|
||||
{self._get_cabinet_button(language)}
|
||||
""",
|
||||
}
|
||||
|
||||
return {
|
||||
'subject': subjects.get(language, subjects['ru']),
|
||||
'body_html': self._get_base_template(bodies.get(language, bodies['ru']), language),
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Payment Templates
|
||||
# ============================================================================
|
||||
|
||||
+141
-131
@@ -1,7 +1,6 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
import html
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
@@ -11,6 +10,7 @@ from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import structlog
|
||||
from pydantic import Field, field_validator
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
@@ -23,7 +23,7 @@ DEFAULT_DISPLAY_NAME_BANNED_KEYWORDS: list[str] = [
|
||||
USER_TAG_PATTERN = re.compile(r'^[A-Z0-9_]{1,16}$')
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
@@ -105,6 +105,25 @@ class Settings(BaseSettings):
|
||||
REMNAWAVE_AUTO_SYNC_TIMES: str = '03:00'
|
||||
CABINET_REMNA_SUB_CONFIG: str | None = None # UUID конфига страницы подписки из RemnaWave
|
||||
|
||||
# RemnaWave incoming webhooks (real-time event delivery from backend)
|
||||
REMNAWAVE_WEBHOOK_ENABLED: bool = False
|
||||
REMNAWAVE_WEBHOOK_PATH: str = '/remnawave-webhook'
|
||||
REMNAWAVE_WEBHOOK_SECRET: str | None = None # HMAC-SHA256 shared secret (min 32 chars)
|
||||
|
||||
# Webhook user notification toggles (what Telegram messages users receive from webhook events)
|
||||
WEBHOOK_NOTIFY_USER_ENABLED: bool = True
|
||||
WEBHOOK_NOTIFY_SUB_STATUS: bool = True
|
||||
WEBHOOK_NOTIFY_SUB_EXPIRED: bool = True
|
||||
WEBHOOK_NOTIFY_SUB_EXPIRING: bool = True
|
||||
WEBHOOK_NOTIFY_SUB_LIMITED: bool = True
|
||||
WEBHOOK_NOTIFY_TRAFFIC_RESET: bool = True
|
||||
WEBHOOK_NOTIFY_SUB_DELETED: bool = True
|
||||
WEBHOOK_NOTIFY_SUB_REVOKED: bool = True
|
||||
WEBHOOK_NOTIFY_FIRST_CONNECTED: bool = True
|
||||
WEBHOOK_NOTIFY_NOT_CONNECTED: bool = True
|
||||
WEBHOOK_NOTIFY_BANDWIDTH_THRESHOLD: bool = True
|
||||
WEBHOOK_NOTIFY_DEVICES: bool = True
|
||||
|
||||
TRIAL_DURATION_DAYS: int = 3
|
||||
TRIAL_TRAFFIC_LIMIT_GB: int = 10
|
||||
TRIAL_DEVICE_LIMIT: int = 2
|
||||
@@ -112,6 +131,7 @@ class Settings(BaseSettings):
|
||||
TRIAL_PAYMENT_ENABLED: bool = False
|
||||
TRIAL_ACTIVATION_PRICE: int = 0
|
||||
TRIAL_USER_TAG: str | None = None
|
||||
TRIAL_DISABLED_FOR: str = 'none' # none, email, telegram, all
|
||||
DEFAULT_TRAFFIC_LIMIT_GB: int = 100
|
||||
DEFAULT_DEVICE_LIMIT: int = 1
|
||||
DEFAULT_TRAFFIC_RESET_STRATEGY: str = 'MONTH'
|
||||
@@ -161,11 +181,6 @@ class Settings(BaseSettings):
|
||||
DEVICES_SELECTION_ENABLED: bool = True
|
||||
DEVICES_SELECTION_DISABLED_AMOUNT: int | None = None
|
||||
|
||||
# Настройки модема
|
||||
MODEM_ENABLED: bool = False
|
||||
MODEM_PRICE_PER_MONTH: int = 10000 # Цена модема в копейках за месяц
|
||||
MODEM_PERIOD_DISCOUNTS: str = '' # Скидки на модем: "месяцев:процент,месяцев:процент" (напр. "3:10,6:15,12:20")
|
||||
|
||||
BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED: bool = False
|
||||
BASE_PROMO_GROUP_PERIOD_DISCOUNTS: str = ''
|
||||
|
||||
@@ -180,7 +195,7 @@ class Settings(BaseSettings):
|
||||
# Режим продаж подписок:
|
||||
# - classic: классический режим (выбор серверов, трафика, устройств, периода отдельно)
|
||||
# - tariffs: режим тарифов (готовые пакеты с фиксированными параметрами)
|
||||
SALES_MODE: str = 'classic'
|
||||
SALES_MODE: str = 'tariffs'
|
||||
|
||||
# ID тарифа для триала в режиме тарифов (0 = использовать стандартные настройки триала)
|
||||
# Если указан ID тарифа, параметры триала берутся из тарифа (traffic_limit_gb, device_limit, allowed_squads)
|
||||
@@ -215,7 +230,9 @@ class Settings(BaseSettings):
|
||||
REFERRAL_WITHDRAWAL_MIN_AMOUNT_KOPEKS: int = 100000 # Мин. сумма вывода (1000₽)
|
||||
REFERRAL_WITHDRAWAL_COOLDOWN_DAYS: int = 30 # Частота запросов на вывод
|
||||
REFERRAL_WITHDRAWAL_ONLY_REFERRAL_BALANCE: bool = True # Только реф. баланс (False = реф + свой)
|
||||
REFERRAL_WITHDRAWAL_REQUISITES_TEXT: str = '' # Текст-подсказка для реквизитов при выводе
|
||||
REFERRAL_WITHDRAWAL_NOTIFICATIONS_TOPIC_ID: int | None = None # Топик для уведомлений
|
||||
REFERRAL_PARTNER_SECTION_VISIBLE: bool = True # Показывать раздел партнёрки в кабинете
|
||||
|
||||
# Настройки анализа на подозрительность
|
||||
REFERRAL_WITHDRAWAL_SUSPICIOUS_MIN_DEPOSIT_KOPEKS: int = 50000 # Мин. сумма от 1 реферала (500₽)
|
||||
@@ -236,6 +253,8 @@ class Settings(BaseSettings):
|
||||
BLACKLIST_UPDATE_INTERVAL_HOURS: int = 24
|
||||
BLACKLIST_IGNORE_ADMINS: bool = True
|
||||
|
||||
DISPOSABLE_EMAIL_CHECK_ENABLED: bool = True
|
||||
|
||||
# Настройки простой покупки
|
||||
SIMPLE_SUBSCRIPTION_ENABLED: bool = False
|
||||
SIMPLE_SUBSCRIPTION_PERIOD_DAYS: int = 30
|
||||
@@ -336,12 +355,6 @@ class Settings(BaseSettings):
|
||||
NALOGO_STORAGE_PATH: str = './nalogo_tokens.json'
|
||||
|
||||
AUTO_PURCHASE_AFTER_TOPUP_ENABLED: bool = False
|
||||
AUTO_ACTIVATE_AFTER_TOPUP_ENABLED: bool = False
|
||||
|
||||
# Показывать предупреждение об активации подписки после пополнения баланса
|
||||
# Если True - после пополнения показывает большое сообщение с кнопками:
|
||||
# "Активировать", "Продлить", "Добавить устройства"
|
||||
SHOW_ACTIVATION_PROMPT_AFTER_TOPUP: bool = False
|
||||
|
||||
# Отключение превью ссылок в сообщениях бота
|
||||
DISABLE_WEB_PAGE_PREVIEW: bool = False
|
||||
@@ -398,6 +411,7 @@ class Settings(BaseSettings):
|
||||
MULENPAY_MIN_AMOUNT_KOPEKS: int = 10000
|
||||
MULENPAY_MAX_AMOUNT_KOPEKS: int = 10000000
|
||||
MULENPAY_IFRAME_EXPECTED_ORIGIN: str | None = None
|
||||
MULENPAY_WEBSITE_URL: str | None = None
|
||||
|
||||
PAL24_ENABLED: bool = False
|
||||
PAL24_DISPLAY_NAME: str = 'PAL24'
|
||||
@@ -406,7 +420,6 @@ class Settings(BaseSettings):
|
||||
PAL24_SIGNATURE_TOKEN: str | None = None
|
||||
PAL24_BASE_URL: str = 'https://pal24.pro/api/v1/'
|
||||
PAL24_WEBHOOK_PATH: str = '/pal24-webhook'
|
||||
PAL24_WEBHOOK_PORT: int = 8084
|
||||
PAL24_PAYMENT_DESCRIPTION: str = 'Пополнение баланса'
|
||||
PAL24_MIN_AMOUNT_KOPEKS: int = 10000
|
||||
PAL24_MAX_AMOUNT_KOPEKS: int = 100000000
|
||||
@@ -505,8 +518,10 @@ class Settings(BaseSettings):
|
||||
# Способ оплаты: 44 = СБП (QR код), 36 = Карты РФ, 43 = SberPay
|
||||
KASSA_AI_PAYMENT_SYSTEM_ID: int = 44
|
||||
|
||||
MAIN_MENU_MODE: str = 'default'
|
||||
CONNECT_BUTTON_MODE: str = 'guide'
|
||||
MAIN_MENU_MODE: str = 'default' # 'default' | 'cabinet'
|
||||
# Стиль кнопок Cabinet: primary (синий), success (зелёный), danger (красный), '' (по умолчанию для каждой секции)
|
||||
CABINET_BUTTON_STYLE: str = ''
|
||||
CONNECT_BUTTON_MODE: str = 'miniapp_subscription'
|
||||
MINIAPP_CUSTOM_URL: str = ''
|
||||
MINIAPP_STATIC_PATH: str = 'miniapp'
|
||||
MINIAPP_PURCHASE_URL: str = ''
|
||||
@@ -528,7 +543,7 @@ class Settings(BaseSettings):
|
||||
SKIP_REFERRAL_CODE: bool = False
|
||||
|
||||
DEFAULT_LANGUAGE: str = 'ru'
|
||||
AVAILABLE_LANGUAGES: str = 'ru,en'
|
||||
AVAILABLE_LANGUAGES: str = 'ru,en,ua,zh,fa'
|
||||
LANGUAGE_SELECTION_ENABLED: bool = True
|
||||
|
||||
# Округление цен при отображении (≤50 коп вниз, >50 коп вверх)
|
||||
@@ -536,6 +551,7 @@ class Settings(BaseSettings):
|
||||
|
||||
LOG_LEVEL: str = 'INFO'
|
||||
LOG_FILE: str = 'logs/bot.log'
|
||||
LOG_COLORS: bool = True # ANSI-цвета в консоли (false для plain-text вывода)
|
||||
|
||||
# === Log Rotation Settings ===
|
||||
LOG_ROTATION_ENABLED: bool = False # По умолчанию старое поведение
|
||||
@@ -657,6 +673,7 @@ class Settings(BaseSettings):
|
||||
WEB_API_DEFAULT_TOKEN: str | None = None
|
||||
WEB_API_DEFAULT_TOKEN_NAME: str = 'Bootstrap Token'
|
||||
WEB_API_TOKEN_HASH_ALGORITHM: str = 'sha256'
|
||||
WEB_API_TOKEN_HMAC_SECRET: str | None = None
|
||||
WEB_API_REQUEST_LOGGING: bool = True
|
||||
|
||||
APP_CONFIG_PATH: str = 'app-config.json'
|
||||
@@ -691,9 +708,27 @@ class Settings(BaseSettings):
|
||||
CABINET_EMAIL_VERIFICATION_ENABLED: bool = True
|
||||
CABINET_EMAIL_VERIFICATION_EXPIRE_HOURS: int = 24
|
||||
CABINET_PASSWORD_RESET_EXPIRE_HOURS: int = 1
|
||||
CABINET_EMAIL_CHANGE_CODE_EXPIRE_MINUTES: int = 15 # Email change verification code expiration
|
||||
CABINET_EMAIL_AUTH_ENABLED: bool = True # Enable email registration/login in cabinet
|
||||
CABINET_URL: str = 'https://example.com/cabinet' # Base URL for cabinet (used in verification emails)
|
||||
|
||||
# OAuth 2.0 provider settings for cabinet
|
||||
OAUTH_GOOGLE_CLIENT_ID: str = ''
|
||||
OAUTH_GOOGLE_CLIENT_SECRET: str = ''
|
||||
OAUTH_GOOGLE_ENABLED: bool = False
|
||||
|
||||
OAUTH_YANDEX_CLIENT_ID: str = ''
|
||||
OAUTH_YANDEX_CLIENT_SECRET: str = ''
|
||||
OAUTH_YANDEX_ENABLED: bool = False
|
||||
|
||||
OAUTH_DISCORD_CLIENT_ID: str = ''
|
||||
OAUTH_DISCORD_CLIENT_SECRET: str = ''
|
||||
OAUTH_DISCORD_ENABLED: bool = False
|
||||
|
||||
OAUTH_VK_CLIENT_ID: str = ''
|
||||
OAUTH_VK_CLIENT_SECRET: str = ''
|
||||
OAUTH_VK_ENABLED: bool = False
|
||||
|
||||
# SMTP settings for cabinet email
|
||||
SMTP_HOST: str | None = None
|
||||
SMTP_PORT: int = 587
|
||||
@@ -721,15 +756,16 @@ class Settings(BaseSettings):
|
||||
'default': 'default',
|
||||
'full': 'default',
|
||||
'standard': 'default',
|
||||
'text': 'text',
|
||||
'text_only': 'text',
|
||||
'textual': 'text',
|
||||
'minimal': 'text',
|
||||
'cabinet': 'cabinet',
|
||||
'text': 'cabinet',
|
||||
'text_only': 'cabinet',
|
||||
'textual': 'cabinet',
|
||||
'minimal': 'cabinet',
|
||||
}
|
||||
|
||||
mode = aliases.get(normalized, normalized)
|
||||
if mode not in {'default', 'text'}:
|
||||
raise ValueError('MAIN_MENU_MODE must be one of: default, text')
|
||||
if mode not in {'default', 'cabinet'}:
|
||||
raise ValueError('MAIN_MENU_MODE must be one of: default, cabinet')
|
||||
return mode
|
||||
|
||||
@field_validator('SERVER_STATUS_MODE', mode='before')
|
||||
@@ -1030,13 +1066,14 @@ class Settings(BaseSettings):
|
||||
)
|
||||
|
||||
raw_username = template.format_map(values).strip()
|
||||
sanitized_username = re.sub(r'[^0-9A-Za-z._-]+', '_', raw_username)
|
||||
sanitized_username = re.sub(r'_+', '_', sanitized_username).strip('._-')
|
||||
# Remnawave разрешает только буквы, цифры, подчёркивания и дефисы
|
||||
sanitized_username = re.sub(r'[^0-9A-Za-z_-]+', '_', raw_username)
|
||||
sanitized_username = re.sub(r'_+', '_', sanitized_username).strip('_-')
|
||||
|
||||
if not sanitized_username:
|
||||
sanitized_username = f'user_{identifier}'
|
||||
|
||||
return sanitized_username[:64]
|
||||
return sanitized_username[:36]
|
||||
|
||||
@staticmethod
|
||||
def parse_daily_time_list(raw_value: str | None) -> list[time]:
|
||||
@@ -1074,6 +1111,13 @@ class Settings(BaseSettings):
|
||||
def get_remnawave_auto_sync_times(self) -> list[time]:
|
||||
return self.parse_daily_time_list(self.REMNAWAVE_AUTO_SYNC_TIMES)
|
||||
|
||||
def is_remnawave_webhook_enabled(self) -> bool:
|
||||
return (
|
||||
self.REMNAWAVE_WEBHOOK_ENABLED
|
||||
and bool(self.REMNAWAVE_WEBHOOK_SECRET)
|
||||
and len(self.REMNAWAVE_WEBHOOK_SECRET or '') >= 32
|
||||
)
|
||||
|
||||
def get_traffic_monitored_nodes(self) -> list[str]:
|
||||
"""Возвращает список UUID нод для мониторинга (пусто = все)"""
|
||||
if not self.TRAFFIC_MONITORED_NODES:
|
||||
@@ -1161,22 +1205,12 @@ class Settings(BaseSettings):
|
||||
|
||||
return bool(value)
|
||||
|
||||
def is_auto_activate_after_topup_enabled(self) -> bool:
|
||||
"""Умная автоактивация после пополнения баланса (без корзины)."""
|
||||
value = getattr(self, 'AUTO_ACTIVATE_AFTER_TOPUP_ENABLED', False)
|
||||
|
||||
if isinstance(value, str):
|
||||
normalized = value.strip().lower()
|
||||
return normalized in {'1', 'true', 'yes', 'on'}
|
||||
|
||||
return bool(value)
|
||||
|
||||
def is_quick_amount_buttons_enabled(self) -> bool:
|
||||
"""Показывать ли кнопки быстрого выбора суммы пополнения."""
|
||||
return self.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not self.DISABLE_TOPUP_BUTTONS
|
||||
|
||||
def get_available_languages(self) -> list[str]:
|
||||
defaults = ['ru', 'en', 'ua', 'zh']
|
||||
defaults = ['ru', 'en', 'ua', 'zh', 'fa']
|
||||
|
||||
try:
|
||||
langs = self.AVAILABLE_LANGUAGES
|
||||
@@ -1267,7 +1301,7 @@ class Settings(BaseSettings):
|
||||
raise ValueError
|
||||
return time(hour=hours, minute=minutes)
|
||||
except (ValueError, AttributeError):
|
||||
logging.getLogger(__name__).warning('Некорректное значение ADMIN_REPORTS_SEND_TIME: %s', value)
|
||||
logger.warning('Некорректное значение ADMIN_REPORTS_SEND_TIME', send_time_value=value)
|
||||
return None
|
||||
|
||||
def kopeks_to_rubles(self, kopeks: int) -> float:
|
||||
@@ -1287,17 +1321,14 @@ class Settings(BaseSettings):
|
||||
|
||||
if len(cleaned) > 16:
|
||||
logger.warning(
|
||||
'Некорректная длина %s: максимум 16 символов, получено %s',
|
||||
setting_name,
|
||||
len(cleaned),
|
||||
'Некорректная длина : максимум 16 символов, получено',
|
||||
setting_name=setting_name,
|
||||
cleaned_count=len(cleaned),
|
||||
)
|
||||
return None
|
||||
|
||||
if not USER_TAG_PATTERN.fullmatch(cleaned):
|
||||
logger.warning(
|
||||
'Некорректный формат %s: допустимы только A-Z, 0-9 и подчёркивание',
|
||||
setting_name,
|
||||
)
|
||||
logger.warning('Некорректный формат : допустимы только A-Z, 0-9 и подчёркивание', setting_name=setting_name)
|
||||
return None
|
||||
|
||||
return cleaned
|
||||
@@ -1308,6 +1339,17 @@ class Settings(BaseSettings):
|
||||
def get_trial_user_tag(self) -> str | None:
|
||||
return self._normalize_user_tag(self.TRIAL_USER_TAG, 'TRIAL_USER_TAG')
|
||||
|
||||
def is_trial_disabled_for_user(self, auth_type: str | None) -> bool:
|
||||
disabled_for = self.TRIAL_DISABLED_FOR
|
||||
if disabled_for == 'all':
|
||||
return True
|
||||
# 'email' means all non-Telegram users (email, google, yandex, discord, vk, etc.)
|
||||
if disabled_for == 'email' and auth_type not in (None, 'telegram'):
|
||||
return True
|
||||
if disabled_for == 'telegram' and (auth_type is None or auth_type == 'telegram'):
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_paid_subscription_user_tag(self) -> str | None:
|
||||
return self._normalize_user_tag(
|
||||
self.PAID_SUBSCRIPTION_USER_TAG,
|
||||
@@ -1327,8 +1369,12 @@ class Settings(BaseSettings):
|
||||
def get_main_menu_mode(self) -> str:
|
||||
return getattr(self, 'MAIN_MENU_MODE', 'default')
|
||||
|
||||
def is_cabinet_mode(self) -> bool:
|
||||
return self.get_main_menu_mode() == 'cabinet'
|
||||
|
||||
def is_text_main_menu_mode(self) -> bool:
|
||||
return self.get_main_menu_mode() == 'text'
|
||||
"""Backward-compatible alias for :meth:`is_cabinet_mode`."""
|
||||
return self.is_cabinet_mode()
|
||||
|
||||
def get_main_menu_miniapp_url(self) -> str | None:
|
||||
for candidate in [self.MINIAPP_CUSTOM_URL, self.MINIAPP_PURCHASE_URL]:
|
||||
@@ -1403,9 +1449,9 @@ class Settings(BaseSettings):
|
||||
try:
|
||||
return int(self.EXTERNAL_ADMIN_TOKEN_BOT_ID) if self.EXTERNAL_ADMIN_TOKEN_BOT_ID else None
|
||||
except (TypeError, ValueError): # pragma: no cover - защитная ветка для некорректных значений
|
||||
logging.getLogger(__name__).warning(
|
||||
'Некорректный идентификатор бота для внешней админки: %s',
|
||||
self.EXTERNAL_ADMIN_TOKEN_BOT_ID,
|
||||
logger.warning(
|
||||
'Некорректный идентификатор бота для внешней админки',
|
||||
EXTERNAL_ADMIN_TOKEN_BOT_ID=self.EXTERNAL_ADMIN_TOKEN_BOT_ID,
|
||||
)
|
||||
return None
|
||||
|
||||
@@ -1486,10 +1532,7 @@ class Settings(BaseSettings):
|
||||
try:
|
||||
value = int(raw_value)
|
||||
except (TypeError, ValueError):
|
||||
logger.warning(
|
||||
'Некорректное значение DEVICES_SELECTION_DISABLED_AMOUNT: %s',
|
||||
raw_value,
|
||||
)
|
||||
logger.warning('Некорректное значение DEVICES_SELECTION_DISABLED_AMOUNT', raw_value=raw_value)
|
||||
return None
|
||||
|
||||
if value < 0:
|
||||
@@ -1500,9 +1543,6 @@ class Settings(BaseSettings):
|
||||
def get_disabled_mode_device_limit(self) -> int | None:
|
||||
return self.get_devices_selection_disabled_amount()
|
||||
|
||||
def is_modem_enabled(self) -> bool:
|
||||
return bool(self.MODEM_ENABLED)
|
||||
|
||||
def is_tariffs_mode(self) -> bool:
|
||||
"""Проверяет, включен ли режим продаж 'Тарифы'."""
|
||||
return self.SALES_MODE == 'tariffs'
|
||||
@@ -1513,68 +1553,12 @@ class Settings(BaseSettings):
|
||||
|
||||
def get_sales_mode(self) -> str:
|
||||
"""Возвращает текущий режим продаж."""
|
||||
return self.SALES_MODE if self.SALES_MODE in ('classic', 'tariffs') else 'classic'
|
||||
return self.SALES_MODE if self.SALES_MODE in ('classic', 'tariffs') else 'tariffs'
|
||||
|
||||
def get_trial_tariff_id(self) -> int:
|
||||
"""Возвращает ID тарифа для триала (0 = использовать стандартные настройки)."""
|
||||
return max(0, self.TRIAL_TARIFF_ID)
|
||||
|
||||
def get_modem_price_per_month(self) -> int:
|
||||
try:
|
||||
value = int(self.MODEM_PRICE_PER_MONTH)
|
||||
except (TypeError, ValueError):
|
||||
logger.warning(
|
||||
'Некорректное значение MODEM_PRICE_PER_MONTH: %s',
|
||||
self.MODEM_PRICE_PER_MONTH,
|
||||
)
|
||||
return 10000
|
||||
return max(0, value)
|
||||
|
||||
def get_modem_period_discounts(self) -> dict[int, int]:
|
||||
"""Возвращает скидки на модем по количеству месяцев: {месяцев: процент_скидки}"""
|
||||
try:
|
||||
config_str = (self.MODEM_PERIOD_DISCOUNTS or '').strip()
|
||||
if not config_str:
|
||||
return {}
|
||||
|
||||
discounts: dict[int, int] = {}
|
||||
for part in config_str.split(','):
|
||||
part = part.strip()
|
||||
if not part:
|
||||
continue
|
||||
|
||||
months_and_discount = part.split(':')
|
||||
if len(months_and_discount) != 2:
|
||||
continue
|
||||
|
||||
months_str, discount_str = months_and_discount
|
||||
try:
|
||||
months = int(months_str.strip())
|
||||
discount_percent = int(discount_str.strip())
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
discounts[months] = max(0, min(100, discount_percent))
|
||||
|
||||
return discounts
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
def get_modem_period_discount(self, months: int) -> int:
|
||||
"""Возвращает процент скидки для указанного количества месяцев"""
|
||||
if months <= 0:
|
||||
return 0
|
||||
|
||||
discounts = self.get_modem_period_discounts()
|
||||
|
||||
# Ищем точное совпадение или ближайшее меньшее
|
||||
applicable_discount = 0
|
||||
for discount_months, discount_percent in sorted(discounts.items()):
|
||||
if months >= discount_months:
|
||||
applicable_discount = discount_percent
|
||||
|
||||
return applicable_discount
|
||||
|
||||
def is_trial_paid_activation_enabled(self) -> bool:
|
||||
# TRIAL_PAYMENT_ENABLED - главный переключатель платной активации
|
||||
# Если выключен - триал бесплатный, независимо от цены
|
||||
@@ -1588,8 +1572,7 @@ class Settings(BaseSettings):
|
||||
value = int(self.TRIAL_ACTIVATION_PRICE)
|
||||
except (TypeError, ValueError):
|
||||
logger.warning(
|
||||
'Некорректное значение TRIAL_ACTIVATION_PRICE: %s',
|
||||
self.TRIAL_ACTIVATION_PRICE,
|
||||
'Некорректное значение TRIAL_ACTIVATION_PRICE', TRIAL_ACTIVATION_PRICE=self.TRIAL_ACTIVATION_PRICE
|
||||
)
|
||||
return 0
|
||||
|
||||
@@ -1708,7 +1691,7 @@ class Settings(BaseSettings):
|
||||
try:
|
||||
method_code = int(part)
|
||||
except ValueError:
|
||||
logger.warning('Некорректный код метода Platega: %s', part)
|
||||
logger.warning('Некорректный код метода Platega', part=part)
|
||||
continue
|
||||
if method_code in {2, 10, 11, 12, 13} and method_code not in seen:
|
||||
methods.append(method_code)
|
||||
@@ -1803,8 +1786,8 @@ class Settings(BaseSettings):
|
||||
|
||||
if minutes <= 0:
|
||||
logger.warning(
|
||||
'Некорректный интервал автопроверки платежей: %s. Используется значение по умолчанию 10 минут.',
|
||||
self.PAYMENT_VERIFICATION_AUTO_CHECK_INTERVAL_MINUTES,
|
||||
'Некорректный интервал автопроверки платежей: . Используется значение по умолчанию 10 минут.',
|
||||
PAYMENT_VERIFICATION_AUTO_CHECK_INTERVAL_MINUTES=self.PAYMENT_VERIFICATION_AUTO_CHECK_INTERVAL_MINUTES,
|
||||
)
|
||||
return 10
|
||||
|
||||
@@ -2158,22 +2141,13 @@ class Settings(BaseSettings):
|
||||
return self.REFERRAL_NOTIFICATIONS_ENABLED
|
||||
|
||||
def get_traffic_packages(self) -> list[dict]:
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
packages = []
|
||||
config_str = self.TRAFFIC_PACKAGES_CONFIG.strip()
|
||||
|
||||
logger.debug(f"CONFIG STRING: '{config_str}'")
|
||||
|
||||
if not config_str:
|
||||
logger.debug('CONFIG EMPTY, USING FALLBACK')
|
||||
return self._get_fallback_traffic_packages()
|
||||
|
||||
logger.debug('PARSING CONFIG...')
|
||||
|
||||
for package_config in config_str.split(','):
|
||||
package_config = package_config.strip()
|
||||
if not package_config:
|
||||
@@ -2192,11 +2166,10 @@ class Settings(BaseSettings):
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
logger.debug(f'PARSED {len(packages)} packages from config')
|
||||
return packages if packages else self._get_fallback_traffic_packages()
|
||||
|
||||
except Exception as e:
|
||||
logger.info(f'ERROR PARSING CONFIG: {e}')
|
||||
logger.warning('ERROR PARSING CONFIG', error=e)
|
||||
return self._get_fallback_traffic_packages()
|
||||
|
||||
def is_version_check_enabled(self) -> bool:
|
||||
@@ -2416,7 +2389,7 @@ class Settings(BaseSettings):
|
||||
|
||||
def get_bot_run_mode(self) -> str:
|
||||
mode = (self.BOT_RUN_MODE or 'polling').strip().lower()
|
||||
if mode not in {'polling', 'webhook', 'both'}:
|
||||
if mode not in {'polling', 'webhook'}:
|
||||
return 'polling'
|
||||
return mode
|
||||
|
||||
@@ -2498,6 +2471,9 @@ class Settings(BaseSettings):
|
||||
def get_cabinet_password_reset_expire_hours(self) -> int:
|
||||
return max(1, self.CABINET_PASSWORD_RESET_EXPIRE_HOURS)
|
||||
|
||||
def get_cabinet_email_change_code_expire_minutes(self) -> int:
|
||||
return max(1, self.CABINET_EMAIL_CHANGE_CODE_EXPIRE_MINUTES)
|
||||
|
||||
def is_cabinet_email_auth_enabled(self) -> bool:
|
||||
return bool(self.CABINET_EMAIL_AUTH_ENABLED)
|
||||
|
||||
@@ -2511,6 +2487,40 @@ class Settings(BaseSettings):
|
||||
return self.SMTP_FROM_EMAIL
|
||||
return self.SMTP_USER
|
||||
|
||||
# OAuth helpers
|
||||
def get_oauth_providers_config(self) -> dict[str, dict[str, str | bool]]:
|
||||
"""Return config for all OAuth providers (enabled or not)."""
|
||||
return {
|
||||
'google': {
|
||||
'client_id': self.OAUTH_GOOGLE_CLIENT_ID,
|
||||
'client_secret': self.OAUTH_GOOGLE_CLIENT_SECRET,
|
||||
'enabled': self.OAUTH_GOOGLE_ENABLED,
|
||||
'display_name': 'Google',
|
||||
},
|
||||
'yandex': {
|
||||
'client_id': self.OAUTH_YANDEX_CLIENT_ID,
|
||||
'client_secret': self.OAUTH_YANDEX_CLIENT_SECRET,
|
||||
'enabled': self.OAUTH_YANDEX_ENABLED,
|
||||
'display_name': 'Yandex',
|
||||
},
|
||||
'discord': {
|
||||
'client_id': self.OAUTH_DISCORD_CLIENT_ID,
|
||||
'client_secret': self.OAUTH_DISCORD_CLIENT_SECRET,
|
||||
'enabled': self.OAUTH_DISCORD_ENABLED,
|
||||
'display_name': 'Discord',
|
||||
},
|
||||
'vk': {
|
||||
'client_id': self.OAUTH_VK_CLIENT_ID,
|
||||
'client_secret': self.OAUTH_VK_CLIENT_SECRET,
|
||||
'enabled': self.OAUTH_VK_ENABLED,
|
||||
'display_name': 'VK',
|
||||
},
|
||||
}
|
||||
|
||||
def get_enabled_oauth_provider_names(self) -> list[str]:
|
||||
"""Return list of enabled OAuth provider names."""
|
||||
return [name for name, cfg in self.get_oauth_providers_config().items() if cfg['enabled']]
|
||||
|
||||
# Ban System helpers
|
||||
def is_ban_system_enabled(self) -> bool:
|
||||
return bool(self.BAN_SYSTEM_ENABLED)
|
||||
|
||||
@@ -8,7 +8,7 @@ from .database import (
|
||||
get_db,
|
||||
get_db_read_only,
|
||||
get_pool_metrics,
|
||||
init_db,
|
||||
sync_postgres_sequences,
|
||||
)
|
||||
|
||||
|
||||
@@ -20,5 +20,5 @@ __all__ = [
|
||||
'get_db',
|
||||
'get_db_read_only',
|
||||
'get_pool_metrics',
|
||||
'init_db',
|
||||
'sync_postgres_sequences',
|
||||
]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import and_, delete, func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
@@ -17,7 +17,7 @@ from app.database.models import (
|
||||
)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def create_campaign(
|
||||
@@ -36,6 +36,7 @@ async def create_campaign(
|
||||
tariff_id: int | None = None,
|
||||
tariff_duration_days: int | None = None,
|
||||
is_active: bool = True,
|
||||
partner_user_id: int | None = None,
|
||||
) -> AdvertisingCampaign:
|
||||
campaign = AdvertisingCampaign(
|
||||
name=name,
|
||||
@@ -50,6 +51,7 @@ async def create_campaign(
|
||||
tariff_duration_days=tariff_duration_days,
|
||||
created_by=created_by,
|
||||
is_active=is_active,
|
||||
partner_user_id=partner_user_id,
|
||||
)
|
||||
|
||||
db.add(campaign)
|
||||
@@ -57,10 +59,10 @@ async def create_campaign(
|
||||
await db.refresh(campaign)
|
||||
|
||||
logger.info(
|
||||
'📣 Создана рекламная кампания %s (start=%s, bonus=%s)',
|
||||
campaign.name,
|
||||
campaign.start_parameter,
|
||||
campaign.bonus_type,
|
||||
'📣 Создана рекламная кампания (start bonus=)',
|
||||
campaign_name=campaign.name,
|
||||
start_parameter=campaign.start_parameter,
|
||||
bonus_type=campaign.bonus_type,
|
||||
)
|
||||
return campaign
|
||||
|
||||
@@ -71,6 +73,7 @@ async def get_campaign_by_id(db: AsyncSession, campaign_id: int) -> AdvertisingC
|
||||
.options(
|
||||
selectinload(AdvertisingCampaign.registrations),
|
||||
selectinload(AdvertisingCampaign.tariff),
|
||||
selectinload(AdvertisingCampaign.partner),
|
||||
)
|
||||
.where(AdvertisingCampaign.id == campaign_id)
|
||||
)
|
||||
@@ -103,6 +106,7 @@ async def get_campaigns_list(
|
||||
.options(
|
||||
selectinload(AdvertisingCampaign.registrations),
|
||||
selectinload(AdvertisingCampaign.tariff),
|
||||
selectinload(AdvertisingCampaign.partner),
|
||||
)
|
||||
.order_by(AdvertisingCampaign.created_at.desc())
|
||||
.offset(offset)
|
||||
@@ -141,6 +145,7 @@ async def update_campaign(
|
||||
'tariff_id',
|
||||
'tariff_duration_days',
|
||||
'is_active',
|
||||
'partner_user_id',
|
||||
}
|
||||
|
||||
update_data = {}
|
||||
@@ -151,20 +156,20 @@ async def update_campaign(
|
||||
if not update_data:
|
||||
return campaign
|
||||
|
||||
update_data['updated_at'] = datetime.utcnow()
|
||||
update_data['updated_at'] = datetime.now(UTC)
|
||||
|
||||
await db.execute(update(AdvertisingCampaign).where(AdvertisingCampaign.id == campaign.id).values(**update_data))
|
||||
await db.commit()
|
||||
await db.refresh(campaign)
|
||||
|
||||
logger.info('✏️ Обновлена рекламная кампания %s (%s)', campaign.name, update_data)
|
||||
logger.info('✏️ Обновлена рекламная кампания', campaign_name=campaign.name, update_data=update_data)
|
||||
return campaign
|
||||
|
||||
|
||||
async def delete_campaign(db: AsyncSession, campaign: AdvertisingCampaign) -> bool:
|
||||
await db.execute(delete(AdvertisingCampaign).where(AdvertisingCampaign.id == campaign.id))
|
||||
await db.commit()
|
||||
logger.info('🗑️ Удалена рекламная кампания %s', campaign.name)
|
||||
logger.info('🗑️ Удалена рекламная кампания', campaign_name=campaign.name)
|
||||
return True
|
||||
|
||||
|
||||
@@ -217,7 +222,7 @@ async def record_campaign_registration(
|
||||
await db.commit()
|
||||
await db.refresh(registration)
|
||||
|
||||
logger.info('📈 Регистрируем пользователя %s в кампании %s', user_id, campaign_id)
|
||||
logger.info('📈 Регистрируем пользователя в кампании', user_id=user_id, campaign_id=campaign_id)
|
||||
return registration
|
||||
|
||||
|
||||
@@ -331,7 +336,7 @@ async def get_campaign_statistics(
|
||||
first_payment_time_by_user[user_id] = converted_at
|
||||
|
||||
for user_id, amount_kopeks, created_at in subscription_payments:
|
||||
amount_value = int(amount_kopeks or 0)
|
||||
amount_value = abs(int(amount_kopeks or 0))
|
||||
subscription_payments_total += amount_value
|
||||
paid_users_from_transactions.add(user_id)
|
||||
|
||||
@@ -359,66 +364,6 @@ async def get_campaign_statistics(
|
||||
if first_payment_amount_by_user:
|
||||
avg_first_payment = int(sum(first_payment_amount_by_user.values()) / len(first_payment_amount_by_user))
|
||||
|
||||
conversion_rate = 0.0
|
||||
if count:
|
||||
conversion_rate = round((paid_users_count / count) * 100, 1)
|
||||
|
||||
trial_conversion_rate = 0.0
|
||||
if trial_users_count:
|
||||
trial_conversion_rate = round((conversion_count / trial_users_count) * 100, 1)
|
||||
|
||||
avg_revenue_per_user = 0
|
||||
if count:
|
||||
avg_revenue_per_user = int(total_revenue / count)
|
||||
|
||||
deposits_result = await db.execute(
|
||||
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
|
||||
Transaction.user_id.in_(select(registrations_subquery.c.user_id)),
|
||||
Transaction.type == TransactionType.DEPOSIT.value,
|
||||
Transaction.is_completed.is_(True),
|
||||
)
|
||||
)
|
||||
total_revenue = deposits_result.scalar() or 0
|
||||
|
||||
trials_result = await db.execute(
|
||||
select(func.count(func.distinct(Subscription.user_id))).where(
|
||||
Subscription.user_id.in_(select(registrations_subquery.c.user_id)),
|
||||
Subscription.is_trial.is_(True),
|
||||
)
|
||||
)
|
||||
trial_users_count = trials_result.scalar() or 0
|
||||
|
||||
active_trials_result = await db.execute(
|
||||
select(func.count(func.distinct(Subscription.user_id))).where(
|
||||
Subscription.user_id.in_(select(registrations_subquery.c.user_id)),
|
||||
Subscription.is_trial.is_(True),
|
||||
Subscription.status == SubscriptionStatus.ACTIVE.value,
|
||||
)
|
||||
)
|
||||
active_trials_count = active_trials_result.scalar() or 0
|
||||
|
||||
conversions_result = await db.execute(
|
||||
select(func.count(func.distinct(SubscriptionConversion.user_id))).where(
|
||||
SubscriptionConversion.user_id.in_(select(registrations_subquery.c.user_id))
|
||||
)
|
||||
)
|
||||
conversion_count = conversions_result.scalar() or 0
|
||||
|
||||
paid_users_result = await db.execute(
|
||||
select(func.count(User.id)).where(
|
||||
User.id.in_(select(registrations_subquery.c.user_id)),
|
||||
User.has_had_paid_subscription.is_(True),
|
||||
)
|
||||
)
|
||||
paid_users_count = paid_users_result.scalar() or 0
|
||||
|
||||
avg_first_payment_result = await db.execute(
|
||||
select(func.coalesce(func.avg(SubscriptionConversion.first_payment_amount_kopeks), 0)).where(
|
||||
SubscriptionConversion.user_id.in_(select(registrations_subquery.c.user_id))
|
||||
)
|
||||
)
|
||||
avg_first_payment = int(avg_first_payment_result.scalar() or 0)
|
||||
|
||||
conversion_rate = 0.0
|
||||
if count:
|
||||
conversion_rate = round((paid_users_count / count) * 100, 1)
|
||||
|
||||
@@ -2,17 +2,17 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import CloudPaymentsPayment
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def create_cloudpayments_payment(
|
||||
@@ -65,10 +65,10 @@ async def create_cloudpayments_payment(
|
||||
await db.refresh(payment)
|
||||
|
||||
logger.debug(
|
||||
'Created CloudPayments payment: id=%s, invoice=%s, amount=%s',
|
||||
payment.id,
|
||||
invoice_id,
|
||||
amount_kopeks,
|
||||
'Created CloudPayments payment: id invoice amount',
|
||||
payment_id=payment.id,
|
||||
invoice_id=invoice_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
)
|
||||
|
||||
return payment
|
||||
@@ -127,7 +127,7 @@ async def update_cloudpayments_payment(
|
||||
if hasattr(payment, key):
|
||||
setattr(payment, key, value)
|
||||
|
||||
payment.updated_at = datetime.utcnow()
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
await db.refresh(payment)
|
||||
|
||||
@@ -171,7 +171,7 @@ async def mark_cloudpayments_payment_as_paid(
|
||||
|
||||
payment.status = 'completed'
|
||||
payment.is_paid = True
|
||||
payment.paid_at = datetime.utcnow()
|
||||
payment.paid_at = datetime.now(UTC)
|
||||
|
||||
if transaction_id_cp is not None:
|
||||
payment.transaction_id_cp = transaction_id_cp
|
||||
@@ -190,14 +190,12 @@ async def mark_cloudpayments_payment_as_paid(
|
||||
if callback_payload:
|
||||
payment.callback_payload = callback_payload
|
||||
|
||||
payment.updated_at = datetime.utcnow()
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
await db.refresh(payment)
|
||||
|
||||
logger.info(
|
||||
'Marked CloudPayments payment as paid: id=%s, invoice=%s',
|
||||
payment.id,
|
||||
payment.invoice_id,
|
||||
'Marked CloudPayments payment as paid: id invoice', payment_id=payment.id, invoice_id=payment.invoice_id
|
||||
)
|
||||
|
||||
return payment
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import logging
|
||||
from collections.abc import Sequence
|
||||
from datetime import datetime
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import and_, delete, desc, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
@@ -9,7 +9,7 @@ from sqlalchemy.orm import selectinload
|
||||
from app.database.models import ContestAttempt, ContestRound, ContestTemplate, User
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
# Templates
|
||||
@@ -107,7 +107,7 @@ async def create_round(
|
||||
|
||||
|
||||
async def get_active_rounds(db: AsyncSession) -> list[ContestRound]:
|
||||
now = datetime.utcnow()
|
||||
now = datetime.now(UTC)
|
||||
result = await db.execute(
|
||||
select(ContestRound)
|
||||
.options(selectinload(ContestRound.template))
|
||||
@@ -124,7 +124,7 @@ async def get_active_rounds(db: AsyncSession) -> list[ContestRound]:
|
||||
|
||||
|
||||
async def get_active_round_by_template(db: AsyncSession, template_id: int) -> ContestRound | None:
|
||||
now = datetime.utcnow()
|
||||
now = datetime.now(UTC)
|
||||
result = await db.execute(
|
||||
select(ContestRound)
|
||||
.options(selectinload(ContestRound.template))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import and_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
@@ -8,7 +8,7 @@ from sqlalchemy.orm import selectinload
|
||||
from app.database.models import CryptoBotPayment
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def create_cryptobot_payment(
|
||||
@@ -41,7 +41,13 @@ async def create_cryptobot_payment(
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
|
||||
logger.info(f'Создан CryptoBot платеж: {invoice_id} на {amount} {asset} для пользователя {user_id}')
|
||||
logger.info(
|
||||
'Создан CryptoBot платеж: на для пользователя',
|
||||
invoice_id=invoice_id,
|
||||
amount=amount,
|
||||
asset=asset,
|
||||
user_id=user_id,
|
||||
)
|
||||
return payment
|
||||
|
||||
|
||||
@@ -70,7 +76,7 @@ async def update_cryptobot_payment_status(
|
||||
return None
|
||||
|
||||
payment.status = status
|
||||
payment.updated_at = datetime.utcnow()
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
|
||||
if status == 'paid' and paid_at:
|
||||
payment.paid_at = paid_at
|
||||
@@ -78,7 +84,7 @@ async def update_cryptobot_payment_status(
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
|
||||
logger.info(f'Обновлен статус CryptoBot платежа {invoice_id}: {status}')
|
||||
logger.info('Обновлен статус CryptoBot платежа', invoice_id=invoice_id, status=status)
|
||||
return payment
|
||||
|
||||
|
||||
@@ -91,12 +97,12 @@ async def link_cryptobot_payment_to_transaction(
|
||||
return None
|
||||
|
||||
payment.transaction_id = transaction_id
|
||||
payment.updated_at = datetime.utcnow()
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
|
||||
logger.info(f'Связан CryptoBot платеж {invoice_id} с транзакцией {transaction_id}')
|
||||
logger.info('Связан CryptoBot платеж с транзакцией', invoice_id=invoice_id, transaction_id=transaction_id)
|
||||
return payment
|
||||
|
||||
|
||||
@@ -114,9 +120,7 @@ async def get_user_cryptobot_payments(
|
||||
|
||||
|
||||
async def get_pending_cryptobot_payments(db: AsyncSession, older_than_hours: int = 24) -> list[CryptoBotPayment]:
|
||||
from datetime import timedelta
|
||||
|
||||
cutoff_time = datetime.utcnow() - timedelta(hours=older_than_hours)
|
||||
cutoff_time = datetime.now(UTC) - timedelta(hours=older_than_hours)
|
||||
|
||||
result = await db.execute(
|
||||
select(CryptoBotPayment)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
@@ -11,7 +11,7 @@ from app.database.crud.promo_offer_log import log_promo_offer_action
|
||||
from app.database.models import DiscountOffer
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def upsert_discount_offer(
|
||||
@@ -28,7 +28,7 @@ async def upsert_discount_offer(
|
||||
) -> DiscountOffer:
|
||||
"""Create or refresh a discount offer for a user."""
|
||||
|
||||
expires_at = datetime.utcnow() + timedelta(hours=valid_hours)
|
||||
expires_at = datetime.now(UTC) + timedelta(hours=valid_hours)
|
||||
|
||||
result = await db.execute(
|
||||
select(DiscountOffer)
|
||||
@@ -116,7 +116,7 @@ async def list_active_discount_offers_for_user(
|
||||
) -> list[DiscountOffer]:
|
||||
"""Return active (not yet claimed) offers for a user."""
|
||||
|
||||
now = datetime.utcnow()
|
||||
now = datetime.now(UTC)
|
||||
stmt = (
|
||||
select(DiscountOffer)
|
||||
.options(
|
||||
@@ -161,7 +161,7 @@ async def mark_offer_claimed(
|
||||
*,
|
||||
details: dict | None = None,
|
||||
) -> DiscountOffer:
|
||||
offer.claimed_at = datetime.utcnow()
|
||||
offer.claimed_at = datetime.now(UTC)
|
||||
offer.is_active = False
|
||||
await db.commit()
|
||||
await db.refresh(offer)
|
||||
@@ -178,24 +178,19 @@ async def mark_offer_claimed(
|
||||
details=details,
|
||||
)
|
||||
except Exception as exc: # pragma: no cover - defensive logging
|
||||
logger.warning(
|
||||
'Failed to record promo offer claim log for offer %s: %s',
|
||||
offer.id,
|
||||
exc,
|
||||
)
|
||||
logger.warning('Failed to record promo offer claim log for offer', offer_id=offer.id, exc=exc)
|
||||
try:
|
||||
await db.rollback()
|
||||
except Exception as rollback_error: # pragma: no cover - defensive logging
|
||||
logger.warning(
|
||||
'Failed to rollback session after promo offer claim log failure: %s',
|
||||
rollback_error,
|
||||
'Failed to rollback session after promo offer claim log failure', rollback_error=rollback_error
|
||||
)
|
||||
|
||||
return offer
|
||||
|
||||
|
||||
async def deactivate_expired_offers(db: AsyncSession) -> int:
|
||||
now = datetime.utcnow()
|
||||
now = datetime.now(UTC)
|
||||
result = await db.execute(
|
||||
select(DiscountOffer).where(
|
||||
DiscountOffer.is_active == True,
|
||||
@@ -239,16 +234,13 @@ async def deactivate_expired_offers(db: AsyncSession) -> int:
|
||||
)
|
||||
except Exception as exc: # pragma: no cover - defensive logging
|
||||
logger.warning(
|
||||
'Failed to record promo offer disable log for offer %s: %s',
|
||||
payload.get('offer_id'),
|
||||
exc,
|
||||
'Failed to record promo offer disable log for offer', payload=payload.get('offer_id'), exc=exc
|
||||
)
|
||||
try:
|
||||
await db.rollback()
|
||||
except Exception as rollback_error: # pragma: no cover - defensive logging
|
||||
logger.warning(
|
||||
'Failed to rollback session after promo offer disable log failure: %s',
|
||||
rollback_error,
|
||||
'Failed to rollback session after promo offer disable log failure', rollback_error=rollback_error
|
||||
)
|
||||
|
||||
return count
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import logging
|
||||
from collections.abc import Iterable
|
||||
from datetime import datetime
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import delete, func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import FaqPage, FaqSetting
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def get_faq_setting(db: AsyncSession, language: str) -> FaqSetting | None:
|
||||
@@ -21,7 +21,7 @@ async def set_faq_enabled(db: AsyncSession, language: str, enabled: bool) -> Faq
|
||||
|
||||
if setting:
|
||||
setting.is_enabled = bool(enabled)
|
||||
setting.updated_at = datetime.utcnow()
|
||||
setting.updated_at = datetime.now(UTC)
|
||||
else:
|
||||
setting = FaqSetting(
|
||||
language=language,
|
||||
@@ -94,7 +94,7 @@ async def create_faq_page(
|
||||
await db.commit()
|
||||
await db.refresh(page)
|
||||
|
||||
logger.info('✅ Создана страница FAQ %s для языка %s', page.id, language)
|
||||
logger.info('✅ Создана страница FAQ для языка', page_id=page.id, language=language)
|
||||
|
||||
return page
|
||||
|
||||
@@ -117,12 +117,12 @@ async def update_faq_page(
|
||||
if is_active is not None:
|
||||
page.is_active = bool(is_active)
|
||||
|
||||
page.updated_at = datetime.utcnow()
|
||||
page.updated_at = datetime.now(UTC)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(page)
|
||||
|
||||
logger.info('✅ Страница FAQ %s обновлена', page.id)
|
||||
logger.info('✅ Страница FAQ обновлена', page_id=page.id)
|
||||
|
||||
return page
|
||||
|
||||
@@ -130,7 +130,7 @@ async def update_faq_page(
|
||||
async def delete_faq_page(db: AsyncSession, page_id: int) -> None:
|
||||
await db.execute(delete(FaqPage).where(FaqPage.id == page_id))
|
||||
await db.commit()
|
||||
logger.info('🗑️ Страница FAQ %s удалена', page_id)
|
||||
logger.info('🗑️ Страница FAQ удалена', page_id=page_id)
|
||||
|
||||
|
||||
async def bulk_update_order(
|
||||
@@ -139,6 +139,6 @@ async def bulk_update_order(
|
||||
) -> None:
|
||||
for page_id, order in pages:
|
||||
await db.execute(
|
||||
update(FaqPage).where(FaqPage.id == page_id).values(display_order=order, updated_at=datetime.utcnow())
|
||||
update(FaqPage).where(FaqPage.id == page_id).values(display_order=order, updated_at=datetime.now(UTC))
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
"""CRUD операции для платежей Freekassa."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import FreekassaPayment
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def create_freekassa_payment(
|
||||
@@ -41,7 +41,7 @@ async def create_freekassa_payment(
|
||||
db.add(payment)
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
logger.info(f'Создан платеж Freekassa: order_id={order_id}, user_id={user_id}')
|
||||
logger.info('Создан платеж Freekassa: order_id=, user_id', order_id=order_id, user_id=user_id)
|
||||
return payment
|
||||
|
||||
|
||||
@@ -77,10 +77,10 @@ async def update_freekassa_payment_status(
|
||||
"""Обновляет статус платежа."""
|
||||
payment.status = status
|
||||
payment.is_paid = is_paid
|
||||
payment.updated_at = datetime.utcnow()
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
|
||||
if is_paid:
|
||||
payment.paid_at = datetime.utcnow()
|
||||
payment.paid_at = datetime.now(UTC)
|
||||
if freekassa_order_id:
|
||||
payment.freekassa_order_id = freekassa_order_id
|
||||
if payment_system_id is not None:
|
||||
@@ -92,7 +92,12 @@ async def update_freekassa_payment_status(
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
logger.info(f'Обновлен статус платежа Freekassa: order_id={payment.order_id}, status={status}, is_paid={is_paid}')
|
||||
logger.info(
|
||||
'Обновлен статус платежа Freekassa: order_id=, status=, is_paid',
|
||||
order_id=payment.order_id,
|
||||
status=status,
|
||||
is_paid=is_paid,
|
||||
)
|
||||
return payment
|
||||
|
||||
|
||||
@@ -129,7 +134,7 @@ async def get_expired_pending_payments(
|
||||
db: AsyncSession,
|
||||
) -> list[FreekassaPayment]:
|
||||
"""Получает просроченные платежи в статусе pending."""
|
||||
now = datetime.utcnow()
|
||||
now = datetime.now(UTC)
|
||||
result = await db.execute(
|
||||
select(FreekassaPayment).where(
|
||||
FreekassaPayment.status == 'pending',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
@@ -9,7 +9,7 @@ from sqlalchemy.orm import selectinload
|
||||
from app.database.models import HeleketPayment
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def create_heleket_payment(
|
||||
@@ -50,12 +50,12 @@ async def create_heleket_payment(
|
||||
await db.refresh(payment)
|
||||
|
||||
logger.info(
|
||||
'Создан Heleket платеж: uuid=%s order_id=%s amount=%s %s для пользователя %s',
|
||||
uuid,
|
||||
order_id,
|
||||
amount,
|
||||
currency,
|
||||
user_id,
|
||||
'Создан Heleket платеж: uuid= order_id= amount= для пользователя',
|
||||
uuid=uuid,
|
||||
order_id=order_id,
|
||||
amount=amount,
|
||||
currency=currency,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
return payment
|
||||
@@ -107,7 +107,7 @@ async def update_heleket_payment(
|
||||
payment = await get_heleket_payment_by_uuid(db, uuid)
|
||||
|
||||
if not payment:
|
||||
logger.error('Heleket платеж с uuid=%s не найден', uuid)
|
||||
logger.error('Heleket платеж с uuid= не найден', uuid=uuid)
|
||||
return None
|
||||
|
||||
if status is not None:
|
||||
@@ -129,17 +129,17 @@ async def update_heleket_payment(
|
||||
if paid_at is not None:
|
||||
payment.paid_at = paid_at
|
||||
|
||||
payment.updated_at = datetime.utcnow()
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
|
||||
logger.info(
|
||||
'Обновлен Heleket платеж %s: статус=%s payer_amount=%s %s',
|
||||
uuid,
|
||||
payment.status,
|
||||
payment.payer_amount,
|
||||
payment.payer_currency,
|
||||
'Обновлен Heleket платеж : статус= payer_amount',
|
||||
uuid=uuid,
|
||||
payment_status=payment.status,
|
||||
payer_amount=payment.payer_amount,
|
||||
payer_currency=payment.payer_currency,
|
||||
)
|
||||
|
||||
return payment
|
||||
@@ -153,19 +153,15 @@ async def link_heleket_payment_to_transaction(
|
||||
payment = await get_heleket_payment_by_uuid(db, uuid)
|
||||
|
||||
if not payment:
|
||||
logger.error('Не найден Heleket платеж для связи с транзакцией: %s', uuid)
|
||||
logger.error('Не найден Heleket платеж для связи с транзакцией', uuid=uuid)
|
||||
return None
|
||||
|
||||
payment.transaction_id = transaction_id
|
||||
payment.updated_at = datetime.utcnow()
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
|
||||
logger.info(
|
||||
'Heleket платеж %s связан с транзакцией %s',
|
||||
uuid,
|
||||
transaction_id,
|
||||
)
|
||||
logger.info('Heleket платеж связан с транзакцией', uuid=uuid, transaction_id=transaction_id)
|
||||
|
||||
return payment
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
"""CRUD операции для платежей KassaAI."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import KassaAiPayment
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def create_kassa_ai_payment(
|
||||
@@ -43,7 +43,7 @@ async def create_kassa_ai_payment(
|
||||
db.add(payment)
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
logger.info(f'Создан платеж KassaAI: order_id={order_id}, user_id={user_id}')
|
||||
logger.info('Создан платеж KassaAI: order_id=, user_id', order_id=order_id, user_id=user_id)
|
||||
return payment
|
||||
|
||||
|
||||
@@ -79,10 +79,10 @@ async def update_kassa_ai_payment_status(
|
||||
"""Обновляет статус платежа."""
|
||||
payment.status = status
|
||||
payment.is_paid = is_paid
|
||||
payment.updated_at = datetime.utcnow()
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
|
||||
if is_paid:
|
||||
payment.paid_at = datetime.utcnow()
|
||||
payment.paid_at = datetime.now(UTC)
|
||||
if kassa_ai_order_id:
|
||||
payment.kassa_ai_order_id = kassa_ai_order_id
|
||||
if payment_system_id is not None:
|
||||
@@ -94,7 +94,12 @@ async def update_kassa_ai_payment_status(
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
logger.info(f'Обновлен статус платежа KassaAI: order_id={payment.order_id}, status={status}, is_paid={is_paid}')
|
||||
logger.info(
|
||||
'Обновлен статус платежа KassaAI: order_id=, status=, is_paid',
|
||||
order_id=payment.order_id,
|
||||
status=status,
|
||||
is_paid=is_paid,
|
||||
)
|
||||
return payment
|
||||
|
||||
|
||||
@@ -131,7 +136,7 @@ async def get_expired_pending_kassa_ai_payments(
|
||||
db: AsyncSession,
|
||||
) -> list[KassaAiPayment]:
|
||||
"""Получает просроченные платежи в статусе pending."""
|
||||
now = datetime.utcnow()
|
||||
now = datetime.now(UTC)
|
||||
result = await db.execute(
|
||||
select(KassaAiPayment).where(
|
||||
KassaAiPayment.status == 'pending',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -8,7 +8,7 @@ from app.config import settings
|
||||
from app.database.models import MulenPayPayment
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def create_mulenpay_payment(
|
||||
@@ -41,12 +41,12 @@ async def create_mulenpay_payment(
|
||||
await db.refresh(payment)
|
||||
|
||||
logger.info(
|
||||
'Создан %s платеж #%s (uuid=%s) на сумму %s копеек для пользователя %s',
|
||||
settings.get_mulenpay_display_name(),
|
||||
payment.mulen_payment_id,
|
||||
uuid,
|
||||
amount_kopeks,
|
||||
user_id,
|
||||
'Создан платеж # (uuid=) на сумму копеек для пользователя',
|
||||
get_mulenpay_display_name=settings.get_mulenpay_display_name(),
|
||||
mulen_payment_id=payment.mulen_payment_id,
|
||||
uuid=uuid,
|
||||
amount_kopeks=amount_kopeks,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
return payment
|
||||
@@ -90,7 +90,7 @@ async def update_mulenpay_payment_status(
|
||||
if metadata is not None:
|
||||
payment.metadata_json = metadata
|
||||
|
||||
payment.updated_at = datetime.utcnow()
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
return payment
|
||||
@@ -103,7 +103,7 @@ async def update_mulenpay_payment_metadata(
|
||||
metadata: dict,
|
||||
) -> MulenPayPayment:
|
||||
payment.metadata_json = metadata
|
||||
payment.updated_at = datetime.utcnow()
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
return payment
|
||||
@@ -116,7 +116,7 @@ async def link_mulenpay_payment_to_transaction(
|
||||
transaction_id: int,
|
||||
) -> MulenPayPayment:
|
||||
payment.transaction_id = transaction_id
|
||||
payment.updated_at = datetime.utcnow()
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
return payment
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import logging
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import SentNotification
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def notification_sent(
|
||||
|
||||
+12
-16
@@ -2,17 +2,17 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import Pal24Payment
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def create_pal24_payment(
|
||||
@@ -51,11 +51,11 @@ async def create_pal24_payment(
|
||||
await db.refresh(payment)
|
||||
|
||||
logger.info(
|
||||
'Создан Pal24 платеж #%s для пользователя %s: %s копеек (статус %s)',
|
||||
payment.id,
|
||||
user_id,
|
||||
amount_kopeks,
|
||||
status,
|
||||
'Создан Pal24 платеж # для пользователя : копеек (статус)',
|
||||
payment_id=payment.id,
|
||||
user_id=user_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
status=status,
|
||||
)
|
||||
|
||||
return payment
|
||||
@@ -128,10 +128,10 @@ async def update_pal24_payment_status(
|
||||
await db.refresh(payment)
|
||||
|
||||
logger.info(
|
||||
'Обновлен Pal24 платеж %s: статус=%s, is_paid=%s',
|
||||
payment.bill_id,
|
||||
payment.status,
|
||||
payment.is_paid,
|
||||
'Обновлен Pal24 платеж : статус is_paid',
|
||||
bill_id=payment.bill_id,
|
||||
payment_status=payment.status,
|
||||
is_paid=payment.is_paid,
|
||||
)
|
||||
|
||||
return payment
|
||||
@@ -145,9 +145,5 @@ async def link_pal24_payment_to_transaction(
|
||||
await db.execute(update(Pal24Payment).where(Pal24Payment.id == payment.id).values(transaction_id=transaction_id))
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
logger.info(
|
||||
'Pal24 платеж %s привязан к транзакции %s',
|
||||
payment.bill_id,
|
||||
transaction_id,
|
||||
)
|
||||
logger.info('Pal24 платеж привязан к транзакции', bill_id=payment.bill_id, transaction_id=transaction_id)
|
||||
return payment
|
||||
|
||||
@@ -2,17 +2,17 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import PlategaPayment
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def create_platega_payment(
|
||||
@@ -55,11 +55,11 @@ async def create_platega_payment(
|
||||
await db.refresh(payment)
|
||||
|
||||
logger.info(
|
||||
'Создан Platega платеж #%s (tx=%s) на сумму %s копеек для пользователя %s',
|
||||
payment.id,
|
||||
platega_transaction_id,
|
||||
amount_kopeks,
|
||||
user_id,
|
||||
'Создан Platega платеж # (tx=) на сумму копеек для пользователя',
|
||||
payment_id=payment.id,
|
||||
platega_transaction_id=platega_transaction_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
return payment
|
||||
@@ -115,7 +115,7 @@ async def update_platega_payment(
|
||||
if expires_at is not None:
|
||||
payment.expires_at = expires_at
|
||||
|
||||
payment.updated_at = datetime.utcnow()
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
@@ -129,7 +129,7 @@ async def link_platega_payment_to_transaction(
|
||||
transaction_id: int,
|
||||
) -> PlategaPayment:
|
||||
payment.transaction_id = transaction_id
|
||||
payment.updated_at = datetime.utcnow()
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
return payment
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import logging
|
||||
from collections.abc import Iterable, Sequence
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import and_, delete, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
@@ -14,7 +14,7 @@ from app.database.models import (
|
||||
)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def create_poll(
|
||||
@@ -97,7 +97,7 @@ async def delete_poll(db: AsyncSession, poll_id: int) -> bool:
|
||||
|
||||
await db.delete(poll)
|
||||
await db.commit()
|
||||
logger.info('🗑️ Удалён опрос %s', poll_id)
|
||||
logger.info('🗑️ Удалён опрос', poll_id=poll_id)
|
||||
return True
|
||||
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import PrivacyPolicy
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def get_privacy_policy(db: AsyncSession, language: str) -> PrivacyPolicy | None:
|
||||
@@ -26,7 +26,7 @@ async def upsert_privacy_policy(
|
||||
|
||||
if policy:
|
||||
policy.content = content or ''
|
||||
policy.updated_at = datetime.utcnow()
|
||||
policy.updated_at = datetime.now(UTC)
|
||||
else:
|
||||
policy = PrivacyPolicy(
|
||||
language=language,
|
||||
@@ -38,11 +38,7 @@ async def upsert_privacy_policy(
|
||||
await db.commit()
|
||||
await db.refresh(policy)
|
||||
|
||||
logger.info(
|
||||
'✅ Политика конфиденциальности для языка %s обновлена (ID: %s)',
|
||||
language,
|
||||
policy.id,
|
||||
)
|
||||
logger.info('✅ Политика конфиденциальности для языка обновлена (ID:)', language=language, policy_id=policy.id)
|
||||
|
||||
return policy
|
||||
|
||||
@@ -56,7 +52,7 @@ async def set_privacy_policy_enabled(
|
||||
|
||||
if policy:
|
||||
policy.is_enabled = bool(enabled)
|
||||
policy.updated_at = datetime.utcnow()
|
||||
policy.updated_at = datetime.now(UTC)
|
||||
else:
|
||||
policy = PrivacyPolicy(
|
||||
language=language,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import logging
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
@@ -25,7 +24,7 @@ def _normalize_period_discounts(period_discounts: dict[int, int] | None) -> dict
|
||||
return normalized
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def get_promo_groups_with_counts(
|
||||
@@ -194,11 +193,7 @@ async def update_promo_group(
|
||||
await db.commit()
|
||||
await db.refresh(group)
|
||||
|
||||
logger.info(
|
||||
"Обновлена промогруппа '%s' (id=%s)",
|
||||
group.name,
|
||||
group.id,
|
||||
)
|
||||
logger.info("Обновлена промогруппа '' (id=)", group_name=group.name, group_id=group.id)
|
||||
return group
|
||||
|
||||
|
||||
@@ -246,10 +241,10 @@ async def delete_promo_group(db: AsyncSession, group: PromoGroup) -> bool:
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
"Промогруппа '%s' (id=%s) удалена, пользователи переведены в '%s'",
|
||||
group.name,
|
||||
group.id,
|
||||
default_group.name,
|
||||
"Промогруппа '' (id=) удалена, пользователи переведены в ''",
|
||||
group_name=group.name,
|
||||
group_id=group.id,
|
||||
default_group_name=default_group.name,
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
@@ -9,7 +8,7 @@ from sqlalchemy.orm import selectinload
|
||||
from app.database.models import PromoOfferLog
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def log_promo_offer_action(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
from datetime import datetime
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -118,13 +118,13 @@ async def ensure_default_templates(db: AsyncSession, *, created_by: int | None =
|
||||
|
||||
if should_update and new_message:
|
||||
existing.message_text = new_message
|
||||
existing.updated_at = datetime.utcnow()
|
||||
existing.updated_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
|
||||
target_active_hours = template_data.get('active_discount_hours')
|
||||
if target_active_hours is not None and target_active_hours > 0 and not existing.active_discount_hours:
|
||||
existing.active_discount_hours = target_active_hours
|
||||
existing.updated_at = datetime.utcnow()
|
||||
existing.updated_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
templates.append(existing)
|
||||
continue
|
||||
@@ -204,7 +204,7 @@ async def update_promo_offer_template(
|
||||
if is_active is not None:
|
||||
template.is_active = is_active
|
||||
|
||||
template.updated_at = datetime.utcnow()
|
||||
template.updated_at = datetime.now(UTC)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(template)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import and_, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
@@ -8,7 +8,7 @@ from sqlalchemy.orm import selectinload
|
||||
from app.database.models import PromoCode, PromoCodeType, PromoCodeUse, User
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def get_promocode_by_code(db: AsyncSession, code: str) -> PromoCode | None:
|
||||
@@ -78,9 +78,9 @@ async def create_promocode(
|
||||
await db.refresh(promocode)
|
||||
|
||||
if promo_group_id:
|
||||
logger.info(f'✅ Создан промокод: {code} с промогруппой ID {promo_group_id}')
|
||||
logger.info('✅ Создан промокод: с промогруппой ID', code=code, promo_group_id=promo_group_id)
|
||||
else:
|
||||
logger.info(f'✅ Создан промокод: {code}')
|
||||
logger.info('✅ Создан промокод', code=code)
|
||||
return promocode
|
||||
|
||||
|
||||
@@ -97,11 +97,11 @@ async def use_promocode(db: AsyncSession, promocode_id: int, user_id: int) -> bo
|
||||
|
||||
await db.commit()
|
||||
|
||||
logger.info(f'✅ Промокод {promocode.code} использован пользователем {user_id}')
|
||||
logger.info('✅ Промокод использован пользователем', code=promocode.code, user_id=user_id)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка использования промокода: {e}')
|
||||
logger.error('Ошибка использования промокода', error=e)
|
||||
await db.rollback()
|
||||
return False
|
||||
|
||||
@@ -114,13 +114,13 @@ async def check_user_promocode_usage(db: AsyncSession, user_id: int, promocode_i
|
||||
|
||||
|
||||
async def create_promocode_use(db: AsyncSession, promocode_id: int, user_id: int) -> PromoCodeUse:
|
||||
promocode_use = PromoCodeUse(promocode_id=promocode_id, user_id=user_id, used_at=datetime.utcnow())
|
||||
promocode_use = PromoCodeUse(promocode_id=promocode_id, user_id=user_id, used_at=datetime.now(UTC))
|
||||
|
||||
db.add(promocode_use)
|
||||
await db.commit()
|
||||
await db.refresh(promocode_use)
|
||||
|
||||
logger.info(f'📝 Записано использование промокода {promocode_id} пользователем {user_id}')
|
||||
logger.info('📝 Записано использование промокода пользователем', promocode_id=promocode_id, user_id=user_id)
|
||||
return promocode_use
|
||||
|
||||
|
||||
@@ -131,6 +131,15 @@ async def get_promocode_use_by_user_and_code(db: AsyncSession, user_id: int, pro
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def count_user_recent_activations(db: AsyncSession, user_id: int, hours: int = 24) -> int:
|
||||
"""Подсчитывает количество активаций промокодов пользователем за последние N часов."""
|
||||
cutoff = datetime.now(UTC) - timedelta(hours=hours)
|
||||
result = await db.execute(
|
||||
select(func.count(PromoCodeUse.id)).where(and_(PromoCodeUse.user_id == user_id, PromoCodeUse.used_at >= cutoff))
|
||||
)
|
||||
return result.scalar() or 0
|
||||
|
||||
|
||||
async def get_user_promocodes(db: AsyncSession, user_id: int) -> list[PromoCodeUse]:
|
||||
result = await db.execute(
|
||||
select(PromoCodeUse).where(PromoCodeUse.user_id == user_id).order_by(PromoCodeUse.used_at.desc())
|
||||
@@ -167,7 +176,7 @@ async def update_promocode(db: AsyncSession, promocode: PromoCode, **kwargs) ->
|
||||
if hasattr(promocode, field):
|
||||
setattr(promocode, field, value)
|
||||
|
||||
promocode.updated_at = datetime.utcnow()
|
||||
promocode.updated_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
await db.refresh(promocode)
|
||||
|
||||
@@ -183,22 +192,52 @@ async def delete_promocode(db: AsyncSession, promocode: PromoCode) -> bool:
|
||||
await db.delete(promocode)
|
||||
await db.commit()
|
||||
|
||||
logger.info(f'🗑️ Удален промокод: {promocode.code}')
|
||||
logger.info('🗑️ Удален промокод', code=promocode.code)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка удаления промокода: {e}')
|
||||
logger.error('Ошибка удаления промокода', error=e)
|
||||
await db.rollback()
|
||||
return False
|
||||
|
||||
|
||||
async def get_active_discount_promocode_for_user(
|
||||
db: AsyncSession, user_id: int
|
||||
) -> tuple[PromoCode | None, PromoCodeUse | None]:
|
||||
"""
|
||||
Находит активный промокод на скидку, который сейчас действует у пользователя.
|
||||
Ищет по source-полю пользователя (формат 'promocode:CODE').
|
||||
|
||||
Returns:
|
||||
Кортеж (PromoCode, PromoCodeUse) или (None, None) если ничего не найдено.
|
||||
"""
|
||||
from app.database.models import User as UserModel
|
||||
|
||||
user_result = await db.execute(select(UserModel).where(UserModel.id == user_id))
|
||||
user = user_result.scalar_one_or_none()
|
||||
if not user:
|
||||
return None, None
|
||||
|
||||
source = getattr(user, 'promo_offer_discount_source', None)
|
||||
if not source or not source.startswith('promocode:'):
|
||||
return None, None
|
||||
|
||||
code = source.split(':', 1)[1]
|
||||
promocode = await get_promocode_by_code(db, code)
|
||||
if not promocode:
|
||||
return None, None
|
||||
|
||||
use = await get_promocode_use_by_user_and_code(db, user_id, promocode.id)
|
||||
return promocode, use
|
||||
|
||||
|
||||
async def get_promocode_statistics(db: AsyncSession, promocode_id: int) -> dict:
|
||||
total_uses_result = await db.execute(
|
||||
select(func.count(PromoCodeUse.id)).where(PromoCodeUse.promocode_id == promocode_id)
|
||||
)
|
||||
total_uses = total_uses_result.scalar()
|
||||
|
||||
today = datetime.utcnow().date()
|
||||
today = datetime.now(UTC).date()
|
||||
today_uses_result = await db.execute(
|
||||
select(func.count(PromoCodeUse.id)).where(
|
||||
and_(PromoCodeUse.promocode_id == promocode_id, PromoCodeUse.used_at >= today)
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import PublicOffer
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def get_public_offer(db: AsyncSession, language: str) -> PublicOffer | None:
|
||||
@@ -26,7 +26,7 @@ async def upsert_public_offer(
|
||||
|
||||
if offer:
|
||||
offer.content = content or ''
|
||||
offer.updated_at = datetime.utcnow()
|
||||
offer.updated_at = datetime.now(UTC)
|
||||
else:
|
||||
offer = PublicOffer(
|
||||
language=language,
|
||||
@@ -38,11 +38,7 @@ async def upsert_public_offer(
|
||||
await db.commit()
|
||||
await db.refresh(offer)
|
||||
|
||||
logger.info(
|
||||
'✅ Публичная оферта для языка %s обновлена (ID: %s)',
|
||||
language,
|
||||
offer.id,
|
||||
)
|
||||
logger.info('✅ Публичная оферта для языка обновлена (ID:)', language=language, offer_id=offer.id)
|
||||
|
||||
return offer
|
||||
|
||||
@@ -56,7 +52,7 @@ async def set_public_offer_enabled(
|
||||
|
||||
if offer:
|
||||
offer.is_enabled = bool(enabled)
|
||||
offer.updated_at = datetime.utcnow()
|
||||
offer.updated_at = datetime.now(UTC)
|
||||
else:
|
||||
offer = PublicOffer(
|
||||
language=language,
|
||||
|
||||
@@ -1,14 +1,25 @@
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import and_, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import ReferralEarning, User
|
||||
from app.database.models import AdvertisingCampaignRegistration, ReferralEarning, User
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def get_user_campaign_id(db: AsyncSession, user_id: int) -> int | None:
|
||||
"""Получить campaign_id первой регистрации пользователя."""
|
||||
result = await db.execute(
|
||||
select(AdvertisingCampaignRegistration.campaign_id)
|
||||
.where(AdvertisingCampaignRegistration.user_id == user_id)
|
||||
.order_by(AdvertisingCampaignRegistration.created_at.asc())
|
||||
.limit(1)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def create_referral_earning(
|
||||
@@ -18,6 +29,7 @@ async def create_referral_earning(
|
||||
amount_kopeks: int,
|
||||
reason: str,
|
||||
referral_transaction_id: int | None = None,
|
||||
campaign_id: int | None = None,
|
||||
) -> ReferralEarning:
|
||||
earning = ReferralEarning(
|
||||
user_id=user_id,
|
||||
@@ -25,13 +37,16 @@ async def create_referral_earning(
|
||||
amount_kopeks=amount_kopeks,
|
||||
reason=reason,
|
||||
referral_transaction_id=referral_transaction_id,
|
||||
campaign_id=campaign_id,
|
||||
)
|
||||
|
||||
db.add(earning)
|
||||
await db.commit()
|
||||
await db.refresh(earning)
|
||||
|
||||
logger.info(f'💰 Создан реферальный заработок: {amount_kopeks / 100}₽ для пользователя {user_id}')
|
||||
logger.info(
|
||||
'💰 Создан реферальный заработок: ₽ для пользователя', amount_kopeks=amount_kopeks / 100, user_id=user_id
|
||||
)
|
||||
return earning
|
||||
|
||||
|
||||
@@ -40,7 +55,11 @@ async def get_referral_earnings_by_user(
|
||||
) -> list[ReferralEarning]:
|
||||
result = await db.execute(
|
||||
select(ReferralEarning)
|
||||
.options(selectinload(ReferralEarning.referral), selectinload(ReferralEarning.referral_transaction))
|
||||
.options(
|
||||
selectinload(ReferralEarning.referral),
|
||||
selectinload(ReferralEarning.referral_transaction),
|
||||
selectinload(ReferralEarning.campaign),
|
||||
)
|
||||
.where(ReferralEarning.user_id == user_id)
|
||||
.order_by(ReferralEarning.created_at.desc())
|
||||
.offset(offset)
|
||||
@@ -176,7 +195,7 @@ async def get_referral_statistics(db: AsyncSession) -> dict:
|
||||
}
|
||||
)
|
||||
|
||||
today = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
today = datetime.now(UTC).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
today_referral_earnings_result = await db.execute(
|
||||
select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0)).where(ReferralEarning.created_at >= today)
|
||||
@@ -188,7 +207,7 @@ async def get_referral_statistics(db: AsyncSession) -> dict:
|
||||
)
|
||||
today_earnings = today_referral_earnings_result.scalar() + today_transaction_earnings_result.scalar()
|
||||
|
||||
week_ago = datetime.utcnow() - timedelta(days=7)
|
||||
week_ago = datetime.now(UTC) - timedelta(days=7)
|
||||
week_referral_earnings_result = await db.execute(
|
||||
select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0)).where(ReferralEarning.created_at >= week_ago)
|
||||
)
|
||||
@@ -199,7 +218,7 @@ async def get_referral_statistics(db: AsyncSession) -> dict:
|
||||
)
|
||||
week_earnings = week_referral_earnings_result.scalar() + week_transaction_earnings_result.scalar()
|
||||
|
||||
month_ago = datetime.utcnow() - timedelta(days=30)
|
||||
month_ago = datetime.now(UTC) - timedelta(days=30)
|
||||
month_referral_earnings_result = await db.execute(
|
||||
select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0)).where(ReferralEarning.created_at >= month_ago)
|
||||
)
|
||||
@@ -211,7 +230,10 @@ async def get_referral_statistics(db: AsyncSession) -> dict:
|
||||
month_earnings = month_referral_earnings_result.scalar() + month_transaction_earnings_result.scalar()
|
||||
|
||||
logger.info(
|
||||
f'Реферальная статистика: {users_with_referrals} рефералов, {active_referrers} рефереров, выплачено {total_paid} копеек'
|
||||
'Реферальная статистика: рефералов, рефереров, выплачено копеек',
|
||||
users_with_referrals=users_with_referrals,
|
||||
active_referrers=active_referrers,
|
||||
total_paid=total_paid,
|
||||
)
|
||||
|
||||
return {
|
||||
@@ -244,7 +266,7 @@ async def get_top_referrers_by_period(
|
||||
"""
|
||||
from app.database.models import Transaction, TransactionType
|
||||
|
||||
now = datetime.utcnow()
|
||||
now = datetime.now(UTC)
|
||||
if period == 'week':
|
||||
start_date = now - timedelta(days=7)
|
||||
else: # month
|
||||
@@ -375,12 +397,12 @@ async def get_user_referral_stats(db: AsyncSession, user_id: int) -> dict:
|
||||
|
||||
total_earned = await get_referral_earnings_sum(db, user_id)
|
||||
|
||||
month_ago = datetime.utcnow() - timedelta(days=30)
|
||||
month_ago = datetime.now(UTC) - timedelta(days=30)
|
||||
month_earned = await get_referral_earnings_sum(db, user_id, start_date=month_ago)
|
||||
|
||||
from app.database.models import Subscription, SubscriptionStatus
|
||||
|
||||
current_time = datetime.utcnow()
|
||||
current_time = datetime.now(UTC)
|
||||
|
||||
active_referrals_result = await db.execute(
|
||||
select(func.count(User.id))
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import logging
|
||||
from collections.abc import Sequence
|
||||
from datetime import date, datetime, time
|
||||
from datetime import UTC, date, datetime, time
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import and_, desc, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
@@ -9,13 +9,14 @@ from sqlalchemy.orm import selectinload
|
||||
from app.database.models import (
|
||||
ReferralContest,
|
||||
ReferralContestEvent,
|
||||
ReferralContestVirtualParticipant,
|
||||
Transaction,
|
||||
TransactionType,
|
||||
User,
|
||||
)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def create_referral_contest(
|
||||
@@ -164,7 +165,7 @@ async def add_contest_event(
|
||||
referral_id=referral_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
event_type=event_type,
|
||||
occurred_at=datetime.utcnow(),
|
||||
occurred_at=datetime.now(UTC),
|
||||
)
|
||||
db.add(event)
|
||||
await db.commit()
|
||||
@@ -439,7 +440,7 @@ async def get_contest_transaction_breakdown(
|
||||
|
||||
# Сумма покупок подписок
|
||||
subscription_result = await db.execute(
|
||||
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
|
||||
select(func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0)).where(
|
||||
and_(
|
||||
Transaction.user_id.in_(referral_ids),
|
||||
Transaction.is_completed.is_(True),
|
||||
@@ -511,7 +512,7 @@ async def upsert_contest_event(
|
||||
referral_id=referral_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
event_type=event_type,
|
||||
occurred_at=datetime.utcnow(),
|
||||
occurred_at=datetime.now(UTC),
|
||||
)
|
||||
db.add(event)
|
||||
await db.commit()
|
||||
@@ -620,10 +621,10 @@ async def debug_contest_transactions(
|
||||
tx.amount_kopeks for tx in txs_in if tx.type == TransactionType.DEPOSIT.value and tx.payment_method is not None
|
||||
)
|
||||
subscription_in_period = sum(
|
||||
tx.amount_kopeks for tx in txs_in if tx.type == TransactionType.SUBSCRIPTION_PAYMENT.value
|
||||
abs(tx.amount_kopeks) for tx in txs_in if tx.type == TransactionType.SUBSCRIPTION_PAYMENT.value
|
||||
)
|
||||
total_in_period = deposit_in_period + subscription_in_period
|
||||
total_outside = sum(tx.amount_kopeks for tx in txs_out)
|
||||
total_outside = sum(abs(tx.amount_kopeks) for tx in txs_out)
|
||||
|
||||
# Подсчёт ПОЛНЫХ сумм (не только sample, БЕЗ бонусов)
|
||||
full_deposit_result = await db.execute(
|
||||
@@ -641,7 +642,7 @@ async def debug_contest_transactions(
|
||||
full_deposit_total = int(full_deposit_result.scalar_one() or 0)
|
||||
|
||||
full_subscription_result = await db.execute(
|
||||
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
|
||||
select(func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0)).where(
|
||||
and_(
|
||||
Transaction.user_id.in_(referral_ids),
|
||||
Transaction.is_completed.is_(True),
|
||||
@@ -724,7 +725,12 @@ async def sync_contest_events(
|
||||
# Конец дня: 23:59:59.999999
|
||||
contest_end = contest_end.replace(hour=23, minute=59, second=59, microsecond=999999)
|
||||
|
||||
logger.info('Синхронизация конкурса %s: период с %s по %s', contest_id, contest_start, contest_end)
|
||||
logger.info(
|
||||
'Синхронизация конкурса : период с по',
|
||||
contest_id=contest_id,
|
||||
contest_start=contest_start,
|
||||
contest_end=contest_end,
|
||||
)
|
||||
|
||||
stats = {
|
||||
'updated': 0,
|
||||
@@ -767,7 +773,7 @@ async def sync_contest_events(
|
||||
|
||||
for event in events:
|
||||
# Считаем ТОЛЬКО покупки подписок (реальные траты на подписки)
|
||||
subscription_query = select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
|
||||
subscription_query = select(func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0)).where(
|
||||
and_(
|
||||
Transaction.user_id == event.referral_id,
|
||||
Transaction.is_completed.is_(True),
|
||||
@@ -814,7 +820,11 @@ async def sync_contest_events(
|
||||
# Логируем значительные изменения
|
||||
if abs(old_amount - total_paid) > 10000: # больше 100 руб разницы
|
||||
logger.debug(
|
||||
'Событие %s (реферал %s): %s -> %s коп.', event.id, event.referral_id, old_amount, total_paid
|
||||
'Событие (реферал): -> коп.',
|
||||
event_id=event.id,
|
||||
referral_id=event.referral_id,
|
||||
old_amount=old_amount,
|
||||
total_paid=total_paid,
|
||||
)
|
||||
else:
|
||||
stats['skipped'] += 1
|
||||
@@ -823,11 +833,11 @@ async def sync_contest_events(
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
'Синхронизация конкурса %s завершена: обновлено %s, пропущено %s, сумма %s коп.',
|
||||
contest_id,
|
||||
stats['updated'],
|
||||
stats['skipped'],
|
||||
stats['total_amount'],
|
||||
'Синхронизация конкурса завершена: обновлено , пропущено , сумма коп.',
|
||||
contest_id=contest_id,
|
||||
stats=stats['updated'],
|
||||
stats_2=stats['skipped'],
|
||||
stats_3=stats['total_amount'],
|
||||
)
|
||||
|
||||
return stats
|
||||
@@ -860,7 +870,9 @@ async def cleanup_invalid_contest_events(
|
||||
if contest_end.hour == 0 and contest_end.minute == 0 and contest_end.second == 0:
|
||||
contest_end = contest_end.replace(hour=23, minute=59, second=59, microsecond=999999)
|
||||
|
||||
logger.info('Очистка конкурса %s: период с %s по %s', contest_id, contest_start, contest_end)
|
||||
logger.info(
|
||||
'Очистка конкурса : период с по', contest_id=contest_id, contest_start=contest_start, contest_end=contest_end
|
||||
)
|
||||
|
||||
# Считаем сколько было событий до очистки
|
||||
total_before_result = await db.execute(
|
||||
@@ -904,11 +916,11 @@ async def cleanup_invalid_contest_events(
|
||||
remaining = int(remaining_result.scalar_one() or 0)
|
||||
|
||||
logger.info(
|
||||
'Очистка конкурса %s завершена: удалено %s невалидных событий, осталось %s валидных (было %s)',
|
||||
contest_id,
|
||||
deleted,
|
||||
remaining,
|
||||
total_before,
|
||||
'Очистка конкурса завершена: удалено невалидных событий, осталось валидных (было)',
|
||||
contest_id=contest_id,
|
||||
deleted=deleted,
|
||||
remaining=remaining,
|
||||
total_before=total_before,
|
||||
)
|
||||
|
||||
return {
|
||||
@@ -918,3 +930,96 @@ async def cleanup_invalid_contest_events(
|
||||
'contest_start': contest_start.isoformat(),
|
||||
'contest_end': contest_end.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
# ── Виртуальные участники ──────────────────────────────────────────────
|
||||
|
||||
|
||||
async def add_virtual_participant(
|
||||
db: AsyncSession,
|
||||
contest_id: int,
|
||||
display_name: str,
|
||||
referral_count: int,
|
||||
total_amount_kopeks: int = 0,
|
||||
) -> ReferralContestVirtualParticipant:
|
||||
vp = ReferralContestVirtualParticipant(
|
||||
contest_id=contest_id,
|
||||
display_name=display_name,
|
||||
referral_count=referral_count,
|
||||
total_amount_kopeks=total_amount_kopeks,
|
||||
)
|
||||
db.add(vp)
|
||||
await db.commit()
|
||||
await db.refresh(vp)
|
||||
return vp
|
||||
|
||||
|
||||
async def list_virtual_participants(
|
||||
db: AsyncSession,
|
||||
contest_id: int,
|
||||
) -> Sequence[ReferralContestVirtualParticipant]:
|
||||
result = await db.execute(
|
||||
select(ReferralContestVirtualParticipant)
|
||||
.where(ReferralContestVirtualParticipant.contest_id == contest_id)
|
||||
.order_by(ReferralContestVirtualParticipant.referral_count.desc())
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def delete_virtual_participant(
|
||||
db: AsyncSession,
|
||||
participant_id: int,
|
||||
) -> bool:
|
||||
result = await db.execute(
|
||||
select(ReferralContestVirtualParticipant).where(ReferralContestVirtualParticipant.id == participant_id)
|
||||
)
|
||||
vp = result.scalar_one_or_none()
|
||||
if not vp:
|
||||
return False
|
||||
await db.delete(vp)
|
||||
await db.commit()
|
||||
return True
|
||||
|
||||
|
||||
async def update_virtual_participant_count(
|
||||
db: AsyncSession,
|
||||
participant_id: int,
|
||||
referral_count: int,
|
||||
) -> ReferralContestVirtualParticipant | None:
|
||||
result = await db.execute(
|
||||
select(ReferralContestVirtualParticipant).where(ReferralContestVirtualParticipant.id == participant_id)
|
||||
)
|
||||
vp = result.scalar_one_or_none()
|
||||
if not vp:
|
||||
return None
|
||||
vp.referral_count = referral_count
|
||||
await db.commit()
|
||||
await db.refresh(vp)
|
||||
return vp
|
||||
|
||||
|
||||
async def get_contest_leaderboard_with_virtual(
|
||||
db: AsyncSession,
|
||||
contest_id: int,
|
||||
*,
|
||||
limit: int | None = None,
|
||||
) -> list[tuple[str, int, int, bool]]:
|
||||
"""Лидерборд с виртуальными участниками.
|
||||
|
||||
Возвращает список кортежей (display_name, referral_count, total_amount, is_virtual).
|
||||
"""
|
||||
real = await get_contest_leaderboard(db, contest_id)
|
||||
virtual = await list_virtual_participants(db, contest_id)
|
||||
|
||||
merged: list[tuple[str, int, int, bool]] = []
|
||||
for user, score, amount in real:
|
||||
merged.append((user.full_name, score, amount, False))
|
||||
for vp in virtual:
|
||||
merged.append((vp.display_name, vp.referral_count, vp.total_amount_kopeks, True))
|
||||
|
||||
merged.sort(key=lambda x: (-x[1], -x[2]))
|
||||
|
||||
if limit:
|
||||
merged = merged[:limit]
|
||||
|
||||
return merged
|
||||
|
||||
+17
-13
@@ -1,13 +1,13 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import ServiceRule
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def get_rules_by_language(db: AsyncSession, language: str = 'ru') -> ServiceRule | None:
|
||||
@@ -30,7 +30,7 @@ async def create_or_update_rules(
|
||||
|
||||
for rule in existing_rules:
|
||||
rule.is_active = False
|
||||
rule.updated_at = datetime.utcnow()
|
||||
rule.updated_at = datetime.now(UTC)
|
||||
|
||||
new_rules = ServiceRule(title=title, content=content, language=language, is_active=True, order=0)
|
||||
|
||||
@@ -38,7 +38,7 @@ async def create_or_update_rules(
|
||||
await db.commit()
|
||||
await db.refresh(new_rules)
|
||||
|
||||
logger.info(f'✅ Правила для языка {language} обновлены (ID: {new_rules.id})')
|
||||
logger.info('✅ Правила для языка обновлены (ID: )', language=language, new_rules_id=new_rules.id)
|
||||
return new_rules
|
||||
|
||||
|
||||
@@ -47,18 +47,20 @@ async def clear_all_rules(db: AsyncSession, language: str = 'ru') -> bool:
|
||||
result = await db.execute(
|
||||
update(ServiceRule)
|
||||
.where(ServiceRule.language == language, ServiceRule.is_active == True)
|
||||
.values(is_active=False, updated_at=datetime.utcnow())
|
||||
.values(is_active=False, updated_at=datetime.now(UTC))
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
rows_affected = result.rowcount
|
||||
logger.info(f'✅ Очищены правила для языка {language}. Деактивировано записей: {rows_affected}')
|
||||
logger.info(
|
||||
'✅ Очищены правила для языка . Деактивировано записей', language=language, rows_affected=rows_affected
|
||||
)
|
||||
|
||||
return rows_affected > 0
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'❌ Ошибка при очистке правил для языка {language}: {e}')
|
||||
logger.error('❌ Ошибка при очистке правил для языка', language=language, error=e)
|
||||
await db.rollback()
|
||||
raise
|
||||
|
||||
@@ -102,13 +104,13 @@ async def restore_rules_version(db: AsyncSession, rule_id: int, language: str =
|
||||
rule_to_restore = result.scalar_one_or_none()
|
||||
|
||||
if not rule_to_restore:
|
||||
logger.warning(f'Правило с ID {rule_id} не найдено для языка {language}')
|
||||
logger.warning('Правило с ID не найдено для языка', rule_id=rule_id, language=language)
|
||||
return None
|
||||
|
||||
await db.execute(
|
||||
update(ServiceRule)
|
||||
.where(ServiceRule.language == language, ServiceRule.is_active == True)
|
||||
.values(is_active=False, updated_at=datetime.utcnow())
|
||||
.values(is_active=False, updated_at=datetime.now(UTC))
|
||||
)
|
||||
|
||||
restored_rule = ServiceRule(
|
||||
@@ -119,11 +121,13 @@ async def restore_rules_version(db: AsyncSession, rule_id: int, language: str =
|
||||
await db.commit()
|
||||
await db.refresh(restored_rule)
|
||||
|
||||
logger.info(f'✅ Восстановлена версия правил ID {rule_id} как новое правило ID {restored_rule.id}')
|
||||
logger.info(
|
||||
'✅ Восстановлена версия правил ID как новое правило ID', rule_id=rule_id, restored_rule_id=restored_rule.id
|
||||
)
|
||||
return restored_rule
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'❌ Ошибка при восстановлении правил ID {rule_id}: {e}')
|
||||
logger.error('❌ Ошибка при восстановлении правил ID', rule_id=rule_id, error=e)
|
||||
await db.rollback()
|
||||
raise
|
||||
|
||||
@@ -156,5 +160,5 @@ async def get_rules_statistics(db: AsyncSession) -> dict:
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'❌ Ошибка при получении статистики правил: {e}')
|
||||
logger.error('❌ Ошибка при получении статистики правил', error=e)
|
||||
return {'total_active': 0, 'total_all_time': 0, 'languages': {}, 'total_languages': 0, 'error': str(e)}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user