Compare commits
2896 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7bc29c3421 | |||
| 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 | |||
| 2796cd0a1e | |||
| 3d0497211a | |||
| d489c3bbfd | |||
| c634456fb8 | |||
| 4086824a11 | |||
| ea512ff153 | |||
| c8d1254203 | |||
| 66773261bc | |||
| 4b908ce3cf | |||
| 0131b39861 | |||
| 9217d1fefa | |||
| 3eefbbf48f | |||
| 0bfe665b70 | |||
| a85ac342ed | |||
| a7f6008995 | |||
| b07cbaabd5 | |||
| affca76ec0 | |||
| ef3491fb22 | |||
| 67a75235fd | |||
| 26922b942f | |||
| 866f89aea4 | |||
| 75c4dea603 | |||
| e85a2a58cf | |||
| 9a4acf2016 | |||
| 1ea513b3b5 | |||
| c912d6a4de | |||
| d18945d0ee | |||
| c5c8eb880f | |||
| 282ba43b42 | |||
| 6ce42357c5 | |||
| 13bb03ac91 | |||
| 2f58d35c80 | |||
| 021918dd99 | |||
| eb1dd5aa12 | |||
| 545365d923 | |||
| 0ce3e44058 | |||
| a8b51f4aee | |||
| 0efae905bc | |||
| c1f2c4c066 | |||
| 93004b7636 | |||
| 7a42d34bc9 | |||
| 0aa9fc3723 | |||
| e7850a3777 | |||
| c8e8087d69 | |||
| 89b958c726 | |||
| 6bb22b76f7 | |||
| b5df07c7a7 | |||
| dd423efe08 | |||
| 7019799f4c | |||
| 441bc44a24 | |||
| 84b933a0e4 | |||
| 7d69e466d0 | |||
| cca3563422 | |||
| aaf258fbad | |||
| d6ab9f6092 | |||
| caa19331ac | |||
| e9af145934 | |||
| a7abce9a4e | |||
| d01492f982 | |||
| 37567c0090 | |||
| 25b1d7cf69 | |||
| 990a1ddc66 | |||
| 01eaa8bbd5 | |||
| da09be3af7 | |||
| c188ff805e | |||
| e71e2bbaa0 | |||
| 5f5ea6b1ef | |||
| 94c9b6232e | |||
| 36acd7a66a | |||
| 2e86380e2c | |||
| 82d24eea5b | |||
| 8306fa63c0 | |||
| f85097698a | |||
| f7928e68e5 | |||
| 5aac3481a0 | |||
| 715520320d | |||
| b636aec03a | |||
| 59f05efcb3 | |||
| a3fafc6dfc | |||
| d0ad9f6e2e | |||
| 0d84e0ba8f | |||
| 964b7c19b4 | |||
| b3391139f1 | |||
| 0d9498f169 | |||
| 1b212cc8d6 | |||
| bd18e6c187 | |||
| 02f8826132 | |||
| 989da445be | |||
| ac892cc899 | |||
| f8564f353f | |||
| 99c60793b8 | |||
| af45f1aac0 | |||
| b797a93105 | |||
| 4f84dc6324 | |||
| 91102d66f9 | |||
| 4eb6e035db | |||
| d7ac622ddc | |||
| df91ac51a9 | |||
| 658b48f154 | |||
| 3c47dab510 | |||
| e612e38e46 | |||
| 5d3af39137 | |||
| 6f40cdd09c | |||
| df189dcf17 | |||
| e7a82a7e6e | |||
| c990a10c5b | |||
| 52a19f884f | |||
| fc0e7a3347 | |||
| 832deccfe6 | |||
| d80983ec25 | |||
| 7ab108a74d | |||
| 5d66cc21bb | |||
| 294810fb94 | |||
| 45edbd53d0 | |||
| b547bea807 | |||
| d6f4ea1d43 | |||
| 1b1cc7312d | |||
| 9dec4186ed | |||
| 9578a91a9e | |||
| 7b42bfd02d | |||
| 41fb6b0f9b | |||
| 182b9c47cf | |||
| 8b492d7ebb | |||
| 5677fca06f | |||
| 9a32a108c4 | |||
| 98677b90d0 | |||
| 9a2aea038a | |||
| 6c12dd8ce7 | |||
| 345f3c0f1f | |||
| 8c7a6ebc2b | |||
| b5f563f80b | |||
| d155493b7a | |||
| e1b0863642 | |||
| 9ff8cfab5d | |||
| f0ab05e02b | |||
| 7fd6450180 | |||
| 5b9b5a4eed | |||
| f44a59f365 | |||
| 73446e0360 | |||
| 7d64abf6f8 | |||
| 328183d942 | |||
| 34105f8e97 | |||
| 7e37bc4409 | |||
| e12b19099f | |||
| 890c219d90 | |||
| c27128aa30 | |||
| cd80164bee | |||
| c0032c1c0d | |||
| 3d9a8c39dc | |||
| cc5aa4278d | |||
| fe73128a9e | |||
| c9cbb81054 | |||
| 7e16d0edee | |||
| 80e4cdb791 | |||
| 37797ba3f6 | |||
| 334130e587 | |||
| 1ce729629d | |||
| 0a0fc48463 | |||
| b576cb4486 | |||
| e79f86e5ec | |||
| 0b31d7b27f | |||
| bd245076a5 | |||
| fd2e032205 | |||
| fcf84aa41e | |||
| db01725582 | |||
| 9269770703 | |||
| e91cc23156 | |||
| ffc9453b76 | |||
| 3a9404c349 | |||
| de2f3de28a | |||
| 25318c1c41 | |||
| c233ba8a8c | |||
| 2a82b037d8 | |||
| 67083980a3 | |||
| 05f65af8e9 | |||
| 085459dfd3 | |||
| 65af46cdae | |||
| f218852f5f | |||
| 6635666112 | |||
| 83f9d05fe3 | |||
| d47a65c29f | |||
| 626c67a7a7 | |||
| e9c6ea9fc9 | |||
| 318dda9e04 | |||
| c73b0433b9 | |||
| 0c2293fef2 | |||
| 9f5971563b | |||
| 5930506972 | |||
| 6b6d79257e | |||
| 86c2092eff | |||
| 7bd838f0b0 | |||
| 5563314718 | |||
| db69af159b | |||
| 4a16bcbccf | |||
| 8dec623f2d | |||
| 0cb714b3a9 | |||
| 2c3c4ba09c | |||
| 48c6c8dd63 | |||
| d01dd47d57 | |||
| d51d51db55 | |||
| 060ae9decf | |||
| 275c797566 | |||
| d9a4af341e | |||
| a4f337a502 | |||
| 1f55d76459 | |||
| 2760a744db | |||
| f169c08275 | |||
| c69b371c53 | |||
| a45d667c89 | |||
| 894e4e02b2 | |||
| c289b96f1a | |||
| 6425cfb0fb | |||
| a56daca368 | |||
| d0628eebda | |||
| 6a36504699 | |||
| 8db061553f | |||
| 7eb8750d0f | |||
| ae7f63aed0 | |||
| c18b4a3cbb | |||
| c0cada8fb5 | |||
| 1117a1dd34 | |||
| abd312dacc | |||
| 7aa64521d2 | |||
| b99ff79920 | |||
| dff723aede | |||
| 86097b300e | |||
| f6b795e555 | |||
| 7719d035a1 | |||
| 33e11fb25a | |||
| fcdda41541 | |||
| c5183f5a9f | |||
| f200f90150 | |||
| e42421d2ff | |||
| c783884ace | |||
| 6ca1bae6f8 | |||
| e1aeff55d7 | |||
| 10107964ba | |||
| ade2794d52 | |||
| a462657f96 | |||
| 91af4a3818 | |||
| 05f29c25ec | |||
| 5b851f1047 | |||
| fac258a40d | |||
| d92071e8aa | |||
| f6c9304a40 | |||
| b994a5ffd6 | |||
| 0413a0ee6a | |||
| 1fc8d38c70 | |||
| f50fbe232d | |||
| 80e1b68863 | |||
| 4f54ffcc2a | |||
| 6fed2542eb | |||
| c331c33321 | |||
| 338ee832ea | |||
| b7a05f7cd1 | |||
| dd396aa16d | |||
| 814d224359 | |||
| 95ab0236d9 | |||
| 4fbe07919c | |||
| 1752f52197 | |||
| 5fe37e57a6 | |||
| da9997609b | |||
| 92b7c0b602 | |||
| 44b3a0dfab | |||
| b3bbd48723 | |||
| e51afc6bcf | |||
| 0ae70d702e | |||
| 722b800f1d | |||
| cd9287236e | |||
| 18e8e38855 | |||
| a63d587aad | |||
| aba2df927d | |||
| d78f85735b | |||
| a7e0f52e27 | |||
| b9ca73a306 | |||
| c26ccb8e96 | |||
| 38bf8633b7 | |||
| 73d52f2047 | |||
| d781290652 | |||
| 9d453283ec | |||
| 98d01cff49 | |||
| 255c623143 | |||
| 2d69d98d81 | |||
| 8ad2d50b25 | |||
| 372654683a | |||
| 6fd470a02f | |||
| cb4f79dd6e | |||
| b38e06383d | |||
| 5523c6bf76 | |||
| 57981b69c7 | |||
| 6a3e716ad2 | |||
| c21804f8db | |||
| 3937bbffc9 | |||
| 70441da7de | |||
| df2626adae | |||
| 9bffeb4151 | |||
| 8524e2c023 | |||
| 3553ff615d | |||
| ca0682e48f | |||
| 1e1c0e89bc | |||
| 93227b7f41 | |||
| 0a51e4d9ea | |||
| 9e69b0cf22 | |||
| aaaaa25231 | |||
| 10fea329ad | |||
| 408c5c520e | |||
| 6775b06b2e | |||
| 9460c86f7a | |||
| f8d7b3288c | |||
| bd6498fb73 | |||
| cdb3507a56 | |||
| 0b18c16f47 | |||
| f452434b13 | |||
| e6e688a395 | |||
| b1206a84c7 | |||
| 63e45e12de | |||
| 792ff22471 | |||
| 346806bce0 | |||
| 5630e99812 | |||
| 67c3dba1cc | |||
| c1e901ba5e | |||
| b6f2052464 | |||
| c63db708cc | |||
| 3fc702ec65 | |||
| 0aff94de74 | |||
| d8c60ce19f | |||
| f3995c7ca8 | |||
| 1330f394bb | |||
| ec27d63f70 | |||
| 78d785f83f | |||
| 04bb20c1e7 | |||
| 7fdf2dcd51 | |||
| 6d5462b084 | |||
| 956b90e95e | |||
| 15e7725cf7 | |||
| 4205eed54f | |||
| 921ae25dd3 | |||
| cee4139a64 | |||
| 92339ec55a | |||
| 3372736fa3 | |||
| cd0ce908a1 | |||
| b7f8469895 | |||
| 8a96dbd224 | |||
| 840c954203 | |||
| 8b6683302d | |||
| 7a351d3028 | |||
| 5610a91866 | |||
| 0858388b18 | |||
| 1793775fe8 | |||
| ff45a3e28d | |||
| a59252fbb1 | |||
| 44a410babf | |||
| 19a1d93a15 | |||
| 64fe45fcfc | |||
| 6d2b16f180 | |||
| 1f138aa772 | |||
| 65f5e31540 | |||
| ca4252c9cb | |||
| ee9682320f | |||
| 15386ad4d8 | |||
| d93db9b199 | |||
| a54f5347a8 | |||
| c121855078 | |||
| c870df4923 | |||
| 8956fff7d7 | |||
| de782e54ce | |||
| 35b900dfe4 | |||
| c1b70436b7 | |||
| 5ecdfa14bd | |||
| fd68c1c99f | |||
| 0e339450f8 | |||
| 1eadbfe3f8 | |||
| bf0627a301 | |||
| 274f8701a2 | |||
| ea4570ba71 | |||
| 5f39821652 | |||
| 26c78b6814 | |||
| 3e1abcbd51 | |||
| cd6f9a3296 | |||
| 73e4009c35 | |||
| 08d202161c | |||
| c4cde5efa2 | |||
| e0868bca18 | |||
| 248cc51e73 | |||
| 322ad3b5e4 | |||
| 63fd318290 | |||
| c0e3e329af | |||
| f5b6f75cf7 | |||
| e3fb2ec854 | |||
| 8b83f3d08b | |||
| 2493684e71 | |||
| acfad93d93 | |||
| 9bc1a66422 | |||
| 0e4f9c1ce0 | |||
| 58b28d9eb7 | |||
| 94320d4217 | |||
| 44592602dc | |||
| 84702e16df | |||
| ec1870ef8f | |||
| 35dc9cf3e2 | |||
| 08fe4df226 | |||
| 17b442ad07 | |||
| 9cfc1e46ac | |||
| c59b823df2 | |||
| 6bbfd0caac | |||
| 39fed57876 | |||
| ed0ae70ff4 | |||
| c0062b1ca3 | |||
| 5bdb06a280 | |||
| a42a884eb6 | |||
| 8726e33665 | |||
| ba7eb8a1e8 | |||
| 4599801f83 | |||
| a5f17b6802 | |||
| c804505361 | |||
| 3de9937442 | |||
| 782d910f25 | |||
| ac87d47bf3 | |||
| 3dc7a80d87 | |||
| c61cd0b42e | |||
| 5fcc202542 | |||
| 1b4758cdbf | |||
| 5cb0ce3030 | |||
| e7bd52463d | |||
| c1f035b13d | |||
| 8d888f3e56 | |||
| 1a990bd776 | |||
| b0f83f3534 | |||
| 723a49f1f1 | |||
| e31c9118b3 | |||
| 7ef416eff2 | |||
| 29d0190a05 | |||
| 5eec0aa1a0 | |||
| 02ae44a594 | |||
| b9801614f3 | |||
| 0ea1076761 | |||
| 94cd06302a | |||
| 5a64dbf209 | |||
| b392a99f56 | |||
| 07417a4877 | |||
| 1c2dca2c65 | |||
| ab492f3aef | |||
| 1c7ed68674 | |||
| fed3fda83e | |||
| 8db459e206 | |||
| 4f3a849d03 | |||
| 9eec5ef9c7 | |||
| 7106c89711 | |||
| fd947bc562 | |||
| b088328439 | |||
| dcaf2b8103 | |||
| 1024a88d19 | |||
| d98baf23aa | |||
| 192f014180 | |||
| 7f6be1c8f0 | |||
| cfe780122b | |||
| 4b7dcbbca4 | |||
| 81bda22cb7 | |||
| 9230efcd31 | |||
| 7cacc73187 | |||
| 53d1828d54 | |||
| 31fbccca26 | |||
| dbf4c1f521 | |||
| a089947aee | |||
| 1337339d23 | |||
| 0a3f67a1f0 | |||
| 1c1a15984e | |||
| e22e786c88 | |||
| a32e3fc582 | |||
| a7303d765f | |||
| 4f66e947b9 | |||
| 46efd56e20 | |||
| 2c124f1343 | |||
| 68ce06e97e | |||
| 577d731adb | |||
| f2e25fe585 | |||
| 65c087aefb | |||
| be9d7290cb | |||
| 9d431bc576 | |||
| 0e907d80fa | |||
| 192381d148 | |||
| 73bf9b757a | |||
| 0a67a55c5e | |||
| 3ef4183650 | |||
| 365bcd15a5 | |||
| 6483b37bd3 | |||
| ec9eaf4271 | |||
| 1c03cd8425 | |||
| 176d17fe40 | |||
| 4e69e0ea0f | |||
| e33995c446 | |||
| a72e029d1d | |||
| d07390f379 | |||
| 4278a84231 | |||
| 088236e136 | |||
| 53db561909 | |||
| 6d2cb32e5f | |||
| 7b23c61703 | |||
| 12f592a3dc | |||
| 9422b9421f | |||
| 5b7fdff186 | |||
| 395646168d | |||
| 572584710c | |||
| 23f0be4915 | |||
| 79904b58a9 | |||
| bba3b478f5 | |||
| 829da095e0 | |||
| 8bafcc89f5 | |||
| f7563dc57c | |||
| a80f6f8700 | |||
| 2e77c5b74e | |||
| 75108682cc | |||
| 7113a381c5 | |||
| 26865059b8 | |||
| c4382ff69d | |||
| 13770a01c6 | |||
| a2a09c8914 | |||
| bcd3c26843 | |||
| 7f65d398bf | |||
| a53219525f | |||
| c868ef3b69 | |||
| 34b87cba88 | |||
| 3aa5e304c3 | |||
| 4b338b3ce2 | |||
| ba4c41a689 | |||
| e4c4b11d74 | |||
| 5fa4cf78c0 | |||
| d2c2f3ced0 | |||
| 7a65dd7fb6 | |||
| e33e8bcc2d | |||
| b49031ea72 | |||
| c02c5472a5 | |||
| 243eef066c | |||
| e82b6cd4c0 | |||
| e1d3363122 | |||
| 25623ea211 | |||
| 3557c8c285 | |||
| 84abf529de | |||
| 48effb5087 | |||
| 237bd24fb3 | |||
| 6bea51b6dc | |||
| 834c542a57 | |||
| 9c2b17af3b | |||
| c9d1a6bb55 | |||
| a174943bcc | |||
| b651867aac | |||
| 10b5b37c48 | |||
| f19445bcf1 | |||
| 171b4e2ab4 | |||
| a1ab2f3727 | |||
| 58eae42eee | |||
| 6937bcbc3d | |||
| 4730cf30e9 | |||
| ce7d7a206c | |||
| fe4e261115 | |||
| 38613722b3 | |||
| a686333603 | |||
| eac299d0bd | |||
| 31241a6f01 | |||
| e5faaa6c6f | |||
| 96384cfdcf | |||
| 91f75acb77 | |||
| 11167d532b | |||
| 70e747b866 | |||
| 87f909d9bc | |||
| 808bc2d40b | |||
| 2f06285f69 | |||
| 7fb7116101 | |||
| 9d1b4bcb54 | |||
| 1583511854 | |||
| 45d7c2f659 | |||
| e92b14391b | |||
| 61cc2a72cd | |||
| 9b3a39b13f | |||
| 1b51886dd1 | |||
| cd5116e62f | |||
| a9499337a0 | |||
| 2745b015d7 | |||
| 8e44db357a | |||
| ac5850746e | |||
| c36d51be30 | |||
| e9a9b85791 | |||
| 43548fdb27 | |||
| 4e3b6a081c | |||
| a2380b937c | |||
| fe9feaf65d | |||
| cec597cfd9 | |||
| 40f1312d0b | |||
| e4d54677fa | |||
| f59614f14f | |||
| c42444429e | |||
| 7692e85ef0 | |||
| 5fdbb6fe7e | |||
| 26f3d4e052 | |||
| fab17702c5 | |||
| 40fad0d537 | |||
| 67f60ba41a | |||
| eb40d8f48a | |||
| e987b25e6b | |||
| 25f2a9507d | |||
| db54b01f04 | |||
| df17f2be4a | |||
| 26e242cac7 | |||
| bcef64bafa | |||
| 97711ac735 | |||
| 0ac2a7a62b | |||
| c350195bfc | |||
| 6e8c9bda30 | |||
| ae94ed1dcf | |||
| d9514ac00e | |||
| a9e56504a7 | |||
| 0f8a367b24 | |||
| 09bb52f8e5 | |||
| 0903f42da9 | |||
| 3839f6f709 | |||
| 0759657185 | |||
| 71ff89ca55 | |||
| 030acf07a4 | |||
| 891b388799 | |||
| 92a6231b6f | |||
| 7aac2fadf7 | |||
| 640a80953f | |||
| 2090da3603 | |||
| 62bec70db9 | |||
| 8c5a385f14 | |||
| 78c44a1e00 | |||
| 1c23538b71 | |||
| 4967c7ff7d | |||
| 2b1a20e373 | |||
| 6fc3dd0e35 | |||
| 7c4471a510 | |||
| 24666dd155 | |||
| 42978e2a37 | |||
| 22884b5cdd | |||
| 785eabaa47 | |||
| 538c002f8f | |||
| 472ef37490 | |||
| 9e4fa9defe | |||
| 49bd69f987 | |||
| e15aed6c19 | |||
| 2e4e62d324 | |||
| b7137c9498 | |||
| 15f6108674 | |||
| 0e24a5505c | |||
| ffbb879752 | |||
| 8f1b71408e | |||
| 62a8e6cdc6 | |||
| a18cfd0147 | |||
| 2619db3f40 | |||
| fea2283d88 | |||
| 479a2b8344 | |||
| 453577b448 | |||
| b5234f3265 | |||
| edb335ee52 | |||
| 0333720fca | |||
| 76b521879e | |||
| 4a24dbe267 | |||
| fdd40ed923 | |||
| b2b48b8bde | |||
| 10f9622df7 | |||
| 1586a3fe9a | |||
| d4556038cc | |||
| a54a12825e | |||
| efae4b0c44 | |||
| cda70afdef | |||
| ead8266e0e | |||
| daf8214c40 | |||
| 9dacd3be24 | |||
| a7fa36ca9b | |||
| fae72d7107 | |||
| f94c1437e4 | |||
| a8d167ac9a | |||
| 8b9ff3a32b | |||
| 8482e4caad | |||
| b2577d5973 | |||
| 752abfc6b5 | |||
| b213f7deb4 | |||
| 7dfbd833bb | |||
| 83812e1d4c | |||
| 21b2add7ac | |||
| 1f52c39fed | |||
| b9c9a82b6f | |||
| 0a0f59ea24 | |||
| aa6935464b | |||
| f50d6fc28a | |||
| 3656f0181c | |||
| 7c3b3188c1 | |||
| fc7d3d1823 | |||
| 3117d02c2f | |||
| c2fe9f082f | |||
| ce66c08e1a | |||
| c58faff2df | |||
| cb6aa4bad4 | |||
| 11a35e1058 | |||
| d51cb4a02d | |||
| deae83d2cd | |||
| 0ad0462afb | |||
| ca5f9af325 | |||
| db98b20118 | |||
| 66cd99e2a8 | |||
| 217df39e10 | |||
| 752850e56b | |||
| 6a1eb3a530 | |||
| 2b89748384 | |||
| 638c60b39b | |||
| f27b6c55fd | |||
| e5c1d4166a | |||
| 34a5b0345f | |||
| c9f67e6db3 | |||
| 8eaec9b032 | |||
| 39c3ec9ed4 | |||
| a598237465 | |||
| c5a35cd254 | |||
| c7a51915c9 | |||
| 29725d6028 | |||
| 60846ad06b | |||
| 7a86cb6010 | |||
| 16e70bff46 | |||
| 4c947a3de9 | |||
| 2c4b77ff8c | |||
| e6676720f2 | |||
| 83769df0ee | |||
| b2e00ecbbe | |||
| 7ea906bd1e | |||
| 744428e4f6 | |||
| 5d1d561882 | |||
| 69f4fd2b2a | |||
| 8690d5fa8e | |||
| 209ce2c25a | |||
| 90d090a048 | |||
| e66944e2bf | |||
| 57e4ccea28 | |||
| 011f4428e2 | |||
| 4d62fa946b | |||
| fc9552a1bf | |||
| af5401a393 | |||
| 3685af771e | |||
| bc7470b39d | |||
| cd0a25abec | |||
| 553e8e1ecd | |||
| 6e1d671df2 | |||
| b482c76a5c | |||
| 4e6d5b4716 | |||
| 879650c2e4 | |||
| d9ce09b3bf | |||
| 678bd18509 | |||
| 805e3847b9 | |||
| eaeb6def51 | |||
| 4c1ebd9f61 | |||
| 51b88068bd | |||
| f25ed360fc | |||
| 75b1fff791 | |||
| 4ed23caa68 | |||
| 098e297e3a | |||
| 20315cf88f | |||
| 28c953c5ae | |||
| 685305bff2 | |||
| 927eb1d240 | |||
| c767473184 | |||
| 744e5c1453 | |||
| 5eb581c68e | |||
| cf53801408 | |||
| 4e59d0a071 | |||
| c31de445b9 | |||
| cc3f78c2f8 | |||
| 5eae547cb2 | |||
| 7e8e0c1617 | |||
| 250b8d95fb | |||
| 28e868fb09 | |||
| 45e9a29b4e | |||
| 8a52b34760 | |||
| 464df9689e | |||
| 6191a7a395 | |||
| 233f6c3490 | |||
| 0e5b115d64 | |||
| 69bf99f3a6 | |||
| dae5c07318 | |||
| d53bf4ae29 | |||
| 50dd5a5fb3 | |||
| 9595026d3d | |||
| 571018982f | |||
| cfdfe1ccd1 | |||
| d9f0d6496d | |||
| 0eba4cfc8f | |||
| 6392033579 | |||
| 2607ee4d0a | |||
| 3299d47b11 | |||
| 4afefcafa4 | |||
| c582e1b0c6 | |||
| 3eb84338a4 | |||
| 127a609d6b | |||
| 528944f649 | |||
| 48fa739ca7 | |||
| 07e50f449f | |||
| 47433d905e | |||
| b096003683 | |||
| 5814bbc920 | |||
| fdffe7ae35 | |||
| 917fa84838 | |||
| 9631f340c1 | |||
| 61fe7f0be4 | |||
| a8c54c4d3b | |||
| db7e6cf8f8 | |||
| 9690436b9f | |||
| fefc46e5c4 | |||
| 14fc1f58af | |||
| f933992883 | |||
| fdc382f309 | |||
| 0ef3e32c15 | |||
| 9c870ffc70 | |||
| 6ce87698bc | |||
| 60a38d3ea6 | |||
| 5355f41bef | |||
| 0fac84aa7d | |||
| b4e472b873 | |||
| 6c0c3f1b79 | |||
| 203405438f | |||
| a38c12eaa6 | |||
| e48ebca91e | |||
| e32bf5f2c4 | |||
| fbb7d6c4ab | |||
| 29f967469f | |||
| 541d3c903d | |||
| f19e2b2a34 | |||
| 3d0050139c | |||
| 0b677e4205 | |||
| 71dac493d8 | |||
| fc1528532e | |||
| 48cb19170b | |||
| 1af1919a14 | |||
| 5d864d0286 | |||
| a448a2c450 | |||
| e301d49657 | |||
| 738216cf9f | |||
| 7b6f646d7e | |||
| 3150349ffa | |||
| b50478eda0 | |||
| cff00eb515 | |||
| a981bf2ae0 | |||
| 031c2b683b | |||
| 72d2501be5 | |||
| 69ff87ab31 | |||
| e4fce0f430 | |||
| 5fb0699dcb | |||
| 965ae9d1d8 | |||
| 23b64ffb2a | |||
| 9868c5de0f | |||
| aeaaa54920 | |||
| fb25032284 | |||
| 3e0661da39 | |||
| 7ab9fe9ad2 | |||
| 7c846c2f83 | |||
| 163f55ec14 | |||
| 2c8e67ac82 | |||
| e9ce583eb2 | |||
| 416908aea4 | |||
| 6223a5d63a | |||
| 53a10c0640 | |||
| 7c2408209d | |||
| 9fbc3a8312 | |||
| dff08cbad2 | |||
| a2379b9be1 | |||
| 12dc9ccbaa | |||
| 8342e8fe35 | |||
| 03d30dd5c0 | |||
| 27512825ae | |||
| 4b74ae12ad | |||
| 258a4a5cb4 | |||
| 2cd2147464 | |||
| c5efc7ce06 | |||
| 9cd5d8e0b9 | |||
| 31d538fbcf | |||
| 2156f630dc | |||
| 917ca69b1d | |||
| d524088bb8 | |||
| 9bd1944ba3 | |||
| c41979bda6 | |||
| 0813d585d2 | |||
| 3f74005068 | |||
| 6b69ec750e | |||
| 5a5a18d80d | |||
| 4bebff5c4a | |||
| e2ae7dd34d | |||
| e15728e369 | |||
| ecaf270e04 | |||
| 08e864e5ea | |||
| 97655b8616 | |||
| 800d589afa | |||
| ff51a984ef | |||
| a4072237cc | |||
| ac83273a22 | |||
| 073d96fb27 | |||
| 8e6082ce15 | |||
| 08692145d2 | |||
| 56cc8bacf2 | |||
| 096b4d4fe3 | |||
| 9dd3299744 | |||
| bc19ec32bb | |||
| 720f0ecb60 | |||
| a9fd4c2466 | |||
| 5aa9b6ddb3 | |||
| 180cba4561 | |||
| 25dc7ff624 | |||
| 1233d38fe1 | |||
| d60ebaef41 | |||
| dd86014667 | |||
| bb8beff511 | |||
| b276d764a3 | |||
| ee78ab0932 | |||
| e9daa76d7e | |||
| 780c3956fa | |||
| e582802e39 | |||
| d13b20d380 | |||
| 5918f296ff | |||
| 2f1ef8a60d | |||
| cefb6602f7 | |||
| b6503f9af9 | |||
| 76f465e0f6 | |||
| 63ec894615 | |||
| c9c25613af | |||
| 6ecaa406aa | |||
| 22c8f73eac | |||
| d4bc7d0b51 | |||
| aa3c9231b0 | |||
| bce05d4bc4 | |||
| f107109091 | |||
| 826a554d55 | |||
| fd5801839c | |||
| 8843d86d9b | |||
| 449f82d538 | |||
| abce475ccc | |||
| 22129ecbec | |||
| 2a2a3daaae | |||
| ac94d5d708 | |||
| d10ccc4844 | |||
| de733a5f08 | |||
| 4cab3f5ed4 | |||
| 26b85b16b5 | |||
| ea770c32e8 | |||
| 23f8bdfbec | |||
| a362ef9f25 | |||
| 1b736b381d | |||
| d343a317ee | |||
| 3fc9e63653 | |||
| 9f12462871 | |||
| e293470c14 | |||
| 6322e970ef | |||
| 8a40d54c03 | |||
| 0264c24743 | |||
| 1aade85fc9 | |||
| a5874a9873 | |||
| 6f9dc45e16 | |||
| 34db5a0d28 | |||
| 34517427b0 | |||
| cad9abd8bd | |||
| bf9728352e | |||
| 58c924b70a | |||
| 64ffffdf90 | |||
| e71a3c1af7 | |||
| 265d2b907b | |||
| 54ffe3e126 | |||
| 7e3d7d4771 | |||
| 05bfd89a02 | |||
| 3bf540427e | |||
| bf6dc3991a | |||
| fb9405cccd | |||
| 0b34e90372 | |||
| 04e83814b4 | |||
| 0df3018703 | |||
| 8eb2be15ff | |||
| 648fba6e2a | |||
| 3df8d0cb24 | |||
| a59a15af7f | |||
| c919953219 | |||
| 5e6baac07e | |||
| 42b06ea8bc | |||
| 07ded3710c | |||
| 7d0977066b | |||
| d8e57ab766 | |||
| 83cb1ec823 | |||
| da46e39c61 | |||
| 86dd18fbe7 | |||
| 21f34a9b08 | |||
| 0538d0e337 | |||
| 1e64b65586 | |||
| 90c3df6331 | |||
| b9ea5a80ac | |||
| 40d6514dee | |||
| 4a2b254d99 | |||
| 6c740ad984 | |||
| 929a8d5fa9 | |||
| b0bf5131d4 | |||
| ef8d9fe1ff | |||
| d57e0743a1 | |||
| 664cbff1ce | |||
| 6ea52bf406 | |||
| 0ddf24125b | |||
| f5a533e407 | |||
| dd87b4d408 | |||
| 2c6c7056e8 | |||
| 8a10b96fef | |||
| c0a85bad84 | |||
| fbfc00586c | |||
| 5723c3b379 | |||
| f1be66d1a5 | |||
| b3cdd3c03a | |||
| 01afce002a | |||
| 63a9c1afd1 | |||
| 0c9f667cd8 | |||
| dc428baa6a | |||
| f65aa6a82d | |||
| c986b127a7 | |||
| 94a7a5fce8 | |||
| f07bbe0fd0 | |||
| 46f61dbc57 | |||
| 41502cadcd | |||
| aa5e6841f8 | |||
| 8a98a7ee84 | |||
| e1d6c73557 | |||
| b4d66bd34d | |||
| 1496ec901d | |||
| 78ce6bd428 | |||
| 9ec52dcf95 | |||
| 80a13e6b71 | |||
| 9231f770a3 | |||
| f8ba587e5c | |||
| 9280eecf4c | |||
| a36b15e8ad | |||
| 3e833f8a8c | |||
| 48044b56c1 | |||
| 3dab9df26c | |||
| 65da3a57a7 | |||
| 791482a8a1 | |||
| 7b03f3e553 | |||
| 47698882fa | |||
| 2c4a55f7c7 | |||
| f8053e14c3 | |||
| 2073d42fa8 | |||
| 076eb89760 | |||
| 34878a2ef3 | |||
| 6ae573ed48 | |||
| 482c42e9bf | |||
| 66092af999 | |||
| c7eabdf90e | |||
| ee6f4b35b0 | |||
| 7aea87a227 | |||
| a2b039d092 | |||
| f2d2c0a8a4 | |||
| ea3033a088 | |||
| 07f445b485 | |||
| a94cb355aa | |||
| 9f14ae67da | |||
| 44247dee03 | |||
| c66af415d5 | |||
| 6b2d7618a7 | |||
| 866fa56f5b | |||
| ea48036010 | |||
| 4077b2a032 | |||
| a69500ce91 | |||
| 9a1e57a764 | |||
| 88657dd3e2 | |||
| 85d4c9c208 | |||
| 2bda60cd18 | |||
| 73e5e1b5a3 | |||
| 3f1b65b602 | |||
| fa7dd7434b | |||
| 0243d3a2ba | |||
| 0951c9f6dd | |||
| 6891cc1f36 | |||
| dbe805662a | |||
| cd5c293b71 | |||
| 58e789b094 | |||
| 3fd48807d1 | |||
| 76b32ea4fe | |||
| 60670e44a2 | |||
| 69f1b91ce7 | |||
| c78313b5db | |||
| 55a7ec6b11 | |||
| 41d94cd8e7 | |||
| 2422f56137 | |||
| bb7b95c971 | |||
| a17344858b | |||
| a45dd70531 | |||
| 2bcda34f1c | |||
| ac698b3a74 | |||
| e52a47bfb3 | |||
| 47d7bdd632 | |||
| f7be2911cd | |||
| 104cf899ee | |||
| 6f0e3c0bfd | |||
| 09d8261cbf | |||
| 3c9580dc30 | |||
| 1d72419243 | |||
| ae420a592a | |||
| a2586d88e1 | |||
| 0b61878de8 | |||
| 5ad9fd8ae7 | |||
| f9cfaa4bc1 | |||
| e1cc7a2e23 | |||
| 7a0faa4b0a | |||
| a87d067133 | |||
| 42759854c2 | |||
| b2027df7f3 | |||
| ce865a9b2a | |||
| 7dcd11b964 | |||
| 6482284cd8 | |||
| 7f8fbf069b | |||
| 3e6064756e | |||
| 1d1a9e0d78 | |||
| bc6e57765e | |||
| 16031023cb | |||
| 151ce092b9 | |||
| 01d3659ad2 | |||
| 7dd0f5f7b0 | |||
| cfd79f4b23 | |||
| 0cd355a878 | |||
| 344ff65710 | |||
| 1b47f2aa99 | |||
| 1d01a77457 | |||
| aa669fa3cd | |||
| e487776510 | |||
| 9d038f7443 | |||
| 02ebcd368e | |||
| 7e1a2998af | |||
| 0a5383eb80 | |||
| 763d8f2aaa | |||
| ce11ec7c0e | |||
| 1bb0a5c000 | |||
| 2cb6dfb4aa | |||
| 837473b274 | |||
| aab32299ac | |||
| 2a3f1bac76 | |||
| f4ed274623 | |||
| 32c28aedea | |||
| 69927a4db2 | |||
| bdc1df737b | |||
| c8c79ea807 | |||
| d75fc0c60f | |||
| 931b282f5b | |||
| 10bc00d429 | |||
| dd24b7ffde | |||
| 5fa627bd7f | |||
| b8671ef07d | |||
| 7a30aa23dc | |||
| a87d52f2bc | |||
| 5919cfff16 | |||
| e743689b34 | |||
| 37dd5ede9f | |||
| 930aabc166 | |||
| f70ad92efe | |||
| 01fd7a317d | |||
| 507df0af33 | |||
| af91423dec | |||
| 893ff0e8f1 | |||
| cde838864f | |||
| 2eb2661848 | |||
| d7a935906e | |||
| b81400105f | |||
| 8c3e71bdfd | |||
| 025d50675f | |||
| 5f540d907e | |||
| c6175255a5 | |||
| 71facd9624 | |||
| 89a5f5c6f7 | |||
| 7b60be1ec7 | |||
| ed314ca19b | |||
| 0b714893e7 | |||
| 5ca0f5fc27 | |||
| 31fa7b7072 | |||
| 86d7003c33 | |||
| 753d77b4c2 | |||
| 4add298aab | |||
| e76f2f3d50 | |||
| dcb0002f4a | |||
| 332c20fc45 | |||
| 305a3c4490 | |||
| a9ecd5f620 | |||
| 2f0594e361 | |||
| 65b36c3a5f | |||
| f8ef2b9f5a | |||
| 70a283d1f1 | |||
| f2a5032f15 | |||
| 0f5fcdd067 | |||
| 80388be54f | |||
| 47f0b77138 | |||
| 162e7da350 | |||
| 07e1e08b1d | |||
| fc955bd3b2 | |||
| 1b504063d2 | |||
| 174f9b8517 | |||
| 3706d5ceb4 | |||
| 3577054d79 | |||
| afd4fe8d1d | |||
| 1409a0ab8d | |||
| 9df8ca52d6 | |||
| 0902efc007 | |||
| 0b4fea02a1 | |||
| 01580aae74 | |||
| 7e022a1eca | |||
| f31c64602c | |||
| f193b0bfcf | |||
| b961529001 | |||
| afa172f3cc | |||
| 8be1d7e423 | |||
| 8c4db0a0e6 | |||
| 1e56e4eaa6 | |||
| a8da98c35e | |||
| 7ca6942443 | |||
| 3f97d22a76 | |||
| e3dccf2c1b | |||
| d0e25fff3d | |||
| 3699d248b1 | |||
| bffbf3ea39 | |||
| e869c9b167 | |||
| 0b2b109e86 | |||
| 5dd586e0b2 | |||
| c9de084efa | |||
| 81b3c7ed3f | |||
| 80785f22b0 | |||
| b1cb368bf6 | |||
| 9fc977ace3 | |||
| 47acd37449 | |||
| d7fa956f58 | |||
| 98164c2083 | |||
| 81f5ce429e | |||
| 5a3bded4a8 | |||
| 2743a845bd | |||
| 7c222c0aed | |||
| 3222c82b46 | |||
| 9f0f3a367c | |||
| f702356f48 | |||
| 48d799e312 | |||
| 7c05b36b9d | |||
| b398eecbeb | |||
| 5ab80b09a1 | |||
| 535b92d43b | |||
| cbaddd65e0 | |||
| 136cae68f1 | |||
| 38abf49af9 | |||
| 799243a988 | |||
| 6d024716c7 | |||
| 0892f494d9 | |||
| 42b67bdc47 | |||
| 6c41263511 | |||
| 1aab9550ce | |||
| d596b19d96 | |||
| 0863e3023d | |||
| 1ef2e1264f | |||
| c40806a5a3 | |||
| 841c288313 | |||
| cd1a4a4a6e | |||
| e9e61f4892 | |||
| 167cddcd2f | |||
| 5b4c597e9b | |||
| 0cb1c9b580 | |||
| ff4471a22a | |||
| 93d7bd74a3 | |||
| c770948c1f | |||
| 2e1a6faec1 | |||
| 0031c9e2e0 | |||
| 637d2a07e0 | |||
| 988ffbebdb | |||
| 5a795a2ae2 | |||
| 9b6cd74dbf | |||
| daa2f13ca3 | |||
| 386b9ae998 | |||
| e64854dc48 | |||
| 23eed94009 | |||
| f04ffa58e4 | |||
| fc45db6f3c | |||
| b8874772be | |||
| d8c7793a26 | |||
| 10d08d5b40 | |||
| b07a889826 | |||
| a24b4c72e9 | |||
| 5e1d00ad0d | |||
| d33ebb1453 | |||
| 7a2bcf3d2f | |||
| dbb68582e4 | |||
| 479b9bc384 | |||
| 73f3987481 | |||
| 8b38f1e37d | |||
| 6976129972 | |||
| 207e4673c0 | |||
| 160ff7ff9f | |||
| 6c7c57138d | |||
| 12b5a39194 | |||
| 7678150e6a | |||
| 99e35cae4c | |||
| af313a12ed | |||
| 71d18287fe | |||
| 6b24b69b53 | |||
| 9528457b89 | |||
| 69322640c5 | |||
| 3c0703b599 | |||
| d14aa5bd8a | |||
| baa5da243e | |||
| fff01d1ce3 | |||
| 52c8442423 | |||
| 150f9e741a | |||
| 4ae2234b7f | |||
| 71366a8133 | |||
| fb6cda2c63 | |||
| 2d4f9da9a7 | |||
| 7a39abe05c | |||
| 79659ec5fe | |||
| a3457d5853 | |||
| 0392aa5b45 | |||
| 2c68d9ded9 | |||
| a437ebd65b | |||
| e2323755de | |||
| 5e23920723 | |||
| 5e45fc1285 | |||
| ff1556ffcd | |||
| 549e8fa332 | |||
| 8a7fb598fa | |||
| 521c364fbb | |||
| db60f5c5ba | |||
| affc07985e | |||
| 2761255e65 | |||
| 53988c319b | |||
| 777ea3b99b | |||
| 672ce18f38 | |||
| aafcec2716 | |||
| 4e5b607288 | |||
| aae85683a0 | |||
| 9971c43218 | |||
| b4370e9a34 | |||
| 101e27acd6 | |||
| 5b9002896e | |||
| e8ac14b0b7 | |||
| 7cd6c3acb7 | |||
| 7e7795fc4e | |||
| 1f75413abd | |||
| 3c6d674ce5 | |||
| 92efc52f7e | |||
| c558f69d62 | |||
| 168cb5ea38 | |||
| 6a48cc687c | |||
| 9a5b0553c6 | |||
| 7515964599 | |||
| bf5e0ecd4f | |||
| 6d058beade | |||
| 91f557e357 | |||
| 8e6c594a7b | |||
| 94cc04703b | |||
| 9ac621cebd | |||
| 5d9f6064d6 | |||
| 010be14379 | |||
| d864c80457 | |||
| ac04284d85 | |||
| e1377ad7a0 | |||
| 6c7bbba7ed | |||
| 7a70a90d63 | |||
| 58187cecdb | |||
| 649a358560 | |||
| e0f1f221df | |||
| 1342236b23 | |||
| 61681b393e | |||
| 20a36fe869 | |||
| a4fe68bf2a | |||
| 827cdd7805 | |||
| 2479167073 | |||
| 5cc8f7869f | |||
| 330ace039b | |||
| 36ad95dfef | |||
| a7d564b7d1 | |||
| 4582daa1d1 | |||
| c440274c32 | |||
| a02b2f163b | |||
| f5a6dbdeec | |||
| 282bf3aa11 | |||
| d302e02b0a | |||
| da554736ba | |||
| aefbc21033 | |||
| add0fb4fbe | |||
| f1bec83a6f | |||
| a9e15a7cc1 | |||
| 53c0f17200 | |||
| 0c5a28d1c3 | |||
| cedb6b4cc4 | |||
| 5c7ec8c4a2 | |||
| 3bb2a055e7 | |||
| b117df126a | |||
| 610a39e322 | |||
| 71fca0ccf8 | |||
| 2568cf086a | |||
| ae453279bf | |||
| bdb04ab91b | |||
| 3636cd27b6 | |||
| 04279542c5 | |||
| c7666274b8 | |||
| 5b27b8834d | |||
| 4b506cde0c | |||
| 121d254fc0 | |||
| 515c9dc8b1 | |||
| 4e03fa1b71 | |||
| 80752c3004 | |||
| a11d37ef96 | |||
| 14c89c7aab | |||
| b09b47fa49 | |||
| 49b8a96a84 | |||
| 6f800ac60b | |||
| 46dfb59f3d | |||
| d5bbb9f14a | |||
| 7c52811750 | |||
| 2d4b46afbd | |||
| 83093236fc | |||
| 3881e7d1d5 | |||
| 1c3dbd57c5 | |||
| 6f1275f291 | |||
| cfc48ea061 | |||
| 2679172ae4 | |||
| 6a0c276454 | |||
| 5872e3bd3c | |||
| f6041ab055 | |||
| b5c5da552d | |||
| c093ef95ed | |||
| d376a25e64 | |||
| 4ec926d072 | |||
| 65a7df4549 | |||
| 57c252dcbf | |||
| 9b74b40794 | |||
| 0b028e1f13 | |||
| 337b948fda | |||
| 5de187c6cd | |||
| 7150204c71 | |||
| 3d44403318 | |||
| 812f3e983c | |||
| 7549e571fe | |||
| 9d3f096be0 | |||
| fd40cd3ba6 | |||
| 1ba566f30f | |||
| 9b9d17b8e7 | |||
| 3fee600db0 | |||
| fbb1091f8b | |||
| 8513d67a78 | |||
| 45ce615fbe | |||
| 2ab4ca5b09 | |||
| b229dc22ba | |||
| bf49a5c1d5 | |||
| 27b6e974ab | |||
| 9f00b56be0 | |||
| 05177bff92 | |||
| f40f233562 | |||
| d3a351aeba | |||
| fb76d30457 | |||
| afba2137d1 | |||
| 9ef3ca1831 | |||
| 65ae2f5c48 | |||
| 2686dcd30a | |||
| 6bc125a2f4 | |||
| 29c6e296df | |||
| 3d1d064af7 | |||
| ccf1f8a71d | |||
| b83a8c9df0 | |||
| a7bd94f394 | |||
| 96787c27d3 | |||
| e99c731e27 | |||
| 4a4b5bd8ef | |||
| 9d27f1aa6b | |||
| fb010038f1 | |||
| 700f86fe38 | |||
| 582a8b9615 | |||
| 7c9dfc352c | |||
| b86d549695 | |||
| 577f6dfd8e | |||
| eab4cce251 | |||
| 7a099ab255 | |||
| 9fa67f61f3 | |||
| 86ebff4948 | |||
| 821ac4668f | |||
| d0280f0ab9 | |||
| ceb45b4c0b | |||
| 85ea3d97ef | |||
| 5640041e45 | |||
| 1357ddc1fe | |||
| 0765dae178 | |||
| 81f2e20c08 | |||
| b27d34ec92 | |||
| e7fbbd579c | |||
| 7a2a82fa87 | |||
| c3c105619b | |||
| 01e575676f | |||
| b53a43fcc6 | |||
| 6a8ff1c1b4 | |||
| 451ab514cc | |||
| c8b6830704 | |||
| 74ee7149c5 | |||
| 506b96dec4 | |||
| 927d4230a2 | |||
| 69ff737b72 | |||
| d0114a1f29 | |||
| f92a0fd88b | |||
| e7478cc1e1 | |||
| 5814f9757d | |||
| cb7b9a9e22 | |||
| 6dc525dd72 | |||
| 409c8c161b | |||
| 78db3a1e77 | |||
| d0c5ff19e1 | |||
| 6a29765ddf | |||
| 95147fbe03 | |||
| 0137321d24 | |||
| 0521306699 | |||
| 2a10e7d2c7 | |||
| e78e9e1a4d | |||
| 29d0b54b04 | |||
| 64fe3fdbb7 | |||
| 04edca175e | |||
| 840091cb05 | |||
| 473c3704cf | |||
| af630b22c9 | |||
| 29fe2f2016 | |||
| cd5691640e | |||
| 77c217f9ad | |||
| 9e963569bc | |||
| c7114ec359 | |||
| b9586ad72a | |||
| b6e333127c | |||
| 6ea2c2cceb | |||
| af42377c3b | |||
| 82881a4c45 | |||
| 70d50e9c47 | |||
| ddb5c01026 | |||
| e287adcb84 | |||
| 83d041c13b | |||
| 545c5fd749 | |||
| 7f0e2de7c4 | |||
| 1fdf1e49a3 | |||
| 08f82ee511 | |||
| fba217b87f | |||
| 6668a18ad3 | |||
| 11c239f46c | |||
| ac62dee279 | |||
| 29a17a7876 | |||
| 694c934612 | |||
| c01d2bfcd7 | |||
| b3708f99c0 | |||
| f651f921aa | |||
| ada6845036 | |||
| d33b259537 | |||
| 3c2ed147ca | |||
| d46e88c2b5 | |||
| 1477491e6e | |||
| 647dc0bd7c | |||
| f6a45993e9 | |||
| 0766320411 | |||
| 5ebf113fd6 | |||
| 2005d103f8 | |||
| 3935813336 | |||
| ec71fa78c0 | |||
| cd2b0a2d67 | |||
| 68d502804f | |||
| afbd343442 | |||
| 991e5a3112 | |||
| 7d9de6ca5d | |||
| 562bb69082 | |||
| a02416c78b | |||
| 81f56008d4 | |||
| 43a7039479 | |||
| a9f3904524 | |||
| 9136c7cfe3 | |||
| 5595846929 | |||
| 9ebe2971bf | |||
| fadea75314 | |||
| a2009cae39 | |||
| 0187adb6f9 | |||
| 302a028873 | |||
| 77c9c19806 | |||
| 724da2545e | |||
| 079af66d9f | |||
| e701d234f0 | |||
| 1b111975bb | |||
| c874425e0e | |||
| c6c112fd17 | |||
| 57d924e3ba | |||
| 9d5b9ebf82 | |||
| 354358e860 | |||
| 327c9d52d2 | |||
| c03051614d | |||
| ac9e5cd908 | |||
| 585bb56a99 | |||
| 8f33eb0cc6 | |||
| 71515a87de | |||
| 0a4ede8f79 | |||
| 040b33b541 | |||
| 5c3484ee28 | |||
| ef309c3101 | |||
| 51dad7936b | |||
| 097734b31d | |||
| 7185fde9bc | |||
| 75f2cdb04b | |||
| d3c445ed2d | |||
| 804f088435 | |||
| 6c1b70e796 | |||
| a3532e5878 | |||
| a8c1b32912 | |||
| 20b0c8a0e2 | |||
| bc18692d75 | |||
| c05ecf1ecd | |||
| 8381fc52f1 | |||
| a697ffa8f1 | |||
| 4eb36fab6d | |||
| 4b0780b549 | |||
| 1befc2982a | |||
| df1f225405 | |||
| df6feca53c | |||
| bd41efb974 | |||
| a1216bface | |||
| e0969b315a | |||
| 395bccf4e5 | |||
| f6e5cfce54 | |||
| a26e89147b | |||
| 4396020177 | |||
| e2c89ec19e | |||
| 68c125ba14 | |||
| 8c794fc91c | |||
| efbe8475df | |||
| 7835e02d5c | |||
| 21a5499af8 | |||
| dd85db071a | |||
| 813193102c | |||
| 42112d6c8a | |||
| dfe5e36fe0 | |||
| b5d4a54e97 | |||
| 1eaf7204ce | |||
| 8cc37dc439 | |||
| ccafb18122 | |||
| 654b132ae6 | |||
| 815fa0560c | |||
| c4d19810a4 | |||
| 9fbb32eb5b | |||
| 3ce3cde859 | |||
| 4e71dcd2b1 | |||
| acf21fa6d2 | |||
| f2b724a78d | |||
| 03473360c1 | |||
| 27dc68be41 | |||
| b15d78f8af | |||
| d232d14937 | |||
| 927d469db2 | |||
| e20e9e7cec | |||
| 0f097c3108 | |||
| eaa3c80d59 | |||
| 0c49eddfde | |||
| 33882f1e93 | |||
| f94666b68d | |||
| d32a49f801 | |||
| 9596f82ea6 | |||
| 0e3fdf7d61 | |||
| 54cfec6029 | |||
| 9e88eae416 | |||
| 8d32efac73 | |||
| 9f07b6007e | |||
| 2fb3b6c5dc | |||
| 0637576425 | |||
| ee7a445610 | |||
| ba4c7ee7ac | |||
| 4da169eb0f | |||
| 28937abab4 | |||
| b1dcf2779e | |||
| 3219062ce2 | |||
| 2ac72951f8 | |||
| cefb8cba78 | |||
| e869655028 | |||
| ae03ede209 | |||
| a72bb25ea2 | |||
| 0b22bc7056 | |||
| cde84ff20b | |||
| f76114d7dc | |||
| 9708a56fd7 | |||
| a360337660 | |||
| 9f78335651 | |||
| ef8142a99b | |||
| 06d51f9fcf | |||
| 4d94347179 | |||
| 2f6aa0b1e6 | |||
| 536a58d4d3 | |||
| 62f7c07059 | |||
| 0648edb3c4 | |||
| 9b6c235424 | |||
| 2fc35950cf | |||
| d2a5d74239 | |||
| 6b04c6748e | |||
| 10b44cbdb7 | |||
| dde04c716c | |||
| 819f19a7ea | |||
| e7201597ab | |||
| ea61aa7053 | |||
| 14546a8485 | |||
| f55455761f | |||
| b1a7767827 | |||
| 43c04b7e64 | |||
| add679cf6f | |||
| ee173190a0 | |||
| a6e21b47f6 | |||
| cd627e5840 | |||
| fc096e6867 | |||
| b591844c4e | |||
| 9557acb546 | |||
| 1fa6e75bd3 | |||
| 8965e43a55 | |||
| 8e7a6beece | |||
| e9f4fd8007 | |||
| a991c59d58 | |||
| dd0624adf8 | |||
| ab51cbe96a | |||
| 2f7a08deb0 | |||
| a0fa7f986b | |||
| b2d3eebe39 | |||
| a043fc0e46 | |||
| d6eec8787e | |||
| 95eee42047 | |||
| c673d6db4f | |||
| bb32a63d48 | |||
| af5acb272c | |||
| 5cd5ac8710 | |||
| 8bb58b44b3 | |||
| 511ecd506f | |||
| 62fc014bc3 | |||
| 961f564274 | |||
| 984870c78c | |||
| 6196c135d2 | |||
| bf1b8315a8 | |||
| b7eabcf7e7 | |||
| e83ebc4f02 | |||
| 1a4758f4cc | |||
| b80e8b3804 | |||
| e036173670 | |||
| bd728c6466 | |||
| e3aec310a3 | |||
| 1c59ce601e | |||
| 0563f7524f | |||
| cedbe07759 | |||
| 0746b2a5a7 | |||
| e8a72860dc | |||
| abbfe4a7d3 | |||
| 1ad5d9123b | |||
| 57031e4afe | |||
| b54d2434c0 | |||
| 7c2d7b6b4f | |||
| 9e731a5f18 | |||
| cd3832fef0 | |||
| cb6d51ed3d | |||
| 8b75768296 | |||
| 3f0a26b1c7 | |||
| de4f608d09 | |||
| 618327b9f1 | |||
| 54fdad8f0c | |||
| e9bbd68aa1 | |||
| 5df4837087 | |||
| 025e74c462 | |||
| 3208303c23 | |||
| 4cf0c5a971 | |||
| 7519352e79 | |||
| e7f94f9661 | |||
| cb68a612e9 | |||
| 44d4deb5a6 | |||
| 23c73d1f21 | |||
| 607beca837 | |||
| 31a5f8766f | |||
| cfdc829f48 | |||
| c44cefb983 | |||
| 79c2307c92 | |||
| 25f126a5cb | |||
| 45bd54e7b0 | |||
| 4ce43f80b8 | |||
| f7a4cedbbf | |||
| 6f83122e42 | |||
| dbcf3541a8 | |||
| b7adf3e971 | |||
| 037b1f876b | |||
| 4495658fad | |||
| 017b758e6c | |||
| edff6d5102 | |||
| 979fbe305a | |||
| d1edb8f892 | |||
| 0eeda6b9ac | |||
| 7c31c3616e | |||
| fba80b1a0d | |||
| 86dbb317f3 | |||
| 7432d43b55 | |||
| a61fb10784 | |||
| 8d83881cf0 | |||
| d624df5f65 | |||
| a96a7de784 | |||
| fa188e04c5 | |||
| e8c95ece8d | |||
| bc0f4adb7a | |||
| 3eb5a71e8d | |||
| 37c97de5d6 | |||
| 3624832ed8 | |||
| 9ee39d5f71 | |||
| 3a0e558b6f | |||
| ca083c216e | |||
| 8b9c1d1cc6 | |||
| 78830a7884 | |||
| 9fe7d0592f | |||
| 6a3e3c6fc4 | |||
| 54bfff0ed6 | |||
| 6ee305cbba | |||
| 340041ddae | |||
| 8a3628a6c5 | |||
| 8ac3350ac6 | |||
| a507ef9966 | |||
| 113405ed71 | |||
| 1bdf1a0f4c | |||
| ff7883e33c | |||
| 9bd909c1b6 | |||
| 408b4e8a4d | |||
| 0ee9708b4d | |||
| 44fadf6709 | |||
| 7384763466 | |||
| 9b6f3b10c6 | |||
| 0e4007bde6 | |||
| 9fcffc7df1 | |||
| 8e1a88c5cd | |||
| 7bb21c9f77 | |||
| 7333379fcf | |||
| fc040007b4 | |||
| b30a3c7961 | |||
| 62e18156f5 | |||
| 853666af6f | |||
| 3416f7b56c | |||
| d78d82c6ce | |||
| 47bd1be714 | |||
| 1eec919b88 | |||
| 777ad52bad | |||
| 5fcadb5544 | |||
| 0a8af20b58 | |||
| 95163a0e35 | |||
| ac58466c1a | |||
| 5ce14dcd9b | |||
| 21d700e16d | |||
| 5f2d4a5e04 | |||
| 7b90d73007 | |||
| 68c90aec44 | |||
| 9280c9a39a | |||
| 245e31e262 | |||
| c00d7ef4d3 | |||
| 2a652c562b | |||
| 1cc31729eb | |||
| 34e5c20c1a | |||
| c8545fe725 | |||
| a8339d376d | |||
| 47150ccbd0 | |||
| 0a0890d8bb | |||
| 6bcf24e3d8 | |||
| 898008a1bd | |||
| 09af2e1d41 | |||
| 045d44f451 | |||
| 04b2325cb9 | |||
| 504581b0f8 | |||
| 0572f414d4 | |||
| 50736dc005 | |||
| fe4bdb6a04 | |||
| 7d7c5f3972 | |||
| 131a2d18cc | |||
| 47296c5c08 | |||
| c15b7a63fb | |||
| e476afe208 | |||
| b59d422fb0 | |||
| 8b105c2396 | |||
| 9b6e0b26c3 | |||
| 4ca038e186 | |||
| 3e185e7ffe | |||
| 5e27dcdcfb | |||
| 8730a68b40 | |||
| d88f13784a | |||
| 6e471c9094 | |||
| 3201b60ff2 | |||
| d30d1e2a29 | |||
| bd73ad069f | |||
| 2186780c32 | |||
| fbb2e561c5 | |||
| c8aba42d3d | |||
| 05d495e874 | |||
| 427011fe41 | |||
| 2feacccb13 | |||
| dc196fb478 | |||
| 71c219e6ea | |||
| e250812463 | |||
| 477b0b26c4 | |||
| 06323f7168 | |||
| 6b471caa22 | |||
| 6fb28dd363 | |||
| 22dcff47d1 | |||
| 1d9b5ea3c6 | |||
| 08e0b3a657 | |||
| 10b80981b2 | |||
| 348f02f34c | |||
| 9e9e984f36 | |||
| d47ca550ae | |||
| 16f4285948 | |||
| 23cb6dd8d0 | |||
| dfbb6b0058 | |||
| 62f8fdd9bb | |||
| fc114d15a0 | |||
| a9fb8a069d | |||
| 9c6f738382 | |||
| 1a7b2eb908 | |||
| b108cc6b60 | |||
| 92cb1e3971 | |||
| 005fc95433 | |||
| f09771bc1c | |||
| d5b72198d7 | |||
| 991bf1161c | |||
| 1a955d920a | |||
| d575c914a6 | |||
| 90a054e942 | |||
| aac5fabbca | |||
| 3ce5d1650b | |||
| 24879b9cf2 | |||
| 37f27df062 | |||
| b78ed6797a | |||
| f73dcd6f66 | |||
| 0634d01336 | |||
| b43d6a83ee | |||
| 93144eea0e | |||
| 7d078a58e1 | |||
| 224c346bc0 | |||
| 51e937ae0c | |||
| 04073815ca | |||
| ff556ba347 | |||
| 2d751c238c | |||
| 5d27fa2963 | |||
| fe71935d58 | |||
| d0d1b7617a | |||
| 310990ae32 | |||
| b565c32e77 | |||
| f3edb4344b | |||
| ccdcff6a7b | |||
| 1d4090ea7a | |||
| 68c7d35165 | |||
| 221cc05af3 | |||
| 1524970401 | |||
| 32a03c5e4b | |||
| a03036c099 | |||
| 811e8a97b3 | |||
| c7625643c6 | |||
| 5ce87238c8 | |||
| 7d12a327ca | |||
| 8c93637d79 | |||
| 2d0ea02b4b | |||
| 98692ac3bd | |||
| 940b6b93b2 | |||
| 17768c303f | |||
| 441fcd2a36 | |||
| c2b6b4e370 | |||
| 2f8d27b1ec | |||
| dc32f53585 | |||
| 165691412b | |||
| 824d4b3a23 | |||
| 060f0ca199 | |||
| ac43f17a43 | |||
| 54fc1c029c | |||
| d259bcf169 | |||
| da46c18210 | |||
| ace7121a4e | |||
| 61a93f86b3 | |||
| 49bcd18a9a | |||
| 7df64d8edf | |||
| ca63b4d758 | |||
| 3cbb304e9b | |||
| ef5192234a | |||
| f7bb9ac4d9 | |||
| b8bb4f3c3f | |||
| e43ff27639 | |||
| e02123c1ed | |||
| f26df389b4 | |||
| 10d36a829d | |||
| 61e936dd49 | |||
| d0f9a04d5f | |||
| cf6bbffd9b | |||
| 5ad45addfa | |||
| 2af013b75b | |||
| 74bda5c986 | |||
| b290ccfff2 | |||
| 904d2883e8 | |||
| 1a54beed17 | |||
| 701a8d33af | |||
| aa03384012 | |||
| d346c63184 | |||
| b8005aaad7 | |||
| bfa9490425 | |||
| 1f4af21a07 | |||
| f952c0e38d | |||
| d7dab5d30f | |||
| 8407f7cdb1 | |||
| 6c72987201 | |||
| 81b6d3f18f | |||
| 95754ab224 | |||
| 949d964a7f | |||
| 8eab79434a | |||
| 8ab2351995 | |||
| e8802dde55 | |||
| acc5ace234 | |||
| 7b33721305 | |||
| e94f4598b5 | |||
| c66c8b7933 | |||
| 0b934efa09 | |||
| 79e644f6dd | |||
| 5c68a1aca7 | |||
| bde613c4bf | |||
| 184d8c3ba7 | |||
| d48221d57f | |||
| 657fd6d07d | |||
| 1211cd22ad | |||
| 19ed88009d | |||
| 0f30ee87ab | |||
| 4cc6778e45 | |||
| ab00a0d136 | |||
| 3d4f1af9a9 | |||
| 0302176fd2 | |||
| 8910780332 | |||
| 8bea64179a | |||
| 777f90eab1 | |||
| ad0b0f475a | |||
| 499292eab6 | |||
| 21b2f1bd2a | |||
| 3c025b536e | |||
| bded6fe59f | |||
| 344ad8f3c0 | |||
| b43dd3167f | |||
| 79ed9dbab2 | |||
| 38ff59f71f | |||
| 65c85e146d | |||
| 467955e7b3 | |||
| 1a4250a7ed | |||
| e181e4325b | |||
| 1a35237852 | |||
| 90baf3da52 | |||
| 348e4dd4dc | |||
| d2cd96a45c | |||
| 64238e3280 | |||
| fcfac2df1f | |||
| a0734a6eb4 | |||
| 0d7a235715 | |||
| 180788bc7b | |||
| 922681891a | |||
| 257c628cc8 | |||
| dcee842c92 | |||
| 5f02471fd5 | |||
| 82f0f7ce17 | |||
| b805b7eef9 | |||
| ad03fa070a | |||
| 9c354d971f | |||
| 5cc70c4bb8 | |||
| 9db806d387 | |||
| b7f7621d28 | |||
| 822ac2417d | |||
| b3c80a2a82 | |||
| 3e9d606647 | |||
| 0ab89f4956 | |||
| 0090f1c409 | |||
| 1cc37a57c1 | |||
| 7809efa556 | |||
| b4a69e3c85 | |||
| 67c975d9c2 | |||
| c0812a7ee9 | |||
| 8ed755a339 | |||
| 1aae8783ca | |||
| 5997a741fc | |||
| c5d522e6b8 | |||
| fa1d128d76 | |||
| f30516a6c2 | |||
| 1feb0538cf | |||
| 3a791d624f | |||
| fe0f1f7641 | |||
| 212783ae3d | |||
| 7d72fc9555 | |||
| fb4df5cee1 | |||
| 45cd847b44 | |||
| 98fd97dea2 | |||
| e334086ad1 | |||
| 2e792911f1 | |||
| 878b666a0b | |||
| 69c3239469 | |||
| b8d48745c5 | |||
| 6b64e73d6e | |||
| b433a96cd6 | |||
| 0e4c83299d | |||
| c4d1f3eccc | |||
| 22dcf425cb | |||
| edf4132c2d | |||
| a56e14c736 | |||
| 2e3c707745 | |||
| ebd8a10fd9 | |||
| a681a9ff1b | |||
| d11458c4a8 | |||
| 9d6e17b609 | |||
| cb27487021 | |||
| e1e6b40a19 | |||
| 2616292f97 | |||
| 2e1dfa6638 | |||
| 3a730492cb | |||
| c35da4b98d | |||
| 5e98c25020 | |||
| 11a4d5b436 | |||
| 93c733aac0 | |||
| 5b03e7ab8c | |||
| 33912331af | |||
| aacc07835e | |||
| 86df2a1408 | |||
| 018de17c5b | |||
| 66afa2b924 | |||
| 4ee32fe0de | |||
| 5b59ca3c4f | |||
| 0108b0fc22 | |||
| 2e575f82af | |||
| e8af09041d | |||
| 1cf041fb86 | |||
| 63074cb009 | |||
| 89b9a6e04f | |||
| 4efa344e43 | |||
| 2d3d1ba5d3 | |||
| 849d781e92 | |||
| 48a988aa43 | |||
| ec71f733ad | |||
| 051121a366 | |||
| e2c2b9127c | |||
| fdf7263bb0 | |||
| c8dcde0919 | |||
| 9db0cd6328 | |||
| 2375bf51b3 | |||
| 3631b49d6d | |||
| 3938b56802 | |||
| 3c3b793a9f | |||
| 8985a89a9f | |||
| 5750e6a4d2 | |||
| 8d7f73edec | |||
| 4853d5b19b | |||
| 901f5fd90d | |||
| 89e3865019 | |||
| 538b2b52d3 | |||
| f1c9df8471 | |||
| 9e09e97ed8 | |||
| e50355be23 | |||
| bd036fb4a9 | |||
| 0181f8e5fb | |||
| f5eea6798a | |||
| 7c8d0254b2 | |||
| 49c3c1c0c9 | |||
| 38522442d1 | |||
| 0dacd79598 | |||
| 2022654055 | |||
| f3a1de6335 | |||
| 0b5b89ea9a | |||
| 8e21c34c8c | |||
| 400f3b01d6 | |||
| 6a0b2aabaf | |||
| 13a1ddad11 | |||
| ed404a0e54 | |||
| 05acfc0733 | |||
| 969cd6ed90 | |||
| 939c9b9b09 | |||
| 051c0db106 | |||
| e5fbe005f8 | |||
| 63efb31785 | |||
| 32586e177b | |||
| 651dba2648 | |||
| 1bbcf2a95c | |||
| 82af176018 | |||
| c10a4e9f8a | |||
| 9727e07464 | |||
| b780fe2278 | |||
| 947d61f2a8 | |||
| 27725ec1a7 | |||
| 93a29463f4 | |||
| 84cfcf45fa | |||
| 6651e59f63 | |||
| ed46b7c919 | |||
| 88cd9fbc5b | |||
| 46fc5827ca | |||
| 65d386f3b2 | |||
| 9db640931e | |||
| 53e4dec12d | |||
| 174d9f9b84 | |||
| 30ec07f7fe | |||
| dbf1fd8085 | |||
| 32e1f231d3 | |||
| 96b788693d | |||
| a0e00c3735 | |||
| 51e68cf215 | |||
| fa3ed3af81 | |||
| 6e72cbb5b0 | |||
| 5f316f85b3 | |||
| 2abd1fd9df | |||
| fcb5fedf9f | |||
| d35c9ad87e | |||
| 0cc0368b0b | |||
| 00fb154cb9 | |||
| 4441cf79ff | |||
| 7428bd5cf7 | |||
| 76c524e980 | |||
| 5a9fa167c2 | |||
| c61e9c78ba | |||
| e5e3a9e4b5 | |||
| a1f47e13e3 | |||
| 7ff0ed1248 | |||
| 5eef24fb91 | |||
| 05ba26d3ac | |||
| ef68ce19ce | |||
| 2fa84f6dc9 | |||
| 32409d7471 | |||
| 270282a340 | |||
| 87eaa06652 | |||
| 845f03e873 | |||
| 97eabb0604 | |||
| f0d714d787 | |||
| 27a34d6e9f | |||
| a4e97e0317 | |||
| 97dcd0b7cc | |||
| 9b57904cf4 | |||
| 6ebdae0cae | |||
| b9d4a8f8e9 | |||
| 707c3c5daf | |||
| d075abfeea | |||
| d3399c569c | |||
| e36e3f7f8f | |||
| 583e01f5e6 | |||
| 784b5e0af6 | |||
| cc42d33fbe | |||
| 9c3d8fd701 | |||
| d281eeb69d | |||
| b510ffb7d8 | |||
| 6855b7765c | |||
| 9ed1129268 | |||
| 0c4ba3c0d0 | |||
| 70337ac310 | |||
| e981082713 | |||
| 104d98542e | |||
| fe63ef5fc5 | |||
| b1ff964dd6 | |||
| aba1d5dbe5 | |||
| 0af7e4ac68 | |||
| 7d9a572e6e | |||
| de6557d7e7 | |||
| a052c7bb80 | |||
| 8c47f3c49b | |||
| 323ad903e7 | |||
| 8105fe285d | |||
| 82a19025f1 | |||
| e592b3e5c4 | |||
| 2da12354a1 | |||
| fc65b62d65 | |||
| 28603a87e1 | |||
| 8956aafc9a | |||
| 2ee9b263b8 | |||
| 3a5d838be3 | |||
| bf25fcfda6 | |||
| ecdf2fcae4 | |||
| 6fec14de4b | |||
| 7b4cbb27b7 | |||
| d7d7795257 | |||
| 93554c7034 | |||
| fbe7febdde | |||
| f091b3ea5e | |||
| aae469853e | |||
| dac68ccd8c | |||
| fb140e82f6 | |||
| 8cac442de1 | |||
| 8d8a465c43 | |||
| 51f2c660e8 | |||
| 39ecca306d | |||
| 7896a24e13 | |||
| c21325f260 | |||
| 18df7395a2 | |||
| f139251333 | |||
| 369e384b7d | |||
| 008f62e02b | |||
| 6e8981a245 | |||
| bb17cf7361 | |||
| d13c6767e6 | |||
| bf2ee37f33 | |||
| b82929ac3d | |||
| 9b0ea9bf2a | |||
| 5bde7196dc | |||
| 71c3e7fe0d | |||
| 201543c263 | |||
| 5ce7417c80 | |||
| e98564b5ab | |||
| 37dd48cc20 | |||
| 9dff6e8045 | |||
| 572094050e | |||
| 2b41337bbf | |||
| ca94c402bd | |||
| 6fdc46c3c6 | |||
| 34fc568f97 | |||
| 38664ff853 | |||
| e44284d4ba | |||
| d54dc822cc | |||
| dbe9c2d5e9 | |||
| b036d090c6 | |||
| d44b303dd3 | |||
| e69bfdac20 | |||
| 53f6c4e71b | |||
| c67223a332 | |||
| 2c253382d5 | |||
| 02b65cb1c9 | |||
| 49ace78a68 | |||
| 0669086742 | |||
| 2f2d9bc1af | |||
| 9b2b11ee44 | |||
| d318738596 | |||
| 6ae41369e9 | |||
| ce9f2e0d41 | |||
| aa7bd42375 | |||
| a6b19e4b95 | |||
| 7a21518ce2 | |||
| ba92f7da6c | |||
| f62f37581f | |||
| 0d08c2c7f0 | |||
| 078e59f09c | |||
| ab708a78a8 | |||
| 630688ecf5 | |||
| 2b6901e87a | |||
| 0e43daf48b | |||
| e9badff1d8 | |||
| f588191931 | |||
| e17dfbff35 | |||
| b7e9770588 | |||
| 9371524257 | |||
| c1f8fe69cf | |||
| 87e3449444 | |||
| e8ef1bc255 | |||
| fe3a593da0 | |||
| bf96d85edb | |||
| 700d718da6 | |||
| 0c9ad3cec8 | |||
| 73d789d3f7 | |||
| 0a09ae4290 | |||
| 0758988b5f | |||
| 089f668e14 | |||
| 1e5bf143c4 | |||
| d1132c82f8 | |||
| 62545276e4 | |||
| 2878bc3c3d | |||
| eed0fe39e7 | |||
| 6bbee5586f | |||
| 7ac2354197 | |||
| 3463f08162 | |||
| 4b0aaa02c7 | |||
| a946f7b501 | |||
| 7f2e4615ee | |||
| 28ab7719f5 | |||
| 31b5101f64 | |||
| 62777f8995 | |||
| f9cff3e169 | |||
| 04c255d748 | |||
| aae771764e | |||
| 7be8757964 |
+522
-14
@@ -3,26 +3,125 @@
|
||||
# ===============================================
|
||||
|
||||
# ===== TELEGRAM BOT =====
|
||||
# Токен бота от @BotFather
|
||||
# ВАЖНО: Также используется для авторизации виджета личного кабинета (Cabinet WebApp)
|
||||
# через Telegram.WebApp.initData
|
||||
BOT_TOKEN=
|
||||
ADMIN_IDS=
|
||||
# Ссылка на поддержку: Telegram username (например, @support) или полный URL
|
||||
SUPPORT_USERNAME=@support
|
||||
# Имя пользователя бота (опционально, автоопределяется)
|
||||
# BOT_USERNAME=
|
||||
|
||||
# ===== СИСТЕМА ПОДДЕРЖКИ =====
|
||||
# Включить меню поддержки в интерфейсе
|
||||
SUPPORT_MENU_ENABLED=true
|
||||
# Режим системы поддержки: tickets (тикеты), contact (контакт), both (оба)
|
||||
SUPPORT_SYSTEM_MODE=both
|
||||
# SLA для тикетов поддержки
|
||||
SUPPORT_TICKET_SLA_ENABLED=false
|
||||
SUPPORT_TICKET_SLA_MINUTES=60
|
||||
SUPPORT_TICKET_SLA_CHECK_INTERVAL_SECONDS=300
|
||||
SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES=30
|
||||
|
||||
# ===== ЛИЧНЫЙ КАБИНЕТ (CABINET) =====
|
||||
# Включить личный кабинет пользователя (веб-интерфейс для управления подпиской)
|
||||
CABINET_ENABLED=false
|
||||
# URL кабинета для ссылок в email (например: https://cabinet.example.com)
|
||||
CABINET_URL=
|
||||
# Секретный ключ для JWT токенов (если не указан, используется BOT_TOKEN)
|
||||
CABINET_JWT_SECRET=
|
||||
# Время жизни access token в минутах (по умолчанию 15)
|
||||
CABINET_ACCESS_TOKEN_EXPIRE_MINUTES=15
|
||||
# Время жизни refresh token в днях (по умолчанию 7)
|
||||
CABINET_REFRESH_TOKEN_EXPIRE_DAYS=7
|
||||
# Разрешенные origins для CORS (через запятую, например: https://cabinet.example.com)
|
||||
CABINET_ALLOWED_ORIGINS=
|
||||
# Включить верификацию email (требует настройки SMTP)
|
||||
CABINET_EMAIL_VERIFICATION_ENABLED=false
|
||||
# Включить регистрацию/вход по email (если false - только Telegram)
|
||||
CABINET_EMAIL_AUTH_ENABLED=true
|
||||
|
||||
# ===== ТЕСТОВЫЙ EMAIL ДЛЯ РАЗРАБОТКИ =====
|
||||
# Тестовый email для проверки регистрации без SMTP
|
||||
# При использовании этого email верификация пропускается
|
||||
TEST_EMAIL=
|
||||
TEST_EMAIL_PASSWORD=
|
||||
# Время жизни токена верификации email в часах
|
||||
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)
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=
|
||||
SMTP_PASSWORD=
|
||||
# Email отправителя (если не указан, используется SMTP_USER)
|
||||
SMTP_FROM_EMAIL=
|
||||
SMTP_FROM_NAME=VPN Service
|
||||
# Использовать TLS шифрование
|
||||
SMTP_USE_TLS=true
|
||||
|
||||
# Уведомления администраторов
|
||||
ADMIN_NOTIFICATIONS_ENABLED=true
|
||||
ADMIN_NOTIFICATIONS_CHAT_ID=-1001234567890 # Замени на ID твоего канала (-100) - ПРЕФИКС ЗАКРЫТОГО КАНАЛА! ВСТАВИТЬ СВОЙ ID СРАЗУ ПОСЛЕ (-100) БЕЗ ПРОБЕЛОВ!
|
||||
ADMIN_NOTIFICATIONS_TOPIC_ID=123 # Опционально: ID топика
|
||||
ADMIN_NOTIFICATIONS_TICKET_TOPIC_ID=126 # Опционально: ID топика для тикетов
|
||||
ADMIN_NOTIFICATIONS_NALOG_TOPIC_ID=133 # Опционально: ID топика для уведомлений о чеках NaloGO
|
||||
# Автоматические отчеты
|
||||
ADMIN_REPORTS_ENABLED=false
|
||||
ADMIN_REPORTS_CHAT_ID= # Опционально: чат для отчетов (по умолчанию ADMIN_NOTIFICATIONS_CHAT_ID)
|
||||
ADMIN_REPORTS_TOPIC_ID= # ID топика для отчетов
|
||||
ADMIN_REPORTS_SEND_TIME=10:00 # Время отправки (по МСК) ежедневного отчета
|
||||
|
||||
# ===== МОНИТОРИНГ ТРАФИКА =====
|
||||
# Логика: при запуске бота создаётся snapshot трафика всех пользователей.
|
||||
# Через указанный интервал проверяется дельта (разница) трафика.
|
||||
# Если дельта превышает порог — отправляется уведомление админам.
|
||||
|
||||
# Быстрая проверка (дельта трафика за интервал)
|
||||
TRAFFIC_FAST_CHECK_ENABLED=false # Включить быструю проверку
|
||||
TRAFFIC_FAST_CHECK_INTERVAL_MINUTES=10 # Интервал проверки в минутах
|
||||
TRAFFIC_FAST_CHECK_THRESHOLD_GB=5.0 # Порог дельты в ГБ (сколько потрачено за интервал)
|
||||
|
||||
# Суточная проверка (трафик за 24 часа через bandwidth API)
|
||||
TRAFFIC_DAILY_CHECK_ENABLED=false # Включить суточную проверку
|
||||
TRAFFIC_DAILY_CHECK_TIME=00:00 # Время суточной проверки (HH:MM по UTC)
|
||||
TRAFFIC_DAILY_THRESHOLD_GB=50.0 # Порог суточного трафика в ГБ
|
||||
|
||||
# Куда отправлять уведомления
|
||||
SUSPICIOUS_NOTIFICATIONS_TOPIC_ID=14 # ID топика для уведомлений о подозрительной активности
|
||||
|
||||
# Фильтрация по серверам (UUID нод через запятую)
|
||||
TRAFFIC_MONITORED_NODES= # Только эти ноды (пусто = все)
|
||||
TRAFFIC_IGNORED_NODES= # Исключить эти ноды
|
||||
|
||||
# Исключить пользователей (UUID через запятую)
|
||||
TRAFFIC_EXCLUDED_USER_UUIDS= # Служебные/тунельные пользователи
|
||||
|
||||
# Производительность
|
||||
TRAFFIC_CHECK_BATCH_SIZE=1000 # Размер батча для получения пользователей
|
||||
TRAFFIC_CHECK_CONCURRENCY=10 # Параллельных запросов к API
|
||||
TRAFFIC_NOTIFICATION_COOLDOWN_MINUTES=60 # Кулдаун уведомлений на пользователя (минуты)
|
||||
TRAFFIC_SNAPSHOT_TTL_HOURS=24 # TTL snapshot трафика в Redis (часы, сохраняется при рестарте)
|
||||
|
||||
# Черный список
|
||||
BLACKLIST_CHECK_ENABLED=false # Включить проверку пользователей по черному списку
|
||||
BLACKLIST_GITHUB_URL=https://raw.githubusercontent.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/refs/heads/main/blacklist.txt # URL к файлу черного списка на GitHub
|
||||
BLACKLIST_UPDATE_INTERVAL_HOURS=24 # Интервал обновления черного списка с GitHub (в часах)
|
||||
BLACKLIST_IGNORE_ADMINS=true # Игнорировать администраторов (из ADMIN_IDS) при проверке черного списка
|
||||
SUBSCRIPTION_RENEWAL_BALANCE_THRESHOLD_KOPEKS=20000 # Порог баланса (в копейках) для фильтра «готовы к продлению»
|
||||
|
||||
# Обязательная подписка на канал
|
||||
CHANNEL_SUB_ID= # Опционально ID твоего канала (-100)
|
||||
CHANNEL_IS_REQUIRED_SUB=false # Обязательна ли подписка на канал
|
||||
CHANNEL_LINK= # Опционально ссылка на канал
|
||||
CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE=true # Отключать триальные подписки при отписке от канала
|
||||
CHANNEL_REQUIRED_FOR_ALL=false # Требовать подписку на канал для ВСЕХ пользователей (платных и триальных)
|
||||
|
||||
# ===== DATABASE CONFIGURATION =====
|
||||
# Режим базы данных: "auto", "postgresql", "sqlite"
|
||||
@@ -44,13 +143,16 @@ LOCALES_PATH=./locales
|
||||
|
||||
# Redis
|
||||
REDIS_URL=redis://redis:6379/0
|
||||
# Время жизни корзины пользователя в Redis (секунды, по умолчанию 1 час)
|
||||
CART_TTL_SECONDS=3600
|
||||
|
||||
# ===== REMNAWAVE API =====
|
||||
REMNAWAVE_API_URL=https://panel.example.com
|
||||
REMNAWAVE_API_KEY=your_api_key_here
|
||||
|
||||
# Тип авторизации: "api_key", "basic_auth"
|
||||
# Тип авторизации: "api_key", "basic_auth", "caddy"
|
||||
REMNAWAVE_AUTH_TYPE=api_key
|
||||
REMNAWAVE_CADDY_TOKEN=
|
||||
|
||||
# Для панелей с Basic Auth (опционально)
|
||||
REMNAWAVE_USERNAME=
|
||||
@@ -67,23 +169,100 @@ REMNAWAVE_SECRET_KEY=
|
||||
# {telegram_id} — ID Telegram
|
||||
REMNAWAVE_USER_DESCRIPTION_TEMPLATE="Bot user: {full_name} {username}"
|
||||
|
||||
# Шаблон имени пользователя в панели Remnawave
|
||||
# Доступные плейсхолдеры аналогичны описанию выше
|
||||
# {full_name} — Имя, Фамилия из Telegram
|
||||
# {username} — @логин из Telegram (c @)
|
||||
# {username_clean} — логин из Telegram (без @)
|
||||
# {telegram_id} — ID Telegram
|
||||
REMNAWAVE_USER_USERNAME_TEMPLATE="user_{telegram_id}"
|
||||
|
||||
# Режим удаления пользователей из панели RemnaWave
|
||||
# delete - полностью удалить пользователя из панели
|
||||
# disable - только деактивировать пользователя
|
||||
REMNAWAVE_USER_DELETE_MODE=delete
|
||||
|
||||
# Автоматическая синхронизация пользователей с панелью Remnawave
|
||||
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
|
||||
# Тег для платных пользователей (опционально)
|
||||
# PAID_SUBSCRIPTION_USER_TAG=PAID
|
||||
|
||||
# ========= ПОДПИСКИ =========
|
||||
|
||||
# ===== РЕЖИМ ПРОДАЖ =====
|
||||
# Режим продаж подписок (можно переключить в кабинете: Настройки → Подписки):
|
||||
# "classic" - классический режим:
|
||||
# - Пользователь выбирает период, серверы, трафик, устройства отдельно
|
||||
# - Цены периодов берутся из PERIOD_PRICES ниже
|
||||
# - Подходит для гибкой настройки под каждого пользователя
|
||||
# "tariffs" - режим тарифов:
|
||||
# - Пользователь выбирает готовый тариф (Premium, Basic и т.д.)
|
||||
# - Тарифы создаются в кабинете: Админ → Тарифы
|
||||
# - Каждый тариф имеет свои серверы, трафик, устройства и цены за периоды
|
||||
# - Подходит для продажи готовых пакетов услуг
|
||||
SALES_MODE=tariffs
|
||||
|
||||
# ===== ТРИАЛ ПОДПИСКА =====
|
||||
TRIAL_DURATION_DAYS=3
|
||||
TRIAL_TRAFFIC_LIMIT_GB=10
|
||||
TRIAL_DEVICE_LIMIT=1
|
||||
TRIAL_SQUAD_UUID=
|
||||
# ID тарифа для триала в режиме тарифов (0 = использовать стандартные настройки триала)
|
||||
# Если указан ID тарифа, параметры триала берутся из тарифа (traffic_limit_gb, device_limit, allowed_squads)
|
||||
# Длительность триала всё равно берётся из TRIAL_DURATION_DAYS
|
||||
TRIAL_TARIFF_ID=0
|
||||
# Платный триал: если TRIAL_ACTIVATION_PRICE > 0, триал становится платным
|
||||
# Цена в копейках (1000 = 10 рублей). Пользователь может оплатить триал любым методом оплаты.
|
||||
# TRIAL_PAYMENT_ENABLED опционален (для обратной совместимости)
|
||||
TRIAL_PAYMENT_ENABLED=false
|
||||
TRIAL_ACTIVATION_PRICE=0
|
||||
|
||||
# ===== ПЛАТНАЯ ПОДПИСКА =====
|
||||
# Сколько устройств доступно по дефолту при покупке платной подписки
|
||||
DEFAULT_DEVICE_LIMIT=3
|
||||
|
||||
# Максимум устройств достопных к покупке (0 = Нет лимита)
|
||||
# Максимум устройств доступных к покупке (0 = Нет лимита)
|
||||
MAX_DEVICES_LIMIT=15
|
||||
|
||||
# Дефолт параметры для подписок выданных через админку
|
||||
@@ -107,15 +286,36 @@ TRAFFIC_SELECTION_MODE=selectable
|
||||
# 0 = безлимит
|
||||
FIXED_TRAFFIC_LIMIT_GB=100
|
||||
|
||||
# ===== ДОКУПКА ТРАФИКА =====
|
||||
# Включить/выключить функцию докупки трафика к существующей подписке
|
||||
TRAFFIC_TOPUP_ENABLED=true
|
||||
# Показывать кнопку "Докупить трафик" в меню
|
||||
BUY_TRAFFIC_BUTTON_VISIBLE=true
|
||||
|
||||
# Пакеты для докупки трафика (формат: "гб:цена_в_копейках:enabled")
|
||||
# Пустая строка = использовать TRAFFIC_PACKAGES_CONFIG
|
||||
# Пример: "10:5000:true,25:10000:true,50:15000:true,100:25000:true"
|
||||
TRAFFIC_TOPUP_PACKAGES_CONFIG=
|
||||
|
||||
# ===== СБРОС ТРАФИКА =====
|
||||
# Режим расчета цены сброса трафика:
|
||||
# "period" - фиксированная цена = стоимость периода 30 дней (старое поведение, может быть абьюзом!)
|
||||
# "traffic" - цена = стоимость текущего пакета трафика подписки
|
||||
# "traffic_with_purchased" - цена = стоимость базового + докупленного трафика (рекомендуется)
|
||||
TRAFFIC_RESET_PRICE_MODE=traffic_with_purchased
|
||||
|
||||
# Базовая цена сброса в копейках (0 = использовать PERIOD_PRICES[30])
|
||||
# Используется как минимальная цена или фиксированная в режиме "period"
|
||||
TRAFFIC_RESET_BASE_PRICE=0
|
||||
|
||||
# ===== ПЕРИОДЫ ПОДПИСКИ =====
|
||||
# Доступные периоды подписки (через запятую)
|
||||
# Возможные значения: 14,30,60,90,180,360
|
||||
AVAILABLE_SUBSCRIPTION_PERIODS=30,90,180
|
||||
AVAILABLE_RENEWAL_PERIODS=30,90,180
|
||||
|
||||
# ===== НАСТРОЙКИ ПРОСТОЙ ПОКУПКИ =====
|
||||
# Включить упрощённую покупку из меню
|
||||
SIMPLE_SUBSCRIPTION_ENABLED=false
|
||||
# ===== ПРОСТАЯ ПОКУПКА ПОДПИСКИ =====
|
||||
SIMPLE_SUBSCRIPTION_ENABLED=true
|
||||
# Стандартный период (должен совпадать с одним из AVAILABLE_SUBSCRIPTION_PERIODS)
|
||||
SIMPLE_SUBSCRIPTION_PERIOD_DAYS=30
|
||||
# Сколько устройств выдаётся в рамках простой подписки
|
||||
@@ -143,9 +343,27 @@ BASE_PROMO_GROUP_PERIOD_DISCOUNTS=60:10,90:20,180:40,360:70
|
||||
|
||||
# Выводимые пакеты трафика и их цены в копейках
|
||||
TRAFFIC_PACKAGES_CONFIG="5:2000:false,10:3500:false,25:7000:false,50:11000:true,100:15000:true,250:17000:false,500:19000:false,1000:19500:true,0:0:true"
|
||||
# Цена за безлимитный трафик (в копейках)
|
||||
PRICE_TRAFFIC_UNLIMITED=20000
|
||||
|
||||
# Цена за дополнительное устройство (DEFAULT_DEVICE_LIMIT идет бесплатно!)
|
||||
PRICE_PER_DEVICE=10000
|
||||
# Включить выбор количества устройств при покупке и продлении
|
||||
DEVICES_SELECTION_ENABLED=true
|
||||
# Единое количество устройств для режима без выбора (0 — не назначать устройства)
|
||||
DEVICES_SELECTION_DISABLED_AMOUNT=0
|
||||
|
||||
# ===== МОДЕМ =====
|
||||
# Включить функционал подключения модема
|
||||
MODEM_ENABLED=false
|
||||
# Цена модема в копейках за месяц (добавляется к ежемесячному платежу)
|
||||
MODEM_PRICE_PER_MONTH=10000
|
||||
# Скидки на модем за длительный срок: "месяцев:процент,месяцев:процент"
|
||||
# Пример: 3 мес = 15%, 6 мес = 20%, 12 мес = 25%
|
||||
MODEM_PERIOD_DISCOUNTS=3:15,6:20,12:25
|
||||
|
||||
# Отключение превью ссылок в сообщениях бота
|
||||
DISABLE_WEB_PAGE_PREVIEW=false
|
||||
|
||||
# ===== РЕФЕРАЛЬНАЯ СИСТЕМА =====
|
||||
REFERRAL_PROGRAM_ENABLED=true
|
||||
@@ -154,12 +372,39 @@ REFERRAL_FIRST_TOPUP_BONUS_KOPEKS=10000
|
||||
REFERRAL_INVITER_BONUS_KOPEKS=10000
|
||||
REFERRAL_COMMISSION_PERCENT=25
|
||||
|
||||
|
||||
# Уведомления
|
||||
REFERRAL_NOTIFICATIONS_ENABLED=true
|
||||
REFERRAL_NOTIFICATION_RETRY_ATTEMPTS=3
|
||||
|
||||
# ===== ВЫВОД РЕФЕРАЛЬНОГО БАЛАНСА =====
|
||||
# Включить функцию вывода реферального баланса
|
||||
REFERRAL_WITHDRAWAL_ENABLED=false
|
||||
# Минимальная сумма вывода в копейках (по умолчанию 50000 = 500₽)
|
||||
REFERRAL_WITHDRAWAL_MIN_AMOUNT_KOPEKS=50000
|
||||
# Интервал между запросами на вывод (дни)
|
||||
REFERRAL_WITHDRAWAL_COOLDOWN_DAYS=30
|
||||
# Выводить только реферальный баланс (true) или весь баланс (false)
|
||||
REFERRAL_WITHDRAWAL_ONLY_REFERRAL_BALANCE=true
|
||||
# ID топика для уведомлений о заявках на вывод (0 = основной чат)
|
||||
REFERRAL_WITHDRAWAL_NOTIFICATIONS_TOPIC_ID=0
|
||||
# Тестовый режим (позволяет админам тестировать функционал)
|
||||
REFERRAL_WITHDRAWAL_TEST_MODE=false
|
||||
|
||||
# Настройки анализа на подозрительную активность
|
||||
# Минимальная сумма депозита от реферала для анализа (в копейках)
|
||||
REFERRAL_WITHDRAWAL_SUSPICIOUS_MIN_DEPOSIT_KOPEKS=100000
|
||||
# Максимум пополнений от одного реферала в месяц
|
||||
REFERRAL_WITHDRAWAL_SUSPICIOUS_MAX_DEPOSITS_PER_MONTH=10
|
||||
# Коэффициент подозрительности (пополнено в X раз больше, чем потрачено)
|
||||
REFERRAL_WITHDRAWAL_SUSPICIOUS_NO_PURCHASES_RATIO=3
|
||||
|
||||
# ===== АВТОПРОДЛЕНИЕ =====
|
||||
# Глобально включить/выключить функцию автопродления (false = функция скрыта)
|
||||
ENABLE_AUTOPAY=false
|
||||
# Дни до окончания подписки, когда отправлять предупреждение (через запятую)
|
||||
AUTOPAY_WARNING_DAYS=3,1
|
||||
# Включить автопродление для новых пользователей по умолчанию
|
||||
DEFAULT_AUTOPAY_ENABLED=true
|
||||
DEFAULT_AUTOPAY_DAYS_BEFORE=3
|
||||
MIN_BALANCE_FOR_AUTOPAY_KOPEKS=10000
|
||||
@@ -167,8 +412,8 @@ MIN_BALANCE_FOR_AUTOPAY_KOPEKS=10000
|
||||
# ===== ПЛАТЕЖНЫЕ СИСТЕМЫ =====
|
||||
|
||||
# Telegram Stars (работает автоматически)
|
||||
TELEGRAM_STARS_ENABLED=true
|
||||
TELEGRAM_STARS_RATE_RUB=1.3
|
||||
TELEGRAM_STARS_ENABLED=false
|
||||
TELEGRAM_STARS_RATE_RUB=1.79
|
||||
|
||||
# Tribute (https://tribute.app)
|
||||
TRIBUTE_ENABLED=false
|
||||
@@ -176,6 +421,8 @@ TRIBUTE_API_KEY=
|
||||
TRIBUTE_DONATE_LINK=
|
||||
TRIBUTE_WEBHOOK_PATH=/tribute-webhook
|
||||
TRIBUTE_WEBHOOK_HOST=0.0.0.0
|
||||
# Примечание: индивидуальные *_WEBHOOK_PORT используются только для старой схемы запуска.
|
||||
# В режиме единого FastAPI сервера оставьте эти значения по умолчанию.
|
||||
TRIBUTE_WEBHOOK_PORT=8081
|
||||
|
||||
# YooKassa (https://yookassa.ru)
|
||||
@@ -197,6 +444,12 @@ YOOKASSA_VAT_CODE=1
|
||||
# 4 - НДС 20%
|
||||
# 5 - НДС 10/110
|
||||
# 6 - НДС 20/120
|
||||
# 7 - НДС 5%
|
||||
# 8 - НДС 7%
|
||||
# 9 - НДС 5/105
|
||||
# 10 - НДС 7/107
|
||||
# 11 - НДС 22%
|
||||
# 12 - НДС 22/122
|
||||
|
||||
YOOKASSA_PAYMENT_MODE=full_payment
|
||||
# Способы расчета:
|
||||
@@ -228,7 +481,8 @@ YOOKASSA_PAYMENT_SUBJECT=service
|
||||
YOOKASSA_WEBHOOK_PATH=/yookassa-webhook
|
||||
YOOKASSA_WEBHOOK_HOST=0.0.0.0
|
||||
YOOKASSA_WEBHOOK_PORT=8082
|
||||
YOOKASSA_WEBHOOK_SECRET=your_webhook_secret
|
||||
# Доверенные сети для webhook (IP-адреса YooKassa, через запятую)
|
||||
# YOOKASSA_TRUSTED_PROXY_NETWORKS=185.71.76.0/24,185.71.77.0/24
|
||||
|
||||
# Лимиты сумм пополнения через YooKassa (в копейках)
|
||||
YOOKASSA_MIN_AMOUNT_KOPEKS=5000
|
||||
@@ -239,9 +493,27 @@ YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED=true
|
||||
|
||||
# Отключить отображение кнопок выбора суммы пополнения (оставить только ввод вручную)
|
||||
DISABLE_TOPUP_BUTTONS=false
|
||||
# Отключить пополнение баланса через поддержку
|
||||
SUPPORT_TOPUP_ENABLED=true
|
||||
|
||||
# Автоматическая проверка зависших пополнений и повторные обращения к провайдерам
|
||||
PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED=false
|
||||
# Интервал (в минутах) между автоматическими проверками пополнений
|
||||
PAYMENT_VERIFICATION_AUTO_CHECK_INTERVAL_MINUTES=10
|
||||
|
||||
# ===== НАЛОГОВАЯ СЛУЖБА (NaloGO) =====
|
||||
# Автоматическая отправка чеков в налоговую при пополнении баланса
|
||||
NALOGO_ENABLED=false
|
||||
NALOGO_INN= # ИНН самозанятого
|
||||
NALOGO_PASSWORD= # Пароль от личного кабинета налоговой
|
||||
NALOGO_DEVICE_ID= # Опционально: ID устройства для авторизации
|
||||
NALOGO_STORAGE_PATH=./nalogo_tokens.json # Путь к файлу с токенами
|
||||
NALOGO_QUEUE_CHECK_INTERVAL=300 # Интервал проверки очереди чеков (секунды)
|
||||
NALOGO_QUEUE_RECEIPT_DELAY=3 # Задержка между отправкой чеков (секунды)
|
||||
NALOGO_QUEUE_MAX_ATTEMPTS=10 # Максимум попыток отправки одного чека
|
||||
|
||||
# ===== НАСТРОЙКИ ОПИСАНИЙ ПЛАТЕЖЕЙ =====
|
||||
# Эти настройки позволяют изменить описания платежей,
|
||||
# Эти настройки позволяют изменить описания платежей,
|
||||
# чтобы избежать блокировок платежных систем
|
||||
PAYMENT_SERVICE_NAME=Интернет-сервис
|
||||
PAYMENT_BALANCE_DESCRIPTION=Пополнение баланса
|
||||
@@ -256,11 +528,29 @@ CRYPTOBOT_WEBHOOK_SECRET=your_webhook_secret_here
|
||||
CRYPTOBOT_BASE_URL=https://pay.crypt.bot
|
||||
CRYPTOBOT_TESTNET=false
|
||||
CRYPTOBOT_WEBHOOK_PATH=/cryptobot-webhook
|
||||
# Эти порты используются только при ручном запуске старого aiohttp сервера.
|
||||
# В новой конфигурации все вебхуки обрабатываются через порт 8080.
|
||||
CRYPTOBOT_WEBHOOK_PORT=8081
|
||||
CRYPTOBOT_DEFAULT_ASSET=USDT
|
||||
CRYPTOBOT_ASSETS=USDT,TON,BTC,ETH,LTC,BNB,TRX,USDC
|
||||
CRYPTOBOT_INVOICE_EXPIRES_HOURS=24
|
||||
|
||||
# HELEKET
|
||||
HELEKET_ENABLED=false
|
||||
HELEKET_MERCHANT_ID=
|
||||
HELEKET_API_KEY=
|
||||
HELEKET_BASE_URL=https://api.heleket.com/v1
|
||||
HELEKET_DEFAULT_CURRENCY=USDT
|
||||
HELEKET_DEFAULT_NETWORK=
|
||||
HELEKET_INVOICE_LIFETIME=3600
|
||||
HELEKET_MARKUP_PERCENT=0
|
||||
HELEKET_WEBHOOK_PATH=/heleket-webhook
|
||||
HELEKET_WEBHOOK_HOST=0.0.0.0
|
||||
HELEKET_WEBHOOK_PORT=8086
|
||||
HELEKET_CALLBACK_URL=
|
||||
HELEKET_RETURN_URL=
|
||||
HELEKET_SUCCESS_URL=
|
||||
|
||||
# MULENPAY
|
||||
MULENPAY_ENABLED=false
|
||||
MULENPAY_API_KEY=
|
||||
@@ -269,13 +559,21 @@ MULENPAY_SHOP_ID=<ID магазина>
|
||||
# необязательно, есть дефолтные значения
|
||||
MULENPAY_BASE_URL=https://mulenpay.ru/api
|
||||
MULENPAY_WEBHOOK_PATH=/mulenpay-webhook
|
||||
# Название кнопки в интерфейсе
|
||||
MULENPAY_DISPLAY_NAME=Mulen Pay
|
||||
MULENPAY_DESCRIPTION="Пополнение баланса"
|
||||
# Запрещённые ключевые слова в display_name (КАЗИНО, СТАВКИ и т.п. — блокируются Mulenpay)
|
||||
# DISPLAY_NAME_BANNED_KEYWORDS=КАЗИНО,СТАВКИ,CASINO,BET,1XBET
|
||||
MULENPAY_LANGUAGE=ru
|
||||
MULENPAY_VAT_CODE=0
|
||||
MULENPAY_PAYMENT_SUBJECT=4
|
||||
MULENPAY_PAYMENT_MODE=4
|
||||
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
|
||||
@@ -284,7 +582,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
|
||||
@@ -294,14 +591,127 @@ PAL24_SBP_BUTTON_VISIBLE=true
|
||||
# Отображать кнопку оплаты картой в PayPalych (true - отображать, false - скрывать)
|
||||
PAL24_CARD_BUTTON_VISIBLE=true
|
||||
|
||||
# PLATEGA
|
||||
PLATEGA_ENABLED=false
|
||||
PLATEGA_MERCHANT_ID=
|
||||
PLATEGA_SECRET=
|
||||
PLATEGA_BASE_URL=https://app.platega.io
|
||||
# Название кнопки в интерфейсе
|
||||
PLATEGA_DISPLAY_NAME=Platega
|
||||
PLATEGA_RETURN_URL=
|
||||
PLATEGA_FAILED_URL=
|
||||
PLATEGA_CURRENCY=RUB
|
||||
# Список ID активных методов из кабинета Platega (через запятую)
|
||||
PLATEGA_ACTIVE_METHODS=2,10,11,12,13
|
||||
PLATEGA_MIN_AMOUNT_KOPEKS=100
|
||||
PLATEGA_MAX_AMOUNT_KOPEKS=100000000
|
||||
PLATEGA_WEBHOOK_PATH=/platega-webhook
|
||||
PLATEGA_WEBHOOK_HOST=0.0.0.0
|
||||
PLATEGA_WEBHOOK_PORT=8086
|
||||
|
||||
# ===== FREEKASSA =====
|
||||
FREEKASSA_ENABLED=false
|
||||
FREEKASSA_SHOP_ID=
|
||||
FREEKASSA_API_KEY=
|
||||
# Секретное слово 1 (для формы оплаты)
|
||||
FREEKASSA_SECRET_WORD_1=
|
||||
# Секретное слово 2 (для webhook)
|
||||
FREEKASSA_SECRET_WORD_2=
|
||||
FREEKASSA_DISPLAY_NAME=Freekassa
|
||||
FREEKASSA_CURRENCY=RUB
|
||||
FREEKASSA_MIN_AMOUNT_KOPEKS=10000
|
||||
FREEKASSA_MAX_AMOUNT_KOPEKS=100000000
|
||||
FREEKASSA_PAYMENT_TIMEOUT_SECONDS=3600
|
||||
FREEKASSA_WEBHOOK_PATH=/freekassa-webhook
|
||||
FREEKASSA_WEBHOOK_HOST=0.0.0.0
|
||||
FREEKASSA_WEBHOOK_PORT=8088
|
||||
# Способ оплаты: пусто = форма выбора, 42 = обычный СБП, 44 = NSPK СБП
|
||||
FREEKASSA_PAYMENT_SYSTEM_ID=
|
||||
# Использовать API для создания заказов (обязательно для NSPK СБП)
|
||||
FREEKASSA_USE_API=false
|
||||
|
||||
# ===== KASSA AI (api.fk.life) =====
|
||||
# Отдельная платёжная система, работает параллельно с Freekassa
|
||||
KASSA_AI_ENABLED=false
|
||||
KASSA_AI_SHOP_ID=
|
||||
KASSA_AI_API_KEY=
|
||||
# Секретное слово 2 (для webhook)
|
||||
KASSA_AI_SECRET_WORD_2=
|
||||
KASSA_AI_DISPLAY_NAME=KassaAI
|
||||
KASSA_AI_CURRENCY=RUB
|
||||
KASSA_AI_MIN_AMOUNT_KOPEKS=10000
|
||||
KASSA_AI_MAX_AMOUNT_KOPEKS=100000000
|
||||
KASSA_AI_WEBHOOK_PATH=/kassa-ai-webhook
|
||||
KASSA_AI_WEBHOOK_HOST=0.0.0.0
|
||||
KASSA_AI_WEBHOOK_PORT=8089
|
||||
# Способ оплаты: 44 = СБП (QR), 36 = Карты РФ, 43 = SberPay
|
||||
KASSA_AI_PAYMENT_SYSTEM_ID=44
|
||||
|
||||
# ===== WATA =====
|
||||
WATA_ENABLED=false
|
||||
WATA_BASE_URL=https://api.wata.pro
|
||||
WATA_ACCESS_TOKEN=
|
||||
WATA_TERMINAL_PUBLIC_ID=
|
||||
WATA_PAYMENT_DESCRIPTION=Пополнение баланса
|
||||
# Тип платежа: card, sbp, all
|
||||
WATA_PAYMENT_TYPE=all
|
||||
WATA_SUCCESS_REDIRECT_URL=
|
||||
WATA_FAIL_REDIRECT_URL=
|
||||
WATA_LINK_TTL_MINUTES=60
|
||||
WATA_MIN_AMOUNT_KOPEKS=10000
|
||||
WATA_MAX_AMOUNT_KOPEKS=10000000
|
||||
WATA_REQUEST_TIMEOUT=30
|
||||
WATA_WEBHOOK_PATH=/wata-webhook
|
||||
WATA_WEBHOOK_HOST=0.0.0.0
|
||||
WATA_WEBHOOK_PORT=8087
|
||||
# Кэширование публичного ключа WATA (секунды)
|
||||
WATA_PUBLIC_KEY_CACHE_SECONDS=3600
|
||||
# URL для получения публичного ключа (опционально)
|
||||
# WATA_PUBLIC_KEY_URL=
|
||||
|
||||
# ===== CLOUDPAYMENTS =====
|
||||
CLOUDPAYMENTS_ENABLED=false
|
||||
CLOUDPAYMENTS_PUBLIC_ID=
|
||||
CLOUDPAYMENTS_API_SECRET=
|
||||
# URL API CloudPayments
|
||||
CLOUDPAYMENTS_API_URL=https://api.cloudpayments.ru
|
||||
# URL виджета оплаты
|
||||
CLOUDPAYMENTS_WIDGET_URL=https://widget.cloudpayments.ru/show
|
||||
CLOUDPAYMENTS_DESCRIPTION=Пополнение баланса
|
||||
CLOUDPAYMENTS_CURRENCY=RUB
|
||||
CLOUDPAYMENTS_MIN_AMOUNT_KOPEKS=10000
|
||||
CLOUDPAYMENTS_MAX_AMOUNT_KOPEKS=10000000
|
||||
CLOUDPAYMENTS_WEBHOOK_PATH=/cloudpayments-webhook
|
||||
CLOUDPAYMENTS_WEBHOOK_HOST=0.0.0.0
|
||||
CLOUDPAYMENTS_WEBHOOK_PORT=8089
|
||||
# URL для возврата после оплаты (опционально)
|
||||
# CLOUDPAYMENTS_RETURN_URL=
|
||||
# Скин виджета: mini, classic, modern
|
||||
CLOUDPAYMENTS_SKIN=mini
|
||||
CLOUDPAYMENTS_REQUIRE_EMAIL=false
|
||||
CLOUDPAYMENTS_TEST_MODE=false
|
||||
|
||||
# ===== ИНТЕРФЕЙС И UX =====
|
||||
|
||||
# Включить логотип для всех сообщений (true - с изображением, 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
|
||||
|
||||
# Скрыть блок с ссылкой подключения в разделе с информацией о подписке
|
||||
HIDE_SUBSCRIPTION_LINK=false
|
||||
@@ -312,10 +722,13 @@ 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=
|
||||
MINIAPP_STATIC_PATH=miniapp
|
||||
# URL для редиректа на страницу покупки в мини-приложении (опционально)
|
||||
# MINIAPP_PURCHASE_URL=
|
||||
MINIAPP_SERVICE_NAME_EN=Bedolaga VPN
|
||||
MINIAPP_SERVICE_NAME_RU=Bedolaga VPN
|
||||
MINIAPP_SERVICE_DESCRIPTION_EN=Secure & Fast Connection
|
||||
@@ -327,6 +740,8 @@ HAPP_DOWNLOAD_LINK_IOS=
|
||||
HAPP_DOWNLOAD_LINK_ANDROID=
|
||||
HAPP_DOWNLOAD_LINK_MACOS=
|
||||
HAPP_DOWNLOAD_LINK_WINDOWS=
|
||||
# Универсальная ссылка для ПК (если MACOS и WINDOWS не заданы отдельно)
|
||||
HAPP_DOWNLOAD_LINK_PC=
|
||||
# Кнопка (Подключится) с редиректом (тк ссылки с happ:// тг не поддерживает) - Без установленной ссылки на редирект кнопки (подключится) не будет! Пример: https://sub.domain.sub/redirect-page/?redirect_to=
|
||||
HAPP_CRYPTOLINK_REDIRECT_TEMPLATE=
|
||||
|
||||
@@ -367,21 +782,42 @@ SERVER_STATUS_ITEMS_PER_PAGE=10
|
||||
MAINTENANCE_MODE=false
|
||||
MAINTENANCE_CHECK_INTERVAL=30
|
||||
MAINTENANCE_AUTO_ENABLE=true
|
||||
MAINTENANCE_MONITORING_ENABLED=true
|
||||
MAINTENANCE_RETRY_ATTEMPTS=1
|
||||
MAINTENANCE_MESSAGE=Ведутся технические работы. Сервис временно недоступен. Попробуйте позже.
|
||||
|
||||
# ===== ЛОКАЛИЗАЦИЯ =====
|
||||
# Укажите язык из AVAILABLE_LANGUAGES. При некорректном значении используется ru.
|
||||
DEFAULT_LANGUAGE=ru
|
||||
AVAILABLE_LANGUAGES=ru,en
|
||||
AVAILABLE_LANGUAGES=ru,en,ua,zh,fa
|
||||
# Включить выбор языка при старте и отображение кнопки в меню
|
||||
LANGUAGE_SELECTION_ENABLED=true
|
||||
|
||||
# Округление цен при отображении (≤50 коп вниз, >50 коп вверх)
|
||||
# true: 14.78₽ → 15₽, 14.12₽ → 14₽
|
||||
# false: показывать точные суммы с копейками
|
||||
PRICE_ROUNDING_ENABLED=true
|
||||
|
||||
# Часовой пояс
|
||||
TZ=Europe/Moscow # или UTC, America/New_York и т.д.
|
||||
|
||||
# ===== ДОПОЛНИТЕЛЬНЫЕ НАСТРОЙКИ =====
|
||||
# Конфигурация приложений для гайда подключения
|
||||
APP_CONFIG_PATH=app-config.json
|
||||
ENABLE_DEEP_LINKS=true
|
||||
APP_CONFIG_CACHE_TTL=3600
|
||||
|
||||
# ===== BAN SYSTEM INTEGRATION (BedolagaBan) =====
|
||||
# Интеграция с системой мониторинга банов BedolagaBan
|
||||
# Включить интеграцию с Ban системой
|
||||
BAN_SYSTEM_ENABLED=false
|
||||
# URL API сервера Ban системы (например: http://ban-server:8000)
|
||||
BAN_SYSTEM_API_URL=
|
||||
# API токен для авторизации в Ban системе
|
||||
BAN_SYSTEM_API_TOKEN=
|
||||
# Таймаут запросов к API (секунды)
|
||||
BAN_SYSTEM_REQUEST_TIMEOUT=30
|
||||
|
||||
# ===== СИСТЕМА БЕКАПОВ =====
|
||||
BACKUP_AUTO_ENABLED=true
|
||||
BACKUP_INTERVAL_HOURS=24
|
||||
@@ -398,6 +834,8 @@ BACKUP_SEND_ENABLED=true
|
||||
BACKUP_SEND_CHAT_ID=-100123456789 # Замени на ID твоего канала (-100) - ПРЕФИКС ЗАКРЫТОГО КАНАЛА!
|
||||
# ВСТАВИТЬ СВОЙ ID СРАЗУ ПОСЛЕ (-100) БЕЗ ПРОБЕЛОВ!
|
||||
BACKUP_SEND_TOPIC_ID=123 # Опционально: ID топика
|
||||
# Пароль для архива бекапа (опционально). Если задан - бекап отправляется в зашифрованном ZIP с AES
|
||||
BACKUP_ARCHIVE_PASSWORD=
|
||||
|
||||
# ===== ПРОВЕРКА ОБНОВЛЕНИЙ БОТА =====
|
||||
VERSION_CHECK_ENABLED=true
|
||||
@@ -408,7 +846,77 @@ VERSION_CHECK_INTERVAL_HOURS=1
|
||||
LOG_LEVEL=INFO
|
||||
LOG_FILE=logs/bot.log
|
||||
|
||||
# === Ротация логов ===
|
||||
# Включить новую систему ротации (по умолчанию старое поведение)
|
||||
LOG_ROTATION_ENABLED=false
|
||||
# Время ротации (HH:MM)
|
||||
LOG_ROTATION_TIME=00:00
|
||||
# Хранить архивы N дней
|
||||
LOG_ROTATION_KEEP_DAYS=7
|
||||
# Сжимать архивы gzip
|
||||
LOG_ROTATION_COMPRESS=true
|
||||
# Отправлять архивы в Telegram-канал
|
||||
LOG_ROTATION_SEND_TO_TELEGRAM=false
|
||||
# Канал для логов (если не задан, используется BACKUP_SEND_CHAT_ID)
|
||||
LOG_ROTATION_CHAT_ID=
|
||||
# Топик в канале (если не задан, используется BACKUP_SEND_TOPIC_ID)
|
||||
LOG_ROTATION_TOPIC_ID=
|
||||
# Пути к лог-файлам (при LOG_ROTATION_ENABLED=true)
|
||||
LOG_DIR=logs
|
||||
LOG_INFO_FILE=info.log
|
||||
LOG_WARNING_FILE=warning.log
|
||||
LOG_ERROR_FILE=error.log
|
||||
LOG_PAYMENTS_FILE=payments.log
|
||||
|
||||
# ===== РАЗРАБОТКА =====
|
||||
DEBUG=false
|
||||
WEBHOOK_URL=
|
||||
WEBHOOK_PATH=/webhook
|
||||
WEBHOOK_SECRET_TOKEN=
|
||||
WEBHOOK_DROP_PENDING_UPDATES=true
|
||||
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
|
||||
|
||||
# ===== КОНКУРСНАЯ СИСТЕМА =====
|
||||
CONTESTS_ENABLED=false
|
||||
CONTESTS_BUTTON_VISIBLE=false
|
||||
# Реферальные конкурсы (турниры среди рефералов)
|
||||
REFERRAL_CONTESTS_ENABLED=false
|
||||
|
||||
# ===== АВТОПОКУПКА ПОСЛЕ ПОПОЛНЕНИЯ =====
|
||||
# Автоматическая покупка из сохранённой корзины после пополнения баланса
|
||||
AUTO_PURCHASE_AFTER_TOPUP_ENABLED=false
|
||||
|
||||
# ===== КНОПКА АКТИВАЦИИ =====
|
||||
ACTIVATE_BUTTON_VISIBLE=false
|
||||
# ACTIVATE_BUTTON_TEXT=активировать
|
||||
|
||||
# ===== ЕДИНЫЙ ВЕБ-СЕРВЕР =====
|
||||
WEB_API_ENABLED=false
|
||||
WEB_API_HOST=0.0.0.0
|
||||
WEB_API_PORT=8080
|
||||
# Количество воркеров (для продакшена рекомендуется 2-4)
|
||||
WEB_API_WORKERS=1
|
||||
WEB_API_ALLOWED_ORIGINS=*
|
||||
WEB_API_DOCS_ENABLED=false
|
||||
# Название и версия API (для документации)
|
||||
WEB_API_TITLE=Remnawave Bot Admin API
|
||||
WEB_API_VERSION=1.0.0
|
||||
# Токен по умолчанию для начальной настройки
|
||||
WEB_API_DEFAULT_TOKEN=
|
||||
WEB_API_DEFAULT_TOKEN_NAME=Bootstrap Token
|
||||
# Алгоритм хеширования токенов
|
||||
WEB_API_TOKEN_HASH_ALGORITHM=sha256
|
||||
# Логирование запросов
|
||||
WEB_API_REQUEST_LOGGING=true
|
||||
|
||||
# Внешний админ-токен (для интеграции с другими ботами/системами)
|
||||
# Токен для доступа через API другого бота
|
||||
# EXTERNAL_ADMIN_TOKEN=
|
||||
# ID бота, от которого принимается токен
|
||||
# EXTERNAL_ADMIN_TOKEN_BOT_ID=
|
||||
|
||||
MINIAPP_STATIC_PATH=miniapp
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
---
|
||||
name: Bug Bedolage
|
||||
about: Создать баг-репорт «Бедолаге»
|
||||
title: "[BUG] Заголовок"
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
## Описание / Конфигурация
|
||||
|
||||
Кратко описываем текущую проблему.
|
||||
|
||||
Указываем настройки из `.env` конфига (если таковые имеются):
|
||||
|
||||
```yml
|
||||
|
||||
```
|
||||
|
||||
## Шаги для воспроизведения
|
||||
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
### Фактический результат
|
||||
|
||||
*
|
||||
*
|
||||
*
|
||||
|
||||
### Скрины поведения
|
||||
|
||||
Копируем изображение и вставляем прям сюда, если есть. Если нет — удаляем секцию.
|
||||
|
||||
## Ожидаемое поведение
|
||||
|
||||
Опишите, как должно работать.
|
||||
|
||||
## Дополнительная информация
|
||||
|
||||
Добавляем сюда дополнительную инфу, если есть. Если нет — удаляем секцию.
|
||||
@@ -0,0 +1,32 @@
|
||||
---
|
||||
name: Feat Bedolage
|
||||
about: Создать запрос на фичу «Бедолаге»
|
||||
title: "[FEAT] Заголовок"
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
## Описание / Конфигурация
|
||||
|
||||
Кратко описываем что хотим.
|
||||
|
||||
Указываем настройки из `.env` конфига (если таковые к месту):
|
||||
|
||||
```yml
|
||||
|
||||
```
|
||||
|
||||
## Текущее поведение
|
||||
|
||||
### Скрины поведения
|
||||
|
||||
Копируем изображение и вставляем прям сюда, если есть. Если нет — удаляем секцию.
|
||||
|
||||
## Желаемое поведение
|
||||
|
||||
Опишите, как хотелось бы, чтобы было.
|
||||
|
||||
## Дополнительная информация
|
||||
|
||||
Добавляем сюда дополнительную инфу, если есть. Если нет — удаляем секцию.
|
||||
@@ -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="v2.5.3-$(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="v2.5.3-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="v2.5.3-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="v2.5.3-$(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="v2.5.3-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="v2.5.3-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,27 @@
|
||||
name: Lint
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ['**']
|
||||
pull_request:
|
||||
branches: ['**']
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: astral-sh/setup-uv@v5
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.13'
|
||||
|
||||
- run: uv sync --group dev
|
||||
|
||||
- name: Check formatting
|
||||
run: uv run ruff format --check .
|
||||
|
||||
- name: Check linting
|
||||
run: uv run ruff check .
|
||||
@@ -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
|
||||
+87
-21
@@ -1,36 +1,102 @@
|
||||
# Игнорируем все файлы и папки по умолчанию
|
||||
*
|
||||
|
||||
# Исключения: разрешаем только нужные файлы
|
||||
# ========== WHITELIST: разрешённые файлы ==========
|
||||
|
||||
# Конфигурация проекта
|
||||
!.dockerignore
|
||||
!.env.example
|
||||
!install_bot.sh
|
||||
!.gitignore
|
||||
!.python-version
|
||||
!Dockerfile
|
||||
!app-config.json
|
||||
!main.py
|
||||
!docker-compose.yml
|
||||
!docker-compose.local.yml
|
||||
!Makefile
|
||||
!pyproject.toml
|
||||
!uv.lock
|
||||
!requirements.txt
|
||||
!docs/
|
||||
!docs/**
|
||||
!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/**
|
||||
!migrations/
|
||||
!migrations/**
|
||||
!docs/
|
||||
!docs/**
|
||||
!assets/
|
||||
!assets/**
|
||||
!locales/
|
||||
!locales/**
|
||||
!.github/
|
||||
!.github/**
|
||||
|
||||
# Дополнительно разрешаем README и лицензию (опционально)
|
||||
!README.md
|
||||
!LICENSE
|
||||
# ========== BLACKLIST: игнорируемые внутри папок ==========
|
||||
|
||||
# Разрешаем .gitignore чтобы он попал в репозиторий
|
||||
!.gitignore
|
||||
# Python
|
||||
__pycache__/
|
||||
**/__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
|
||||
# Внутри разрешенных папок игнорируем служебные файлы
|
||||
app/__pycache__/
|
||||
app/**/__pycache__/
|
||||
app/**/*.pyc
|
||||
app/**/*.pyo
|
||||
app/**/*.pyd
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
# 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.13
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
".": "3.12.0"
|
||||
}
|
||||
+302
@@ -0,0 +1,302 @@
|
||||
# Changelog
|
||||
|
||||
## [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))
|
||||
+3
-3
@@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
|
||||
|
||||
FROM python:3.13-slim
|
||||
|
||||
ARG VERSION="v2.5.3"
|
||||
ARG VERSION="v3.12.0" # x-release-please-version
|
||||
ARG BUILD_DATE
|
||||
ARG VCS_REF
|
||||
|
||||
@@ -45,7 +45,7 @@ ENV PYTHONPATH=/app \
|
||||
BUILD_DATE=${BUILD_DATE} \
|
||||
VCS_REF=${VCS_REF}
|
||||
|
||||
EXPOSE 8080 8081 8082
|
||||
EXPOSE 8080
|
||||
|
||||
LABEL org.opencontainers.image.title="Bedolaga RemnaWave Bot" \
|
||||
org.opencontainers.image.description="Telegram bot for RemnaWave VPN service" \
|
||||
@@ -57,6 +57,6 @@ LABEL org.opencontainers.image.title="Bedolaga RemnaWave Bot" \
|
||||
org.opencontainers.image.vendor="fr1ngg"
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:8081/health || exit 1
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1
|
||||
|
||||
CMD ["python", "main.py"]
|
||||
|
||||
@@ -1,3 +1,28 @@
|
||||
"Commons Clause" License Condition v1.0
|
||||
|
||||
The Software is provided to you by the Licensor under the License,
|
||||
as defined below, subject to the following condition.
|
||||
|
||||
Without limiting other conditions in the License, the grant of rights
|
||||
under the License will not include, and the License does not grant to
|
||||
you, the right to Sell the Software.
|
||||
|
||||
For purposes of the foregoing, "Sell" means practicing any or all of
|
||||
the rights granted to you under the License to provide to third parties,
|
||||
for a fee or other consideration (including without limitation fees for
|
||||
hosting or consulting/support services related to the Software), a
|
||||
product or service whose value derives, entirely or substantially, from
|
||||
the functionality of the Software.
|
||||
|
||||
Any license notice or attribution required by the License must also
|
||||
include this Commons Clause License Condition notice.
|
||||
|
||||
Software: remnawave-bedolaga-telegram-bot
|
||||
License: MIT
|
||||
Licensor: Fr1ngg
|
||||
|
||||
---
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Fr1ngg
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
.PHONY: up
|
||||
up: ## Поднять контейнеры (detached)
|
||||
@echo "🚀 Поднимаем контейнеры (detached)..."
|
||||
docker compose up -d --build
|
||||
|
||||
.PHONY: up-follow
|
||||
up-follow: ## Поднять контейнеры с логами
|
||||
@echo "📡 Поднимаем контейнеры (в консоли)..."
|
||||
docker compose up --build
|
||||
|
||||
.PHONY: down
|
||||
down: ## Остановить и удалить контейнеры
|
||||
@echo "🛑 Останавливаем и удаляем контейнеры..."
|
||||
docker compose down
|
||||
|
||||
.PHONY: reload
|
||||
reload: ## Перезапустить контейнеры (detached)
|
||||
@$(MAKE) down
|
||||
@$(MAKE) up
|
||||
|
||||
.PHONY: reload-follow
|
||||
reload-follow: ## Перезапустить контейнеры с логами
|
||||
@$(MAKE) down
|
||||
@$(MAKE) up-follow
|
||||
|
||||
.PHONY: test
|
||||
test: ## Запустить тесты
|
||||
uv run pytest -v
|
||||
|
||||
.PHONY: lint
|
||||
lint: ## Проверить код (ruff check)
|
||||
uv run ruff check .
|
||||
|
||||
.PHONY: format
|
||||
format: ## Форматировать код (ruff format)
|
||||
uv run ruff format .
|
||||
|
||||
.PHONY: fix
|
||||
fix: ## Исправить код (ruff check --fix + format)
|
||||
uv run ruff check . --fix
|
||||
uv run ruff format .
|
||||
|
||||
.PHONY: help
|
||||
help: ## Показать список доступных команд
|
||||
@echo ""
|
||||
@echo "📘 Команды Makefile:"
|
||||
@echo ""
|
||||
@awk -F':.*## ' '/^[a-zA-Z0-9_-]+:.*## / {printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)
|
||||
@echo ""
|
||||
+2
-4
@@ -136,8 +136,7 @@ logger.debug(f"Webhook payload: {webhook_data}")
|
||||
# Открыть только необходимые порты
|
||||
ufw allow 80/tcp # HTTP (redirect to HTTPS)
|
||||
ufw allow 443/tcp # HTTPS
|
||||
ufw deny 8081/tcp # Webhook порты (только через reverse proxy)
|
||||
ufw deny 8082/tcp
|
||||
ufw deny 8080/tcp # Unified FastAPI сервер доступен только из внутренней сети
|
||||
```
|
||||
|
||||
### 📊 Мониторинг безопасности
|
||||
@@ -239,7 +238,6 @@ REMNAWAVE_API_KEY=your_api_key
|
||||
ADMIN_IDS=123456789,987654321
|
||||
|
||||
# Webhook секреты - генерируйте случайные значения
|
||||
YOOKASSA_WEBHOOK_SECRET=random_secret_here
|
||||
TRIBUTE_WEBHOOK_SECRET=another_random_secret
|
||||
CRYPTOBOT_WEBHOOK_SECRET=yet_another_secret
|
||||
```
|
||||
@@ -283,7 +281,7 @@ services:
|
||||
|
||||
# Не пробрасывайте порты наружу без необходимости
|
||||
ports:
|
||||
- "127.0.0.1:8081:8081" # Только localhost
|
||||
- "127.0.0.1:8080:8080" # Только localhost
|
||||
|
||||
# Read-only root filesystem
|
||||
read_only: true
|
||||
|
||||
+155
-83
@@ -1,100 +1,115 @@
|
||||
import logging
|
||||
from aiogram import Bot, Dispatcher, types
|
||||
from aiogram.fsm.storage.redis import RedisStorage
|
||||
from aiogram.fsm.storage.memory import MemoryStorage
|
||||
|
||||
import redis.asyncio as redis
|
||||
from aiogram import Bot, Dispatcher, types
|
||||
from aiogram.fsm.storage.memory import MemoryStorage
|
||||
from aiogram.fsm.storage.redis import RedisStorage
|
||||
|
||||
from app.config import settings
|
||||
from app.middlewares.global_error import GlobalErrorMiddleware
|
||||
from app.middlewares.auth import AuthMiddleware
|
||||
from app.middlewares.logging import LoggingMiddleware
|
||||
from app.middlewares.throttling import ThrottlingMiddleware
|
||||
from app.middlewares.subscription_checker import SubscriptionStatusMiddleware
|
||||
from app.middlewares.maintenance import MaintenanceMiddleware
|
||||
from app.middlewares.display_name_restriction import DisplayNameRestrictionMiddleware
|
||||
from app.services.maintenance_service import maintenance_service
|
||||
from app.utils.cache import cache
|
||||
|
||||
from app.handlers import (
|
||||
start, menu, subscription, balance, promocode,
|
||||
referral, support, server_status, common, tickets
|
||||
balance,
|
||||
common,
|
||||
contests as user_contests,
|
||||
menu,
|
||||
polls as user_polls,
|
||||
promocode,
|
||||
referral,
|
||||
server_status,
|
||||
simple_subscription,
|
||||
start,
|
||||
subscription,
|
||||
support,
|
||||
tickets,
|
||||
)
|
||||
from app.handlers import simple_subscription
|
||||
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,
|
||||
contests as admin_contests,
|
||||
daily_contests as admin_daily_contests,
|
||||
faq as admin_faq,
|
||||
main as admin_main,
|
||||
users as admin_users,
|
||||
subscriptions as admin_subscriptions,
|
||||
promocodes as admin_promocodes,
|
||||
maintenance as admin_maintenance,
|
||||
messages as admin_messages,
|
||||
monitoring as admin_monitoring,
|
||||
referrals as admin_referrals,
|
||||
rules as admin_rules,
|
||||
remnawave as admin_remnawave,
|
||||
statistics as admin_statistics,
|
||||
servers as admin_servers,
|
||||
maintenance as admin_maintenance,
|
||||
promo_groups as admin_promo_groups,
|
||||
campaigns as admin_campaigns,
|
||||
promo_offers as admin_promo_offers,
|
||||
user_messages as admin_user_messages,
|
||||
updates as admin_updates,
|
||||
backup as admin_backup,
|
||||
system_logs as admin_system_logs,
|
||||
welcome_text as admin_welcome_text,
|
||||
tickets as admin_tickets,
|
||||
reports as admin_reports,
|
||||
bot_configuration as admin_bot_configuration,
|
||||
payments as admin_payments,
|
||||
polls as admin_polls,
|
||||
pricing as admin_pricing,
|
||||
privacy_policy as admin_privacy_policy,
|
||||
promo_groups as admin_promo_groups,
|
||||
promo_offers as admin_promo_offers,
|
||||
promocodes as admin_promocodes,
|
||||
public_offer as admin_public_offer,
|
||||
faq as admin_faq,
|
||||
referrals as admin_referrals,
|
||||
remnawave as admin_remnawave,
|
||||
reports as admin_reports,
|
||||
rules as admin_rules,
|
||||
servers as admin_servers,
|
||||
statistics as admin_statistics,
|
||||
subscriptions as admin_subscriptions,
|
||||
system_logs as admin_system_logs,
|
||||
tariffs as admin_tariffs,
|
||||
tickets as admin_tickets,
|
||||
trials as admin_trials,
|
||||
updates as admin_updates,
|
||||
user_messages as admin_user_messages,
|
||||
users as admin_users,
|
||||
welcome_text as admin_welcome_text,
|
||||
)
|
||||
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.global_error import GlobalErrorMiddleware
|
||||
from app.middlewares.logging import LoggingMiddleware
|
||||
from app.middlewares.maintenance import MaintenanceMiddleware
|
||||
from app.middlewares.subscription_checker import SubscriptionStatusMiddleware
|
||||
from app.middlewares.throttling import ThrottlingMiddleware
|
||||
from app.services.maintenance_service import maintenance_service
|
||||
from app.utils.cache import cache
|
||||
from app.utils.message_patch import patch_message_methods
|
||||
|
||||
|
||||
patch_message_methods()
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def debug_callback_handler(callback: types.CallbackQuery):
|
||||
logger.info(f"🔍 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('🔍 DEBUG CALLBACK:')
|
||||
logger.info(f' - Data: {callback.data}')
|
||||
logger.info(f' - User: {callback.from_user.id}')
|
||||
logger.info(f' - Username: {callback.from_user.username}')
|
||||
|
||||
|
||||
async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
|
||||
try:
|
||||
await cache.connect()
|
||||
logger.info("Кеш инициализирован")
|
||||
logger.info('Кеш инициализирован')
|
||||
except Exception as e:
|
||||
logger.warning(f"Кеш не инициализирован: {e}")
|
||||
|
||||
logger.warning(f'Кеш не инициализирован: {e}')
|
||||
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
|
||||
bot = Bot(
|
||||
token=settings.BOT_TOKEN,
|
||||
default=DefaultBotProperties(parse_mode=ParseMode.HTML)
|
||||
)
|
||||
|
||||
bot = Bot(token=settings.BOT_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
|
||||
|
||||
maintenance_service.set_bot(bot)
|
||||
logger.info("Бот установлен в maintenance_service")
|
||||
|
||||
logger.info('Бот установлен в maintenance_service')
|
||||
|
||||
try:
|
||||
redis_client = redis.from_url(settings.REDIS_URL)
|
||||
await redis_client.ping()
|
||||
storage = RedisStorage(redis_client)
|
||||
logger.info("Подключено к Redis для FSM storage")
|
||||
logger.info('Подключено к Redis для FSM storage')
|
||||
except Exception as e:
|
||||
logger.warning(f"Не удалось подключиться к Redis: {e}")
|
||||
logger.info("Используется MemoryStorage для FSM")
|
||||
logger.warning(f'Не удалось подключиться к Redis: {e}')
|
||||
logger.info('Используется MemoryStorage для FSM')
|
||||
storage = MemoryStorage()
|
||||
|
||||
|
||||
|
||||
dp = Dispatcher(storage=storage)
|
||||
|
||||
dp.message.middleware(GlobalErrorMiddleware())
|
||||
@@ -104,22 +119,28 @@ 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())
|
||||
|
||||
# Middleware для автоматического логирования кликов по кнопкам
|
||||
if settings.MENU_LAYOUT_ENABLED:
|
||||
button_stats_middleware = ButtonStatsMiddleware()
|
||||
dp.callback_query.middleware(button_stats_middleware)
|
||||
logger.info('📊 ButtonStatsMiddleware активирован')
|
||||
|
||||
if settings.CHANNEL_IS_REQUIRED_SUB:
|
||||
from app.middlewares.channel_checker import ChannelCheckerMiddleware
|
||||
|
||||
channel_checker_middleware = ChannelCheckerMiddleware()
|
||||
dp.message.middleware(channel_checker_middleware)
|
||||
dp.callback_query.middleware(channel_checker_middleware)
|
||||
logger.info("🔒 Обязательная подписка включена - ChannelCheckerMiddleware активирован")
|
||||
logger.info('🔒 Обязательная подписка включена - ChannelCheckerMiddleware активирован')
|
||||
else:
|
||||
logger.info("🔓 Обязательная подписка отключена - ChannelCheckerMiddleware не зарегистрирован")
|
||||
logger.info('🔓 Обязательная подписка отключена - ChannelCheckerMiddleware не зарегистрирован')
|
||||
dp.message.middleware(AuthMiddleware())
|
||||
dp.callback_query.middleware(AuthMiddleware())
|
||||
dp.pre_checkout_query.middleware(AuthMiddleware())
|
||||
@@ -137,7 +158,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
admin_main.register_handlers(dp)
|
||||
admin_users.register_handlers(dp)
|
||||
admin_subscriptions.register_handlers(dp)
|
||||
admin_servers.register_handlers(dp)
|
||||
admin_servers.register_handlers(dp)
|
||||
admin_promocodes.register_handlers(dp)
|
||||
admin_messages.register_handlers(dp)
|
||||
admin_monitoring.register_handlers(dp)
|
||||
@@ -145,8 +166,11 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
admin_rules.register_handlers(dp)
|
||||
admin_remnawave.register_handlers(dp)
|
||||
admin_statistics.register_handlers(dp)
|
||||
admin_polls.register_handlers(dp)
|
||||
admin_promo_groups.register_handlers(dp)
|
||||
admin_campaigns.register_handlers(dp)
|
||||
admin_contests.register_handlers(dp)
|
||||
admin_daily_contests.register_handlers(dp)
|
||||
admin_promo_offers.register_handlers(dp)
|
||||
admin_maintenance.register_handlers(dp)
|
||||
admin_user_messages.register_handlers(dp)
|
||||
@@ -161,34 +185,82 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
admin_privacy_policy.register_handlers(dp)
|
||||
admin_public_offer.register_handlers(dp)
|
||||
admin_faq.register_handlers(dp)
|
||||
admin_payments.register_handlers(dp)
|
||||
admin_trials.register_handlers(dp)
|
||||
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)
|
||||
user_polls.register_handlers(dp)
|
||||
simple_subscription.register_simple_subscription_handlers(dp)
|
||||
logger.info("⭐ Зарегистрированы обработчики Telegram Stars платежей")
|
||||
logger.info("⚡ Зарегистрированы обработчики простой покупки")
|
||||
logger.info("⚡ Зарегистрированы обработчики простой подписки")
|
||||
|
||||
try:
|
||||
await maintenance_service.start_monitoring()
|
||||
logger.info("Мониторинг техработ запущен")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка запуска мониторинга техработ: {e}")
|
||||
|
||||
logger.info("🛡️ GlobalErrorMiddleware активирован - бот защищен от устаревших callback queries")
|
||||
logger.info("Бот успешно настроен")
|
||||
|
||||
logger.info('⭐ Зарегистрированы обработчики Telegram Stars платежей')
|
||||
logger.info('⚡ Зарегистрированы обработчики простой покупки')
|
||||
logger.info('⚡ Зарегистрированы обработчики простой подписки')
|
||||
|
||||
if settings.is_maintenance_monitoring_enabled():
|
||||
try:
|
||||
await maintenance_service.start_monitoring()
|
||||
logger.info('Мониторинг техработ запущен')
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка запуска мониторинга техработ: {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(f'🏠 Режим Cabinet активен, базовый 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(f'Failed to load button styles cache: {e}')
|
||||
|
||||
logger.info('Бот успешно настроен')
|
||||
|
||||
return bot, dp
|
||||
|
||||
|
||||
async def shutdown_bot():
|
||||
try:
|
||||
await maintenance_service.stop_monitoring()
|
||||
logger.info("Мониторинг техработ остановлен")
|
||||
logger.info('Мониторинг техработ остановлен')
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка остановки мониторинга: {e}")
|
||||
|
||||
logger.error(f'Ошибка остановки мониторинга: {e}')
|
||||
|
||||
try:
|
||||
await cache.close()
|
||||
logger.info("Соединения с кешем закрыты")
|
||||
logger.info('Соединения с кешем закрыты')
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка закрытия кеша: {e}")
|
||||
logger.error(f'Ошибка закрытия кеша: {e}')
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
"""
|
||||
Cabinet module - Personal Account for VPN Bot users.
|
||||
|
||||
This module provides:
|
||||
- JWT-based authentication (Telegram + Email)
|
||||
- Subscription management
|
||||
- Balance & payments
|
||||
- Referral program
|
||||
- Support tickets
|
||||
"""
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Cabinet authentication module."""
|
||||
|
||||
from .jwt_handler import (
|
||||
create_access_token,
|
||||
create_refresh_token,
|
||||
decode_token,
|
||||
get_token_payload,
|
||||
)
|
||||
from .password_utils import hash_password, verify_password
|
||||
from .telegram_auth import validate_telegram_init_data, validate_telegram_login_widget
|
||||
|
||||
|
||||
__all__ = [
|
||||
'create_access_token',
|
||||
'create_refresh_token',
|
||||
'decode_token',
|
||||
'get_token_payload',
|
||||
'hash_password',
|
||||
'validate_telegram_init_data',
|
||||
'validate_telegram_login_widget',
|
||||
'verify_password',
|
||||
]
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Email verification token generation and validation."""
|
||||
|
||||
import secrets
|
||||
from datetime import 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.utcnow() + timedelta(minutes=minutes)
|
||||
|
||||
|
||||
def generate_verification_token() -> str:
|
||||
"""
|
||||
Generate a secure random verification token.
|
||||
|
||||
Returns:
|
||||
32-character hex token string
|
||||
"""
|
||||
return secrets.token_hex(32)
|
||||
|
||||
|
||||
def generate_password_reset_token() -> str:
|
||||
"""
|
||||
Generate a secure random password reset token.
|
||||
|
||||
Returns:
|
||||
32-character hex token string
|
||||
"""
|
||||
return secrets.token_hex(32)
|
||||
|
||||
|
||||
def get_verification_expires_at() -> datetime:
|
||||
"""
|
||||
Get the expiration datetime for a verification token.
|
||||
|
||||
Returns:
|
||||
Datetime when the verification token expires
|
||||
"""
|
||||
hours = settings.get_cabinet_email_verification_expire_hours()
|
||||
return datetime.utcnow() + timedelta(hours=hours)
|
||||
|
||||
|
||||
def get_password_reset_expires_at() -> datetime:
|
||||
"""
|
||||
Get the expiration datetime for a password reset token.
|
||||
|
||||
Returns:
|
||||
Datetime when the password reset token expires
|
||||
"""
|
||||
hours = settings.get_cabinet_password_reset_expire_hours()
|
||||
return datetime.utcnow() + timedelta(hours=hours)
|
||||
|
||||
|
||||
def is_token_expired(expires_at: datetime | None) -> bool:
|
||||
"""
|
||||
Check if a token has expired.
|
||||
|
||||
Args:
|
||||
expires_at: Token expiration datetime
|
||||
|
||||
Returns:
|
||||
True if expired or no expiration set, False otherwise
|
||||
"""
|
||||
if expires_at is None:
|
||||
return True
|
||||
return datetime.utcnow() > expires_at
|
||||
@@ -0,0 +1,111 @@
|
||||
"""JWT token handling for cabinet authentication."""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import jwt
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
JWT_ALGORITHM = 'HS256'
|
||||
|
||||
|
||||
def create_access_token(user_id: int, telegram_id: int | None = None) -> str:
|
||||
"""
|
||||
Create a short-lived access token.
|
||||
|
||||
Args:
|
||||
user_id: Database user ID
|
||||
telegram_id: Telegram user ID (optional for email-only users)
|
||||
|
||||
Returns:
|
||||
Encoded JWT access token
|
||||
"""
|
||||
expire_minutes = settings.get_cabinet_access_token_expire_minutes()
|
||||
expires = datetime.utcnow() + timedelta(minutes=expire_minutes)
|
||||
|
||||
payload = {
|
||||
'sub': str(user_id),
|
||||
'type': 'access',
|
||||
'exp': expires,
|
||||
'iat': datetime.utcnow(),
|
||||
}
|
||||
|
||||
# Добавляем telegram_id только если он есть
|
||||
if telegram_id is not None:
|
||||
payload['telegram_id'] = telegram_id
|
||||
|
||||
secret = settings.get_cabinet_jwt_secret()
|
||||
return jwt.encode(payload, secret, algorithm=JWT_ALGORITHM)
|
||||
|
||||
|
||||
def create_refresh_token(user_id: int) -> str:
|
||||
"""
|
||||
Create a long-lived refresh token.
|
||||
|
||||
Args:
|
||||
user_id: Database user ID
|
||||
|
||||
Returns:
|
||||
Encoded JWT refresh token
|
||||
"""
|
||||
expire_days = settings.get_cabinet_refresh_token_expire_days()
|
||||
expires = datetime.utcnow() + timedelta(days=expire_days)
|
||||
|
||||
payload = {
|
||||
'sub': str(user_id),
|
||||
'type': 'refresh',
|
||||
'exp': expires,
|
||||
'iat': datetime.utcnow(),
|
||||
}
|
||||
|
||||
secret = settings.get_cabinet_jwt_secret()
|
||||
return jwt.encode(payload, secret, algorithm=JWT_ALGORITHM)
|
||||
|
||||
|
||||
def decode_token(token: str) -> dict[str, Any] | None:
|
||||
"""
|
||||
Decode and validate a JWT token.
|
||||
|
||||
Args:
|
||||
token: JWT token string
|
||||
|
||||
Returns:
|
||||
Decoded payload dict or None if invalid/expired
|
||||
"""
|
||||
try:
|
||||
secret = settings.get_cabinet_jwt_secret()
|
||||
return jwt.decode(token, secret, algorithms=[JWT_ALGORITHM])
|
||||
except jwt.ExpiredSignatureError:
|
||||
return None
|
||||
except jwt.InvalidTokenError:
|
||||
return None
|
||||
|
||||
|
||||
def get_token_payload(token: str, expected_type: str = 'access') -> dict[str, Any] | None:
|
||||
"""
|
||||
Decode token and verify its type.
|
||||
|
||||
Args:
|
||||
token: JWT token string
|
||||
expected_type: Expected token type ("access" or "refresh")
|
||||
|
||||
Returns:
|
||||
Decoded payload dict or None if invalid/expired/wrong type
|
||||
"""
|
||||
payload = decode_token(token)
|
||||
|
||||
if not payload:
|
||||
return None
|
||||
|
||||
if payload.get('type') != expected_type:
|
||||
return None
|
||||
|
||||
return payload
|
||||
|
||||
|
||||
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)
|
||||
@@ -0,0 +1,429 @@
|
||||
"""OAuth 2.0 provider implementations for cabinet authentication."""
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any, TypedDict
|
||||
|
||||
import httpx
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.config import settings
|
||||
from app.utils.cache import cache, cache_key
|
||||
|
||||
|
||||
logger = logging.getLogger(__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,
|
||||
)
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Password hashing utilities using bcrypt."""
|
||||
|
||||
import bcrypt
|
||||
|
||||
|
||||
BCRYPT_ROUNDS = 12
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
"""
|
||||
Hash a password using bcrypt.
|
||||
|
||||
Args:
|
||||
password: Plain text password
|
||||
|
||||
Returns:
|
||||
Hashed password string
|
||||
"""
|
||||
password_bytes = password.encode('utf-8')
|
||||
salt = bcrypt.gensalt(rounds=BCRYPT_ROUNDS)
|
||||
hashed = bcrypt.hashpw(password_bytes, salt)
|
||||
return hashed.decode('utf-8')
|
||||
|
||||
|
||||
def verify_password(password: str, password_hash: str) -> bool:
|
||||
"""
|
||||
Verify a password against its hash.
|
||||
|
||||
Args:
|
||||
password: Plain text password to verify
|
||||
password_hash: Previously hashed password
|
||||
|
||||
Returns:
|
||||
True if password matches, False otherwise
|
||||
"""
|
||||
try:
|
||||
password_bytes = password.encode('utf-8')
|
||||
hash_bytes = password_hash.encode('utf-8')
|
||||
return bcrypt.checkpw(password_bytes, hash_bytes)
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Telegram authentication validation for cabinet."""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from urllib.parse import parse_qsl, unquote
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
def validate_telegram_login_widget(data: dict[str, Any], max_age_seconds: int = 86400) -> bool:
|
||||
"""
|
||||
Validate Telegram Login Widget data.
|
||||
|
||||
https://core.telegram.org/widgets/login#checking-authorization
|
||||
|
||||
Args:
|
||||
data: Dictionary with Telegram login data (id, first_name, auth_date, hash, etc.)
|
||||
max_age_seconds: Maximum allowed age of auth_date (default 24 hours)
|
||||
|
||||
Returns:
|
||||
True if data is valid, False otherwise
|
||||
"""
|
||||
auth_data = data.copy()
|
||||
check_hash = auth_data.pop('hash', None)
|
||||
|
||||
if not check_hash:
|
||||
return False
|
||||
|
||||
# Check auth_date is not too old
|
||||
auth_date = auth_data.get('auth_date')
|
||||
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()
|
||||
if age > max_age_seconds:
|
||||
return False
|
||||
except (ValueError, TypeError, OSError):
|
||||
return False
|
||||
|
||||
# Build data-check-string (sorted key=value pairs, newline-separated)
|
||||
data_check_arr = [f'{k}={v}' for k, v in sorted(auth_data.items()) if v is not None]
|
||||
data_check_string = '\n'.join(data_check_arr)
|
||||
|
||||
# Create secret key from bot token using SHA256
|
||||
bot_token = settings.BOT_TOKEN
|
||||
secret_key = hashlib.sha256(bot_token.encode()).digest()
|
||||
|
||||
# Calculate expected hash
|
||||
calculated_hash = hmac.new(secret_key, data_check_string.encode(), hashlib.sha256).hexdigest()
|
||||
|
||||
return hmac.compare_digest(calculated_hash, check_hash)
|
||||
|
||||
|
||||
def validate_telegram_init_data(init_data: str, max_age_seconds: int = 86400) -> dict[str, Any] | None:
|
||||
"""
|
||||
Validate Telegram WebApp initData.
|
||||
|
||||
https://core.telegram.org/bots/webapps#validating-data-received-via-the-mini-app
|
||||
|
||||
Args:
|
||||
init_data: Raw initData string from Telegram WebApp
|
||||
max_age_seconds: Maximum allowed age of auth_date (default 24 hours)
|
||||
|
||||
Returns:
|
||||
Parsed user data dict if valid, None otherwise
|
||||
"""
|
||||
try:
|
||||
# Parse the init_data string
|
||||
parsed = dict(parse_qsl(init_data, keep_blank_values=True))
|
||||
|
||||
received_hash = parsed.pop('hash', None)
|
||||
if not received_hash:
|
||||
return None
|
||||
|
||||
# Check auth_date is not too old
|
||||
auth_date = parsed.get('auth_date')
|
||||
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()
|
||||
if age > max_age_seconds:
|
||||
return None
|
||||
except (ValueError, TypeError, OSError):
|
||||
return None
|
||||
|
||||
# Build data-check-string
|
||||
data_check_arr = [f'{k}={v}' for k, v in sorted(parsed.items())]
|
||||
data_check_string = '\n'.join(data_check_arr)
|
||||
|
||||
# Create secret key: HMAC_SHA256(bot_token, "WebAppData")
|
||||
bot_token = settings.BOT_TOKEN
|
||||
secret_key = hmac.new(b'WebAppData', bot_token.encode(), hashlib.sha256).digest()
|
||||
|
||||
# Calculate expected hash
|
||||
calculated_hash = hmac.new(secret_key, data_check_string.encode(), hashlib.sha256).hexdigest()
|
||||
|
||||
if not hmac.compare_digest(calculated_hash, received_hash):
|
||||
return None
|
||||
|
||||
# Parse user data from the validated data
|
||||
user_data_str = parsed.get('user')
|
||||
if user_data_str:
|
||||
user_data = json.loads(unquote(user_data_str))
|
||||
return user_data
|
||||
|
||||
return parsed
|
||||
|
||||
except (ValueError, TypeError, json.JSONDecodeError):
|
||||
return None
|
||||
|
||||
|
||||
def extract_telegram_user_from_init_data(init_data: str) -> dict[str, Any] | None:
|
||||
"""
|
||||
Extract and validate user info from Telegram WebApp initData.
|
||||
|
||||
Args:
|
||||
init_data: Raw initData string from Telegram WebApp
|
||||
|
||||
Returns:
|
||||
User data dict with id, first_name, last_name, username, etc. or None if invalid
|
||||
"""
|
||||
return validate_telegram_init_data(init_data)
|
||||
@@ -0,0 +1,228 @@
|
||||
"""FastAPI dependencies for cabinet module."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from aiogram import Bot
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
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__)
|
||||
|
||||
security = HTTPBearer(auto_error=False)
|
||||
|
||||
# Кешированный Bot для проверки подписки на канал
|
||||
_channel_check_bot: Bot | None = None
|
||||
|
||||
|
||||
def _get_channel_check_bot() -> Bot:
|
||||
"""Получить или создать Bot для проверки подписки на канал."""
|
||||
global _channel_check_bot
|
||||
if _channel_check_bot is None:
|
||||
_channel_check_bot = Bot(token=settings.BOT_TOKEN)
|
||||
return _channel_check_bot
|
||||
|
||||
|
||||
async def get_cabinet_db() -> AsyncSession:
|
||||
"""Get database session for cabinet operations."""
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
|
||||
async def get_current_cabinet_user(
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(security),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> User:
|
||||
"""
|
||||
Get current authenticated cabinet user from JWT token.
|
||||
|
||||
Args:
|
||||
credentials: HTTP Bearer credentials
|
||||
db: Database session
|
||||
|
||||
Returns:
|
||||
Authenticated User object
|
||||
|
||||
Raises:
|
||||
HTTPException: If token is invalid, expired, or user not found
|
||||
"""
|
||||
# Check maintenance mode first (except for admins - checked later)
|
||||
if maintenance_service.is_maintenance_active():
|
||||
# We need to check token first to see if user is admin
|
||||
pass # Will check after getting user
|
||||
|
||||
if not credentials:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail='Authentication required',
|
||||
headers={'WWW-Authenticate': 'Bearer'},
|
||||
)
|
||||
|
||||
token = credentials.credentials
|
||||
payload = get_token_payload(token, expected_type='access')
|
||||
|
||||
if not payload:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail='Invalid or expired token',
|
||||
headers={'WWW-Authenticate': 'Bearer'},
|
||||
)
|
||||
|
||||
try:
|
||||
user_id = int(payload.get('sub'))
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail='Invalid token payload',
|
||||
headers={'WWW-Authenticate': 'Bearer'},
|
||||
)
|
||||
|
||||
user = await get_user_by_id(db, user_id)
|
||||
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='User not found',
|
||||
)
|
||||
|
||||
if user.status != 'active':
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
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
|
||||
is_admin = settings.is_admin(telegram_id=user.telegram_id, email=user.email if user.email_verified else None)
|
||||
if not is_admin:
|
||||
status_info = maintenance_service.get_status_info()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail={
|
||||
'code': 'maintenance',
|
||||
'message': maintenance_service.get_maintenance_message() or 'Service is under maintenance',
|
||||
'reason': status_info.get('reason'),
|
||||
},
|
||||
)
|
||||
|
||||
# Check required channel subscription - ТОЛЬКО для Telegram юзеров
|
||||
if settings.CHANNEL_IS_REQUIRED_SUB and settings.CHANNEL_SUB_ID:
|
||||
# Пропускаем проверку для email-only юзеров (нет telegram_id)
|
||||
if user.telegram_id is not None:
|
||||
# Проверяем админа по telegram_id ИЛИ email
|
||||
is_admin = settings.is_admin(
|
||||
telegram_id=user.telegram_id, email=user.email if user.email_verified else None
|
||||
)
|
||||
if not is_admin:
|
||||
try:
|
||||
bot = _get_channel_check_bot()
|
||||
chat_member = await asyncio.wait_for(
|
||||
bot.get_chat_member(chat_id=settings.CHANNEL_SUB_ID, user_id=user.telegram_id),
|
||||
timeout=10.0,
|
||||
)
|
||||
# Не закрываем сессию - бот переиспользуется
|
||||
|
||||
if chat_member.status not in ['member', 'administrator', 'creator']:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail={
|
||||
'code': 'channel_subscription_required',
|
||||
'message': 'Please subscribe to our channel to continue',
|
||||
'channel_link': settings.CHANNEL_LINK,
|
||||
},
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except TimeoutError:
|
||||
logger.warning(f'Timeout checking channel subscription for user {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}')
|
||||
# Don't block user if check fails
|
||||
|
||||
return user
|
||||
|
||||
|
||||
async def get_optional_cabinet_user(
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(security),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> User | None:
|
||||
"""
|
||||
Optionally get current authenticated cabinet user.
|
||||
|
||||
Returns None if no valid token is provided instead of raising an exception.
|
||||
"""
|
||||
if not credentials:
|
||||
return None
|
||||
|
||||
token = credentials.credentials
|
||||
payload = get_token_payload(token, expected_type='access')
|
||||
|
||||
if not payload:
|
||||
return None
|
||||
|
||||
try:
|
||||
user_id = int(payload.get('sub'))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
user = await get_user_by_id(db, user_id)
|
||||
|
||||
if not user or user.status != 'active':
|
||||
return None
|
||||
|
||||
return user
|
||||
|
||||
|
||||
async def get_current_admin_user(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
) -> User:
|
||||
"""
|
||||
Get current authenticated admin user.
|
||||
|
||||
Checks if the user is admin by telegram_id or email.
|
||||
|
||||
Args:
|
||||
user: Authenticated User object
|
||||
|
||||
Returns:
|
||||
Authenticated admin User object
|
||||
|
||||
Raises:
|
||||
HTTPException: If user is not an admin
|
||||
"""
|
||||
is_admin = settings.is_admin(telegram_id=user.telegram_id, email=user.email if user.email_verified else None)
|
||||
if not is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Admin access required',
|
||||
)
|
||||
|
||||
return user
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Cabinet API routes."""
|
||||
|
||||
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_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
|
||||
from .admin_servers import router as admin_servers_router
|
||||
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 .auth import router as auth_router
|
||||
from .balance import router as balance_router
|
||||
from .branding import router as branding_router
|
||||
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 .polls import router as polls_router
|
||||
from .promo import router as promo_router
|
||||
from .promocode import router as promocode_router
|
||||
from .referral import router as referral_router
|
||||
from .subscription import router as subscription_router
|
||||
from .ticket_notifications import (
|
||||
admin_router as admin_ticket_notifications_router,
|
||||
router as ticket_notifications_router,
|
||||
)
|
||||
from .tickets import router as tickets_router
|
||||
from .websocket import router as websocket_router
|
||||
from .wheel import router as wheel_router
|
||||
|
||||
|
||||
# Main cabinet router
|
||||
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)
|
||||
# Notifications router MUST be before tickets router to avoid route conflict
|
||||
router.include_router(ticket_notifications_router)
|
||||
router.include_router(tickets_router)
|
||||
router.include_router(promocode_router)
|
||||
router.include_router(contests_router)
|
||||
router.include_router(polls_router)
|
||||
router.include_router(promo_router)
|
||||
router.include_router(notifications_router)
|
||||
router.include_router(info_router)
|
||||
router.include_router(branding_router)
|
||||
router.include_router(media_router)
|
||||
|
||||
# Wheel routes
|
||||
router.include_router(wheel_router)
|
||||
|
||||
# Admin routes (notifications router MUST be before tickets router to avoid route conflict)
|
||||
router.include_router(admin_ticket_notifications_router)
|
||||
router.include_router(admin_tickets_router)
|
||||
router.include_router(admin_settings_router)
|
||||
router.include_router(admin_apps_router)
|
||||
router.include_router(admin_wheel_router)
|
||||
router.include_router(admin_tariffs_router)
|
||||
router.include_router(admin_servers_router)
|
||||
router.include_router(admin_stats_router)
|
||||
router.include_router(admin_ban_system_router)
|
||||
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_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)
|
||||
|
||||
__all__ = ['router']
|
||||
@@ -0,0 +1,579 @@
|
||||
"""Admin routes for managing VPN applications in app-config.json."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
from app.services.system_settings_service import bot_configuration_service
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/apps', tags=['Cabinet Admin Apps'])
|
||||
|
||||
|
||||
# ============ Schemas ============
|
||||
|
||||
|
||||
class LocalizedText(BaseModel):
|
||||
"""Localized text for multiple languages."""
|
||||
|
||||
en: str = ''
|
||||
ru: str = ''
|
||||
zh: str | None = ''
|
||||
fa: str | None = ''
|
||||
|
||||
|
||||
class AppButton(BaseModel):
|
||||
"""Button with link and localized text."""
|
||||
|
||||
buttonLink: str
|
||||
buttonText: LocalizedText
|
||||
|
||||
|
||||
class AppStep(BaseModel):
|
||||
"""Step with description and optional buttons/title."""
|
||||
|
||||
description: LocalizedText
|
||||
buttons: list[AppButton] | None = None
|
||||
title: LocalizedText | None = None
|
||||
|
||||
|
||||
class AppDefinition(BaseModel):
|
||||
"""VPN application definition."""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
isFeatured: bool = False
|
||||
urlScheme: str
|
||||
isNeedBase64Encoding: bool | None = None
|
||||
installationStep: AppStep
|
||||
addSubscriptionStep: AppStep
|
||||
connectAndUseStep: AppStep
|
||||
additionalBeforeAddSubscriptionStep: AppStep | None = None
|
||||
additionalAfterAddSubscriptionStep: AppStep | None = None
|
||||
|
||||
|
||||
class PlatformApps(BaseModel):
|
||||
"""Apps for a specific platform."""
|
||||
|
||||
platform: str
|
||||
apps: list[AppDefinition]
|
||||
|
||||
|
||||
class AppConfigBranding(BaseModel):
|
||||
"""Branding configuration."""
|
||||
|
||||
name: str
|
||||
logoUrl: str
|
||||
supportUrl: str
|
||||
|
||||
|
||||
class AppConfigConfig(BaseModel):
|
||||
"""Top-level config section."""
|
||||
|
||||
additionalLocales: list[str]
|
||||
branding: AppConfigBranding
|
||||
|
||||
|
||||
class AppConfigResponse(BaseModel):
|
||||
"""Full app config response."""
|
||||
|
||||
config: AppConfigConfig
|
||||
platforms: dict[str, list[AppDefinition]]
|
||||
|
||||
|
||||
class CreateAppRequest(BaseModel):
|
||||
"""Request to create a new app."""
|
||||
|
||||
platform: str
|
||||
app: AppDefinition
|
||||
|
||||
|
||||
class UpdateAppRequest(BaseModel):
|
||||
"""Request to update an app."""
|
||||
|
||||
app: AppDefinition
|
||||
|
||||
|
||||
class ReorderAppsRequest(BaseModel):
|
||||
"""Request to reorder apps in a platform."""
|
||||
|
||||
app_ids: list[str]
|
||||
|
||||
|
||||
class UpdateBrandingRequest(BaseModel):
|
||||
"""Request to update branding."""
|
||||
|
||||
branding: AppConfigBranding
|
||||
|
||||
|
||||
# ============ Helpers ============
|
||||
|
||||
|
||||
def _get_config_path() -> Path:
|
||||
"""Get path to app-config.json."""
|
||||
return Path(settings.get_app_config_path())
|
||||
|
||||
|
||||
def _load_config() -> dict:
|
||||
"""Load app config from file."""
|
||||
config_path = _get_config_path()
|
||||
if not config_path.exists():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f'App config file not found: {config_path}',
|
||||
)
|
||||
|
||||
try:
|
||||
with open(config_path, encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
except json.JSONDecodeError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f'Failed to parse app config: {e}',
|
||||
)
|
||||
|
||||
|
||||
def _save_config(config: dict) -> None:
|
||||
"""Save app config to file."""
|
||||
config_path = _get_config_path()
|
||||
|
||||
try:
|
||||
with open(config_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(config, f, indent=2, ensure_ascii=False)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f'Failed to save app config: {e}',
|
||||
)
|
||||
|
||||
|
||||
VALID_PLATFORMS = ['ios', 'android', 'macos', 'windows', 'linux', 'androidTV', 'appleTV']
|
||||
|
||||
|
||||
# ============ Routes ============
|
||||
|
||||
|
||||
@router.get('', response_model=AppConfigResponse)
|
||||
async def get_app_config(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""Get full app configuration."""
|
||||
config = _load_config()
|
||||
return config
|
||||
|
||||
|
||||
@router.get('/platforms', response_model=list[str])
|
||||
async def get_platforms(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""Get list of available platforms."""
|
||||
return VALID_PLATFORMS
|
||||
|
||||
|
||||
@router.get('/platforms/{platform}', response_model=list[AppDefinition])
|
||||
async def get_platform_apps(
|
||||
platform: str,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""Get apps for a specific platform."""
|
||||
if platform not in VALID_PLATFORMS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Invalid platform: {platform}. Valid platforms: {VALID_PLATFORMS}',
|
||||
)
|
||||
|
||||
config = _load_config()
|
||||
platforms = config.get('platforms', {})
|
||||
return platforms.get(platform, [])
|
||||
|
||||
|
||||
@router.post('/platforms/{platform}', response_model=AppDefinition)
|
||||
async def create_app(
|
||||
platform: str,
|
||||
request: CreateAppRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""Create a new app for a platform."""
|
||||
if platform not in VALID_PLATFORMS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Invalid platform: {platform}',
|
||||
)
|
||||
|
||||
config = _load_config()
|
||||
platforms = config.get('platforms', {})
|
||||
|
||||
if platform not in platforms:
|
||||
platforms[platform] = []
|
||||
|
||||
# Check if app with same ID already exists
|
||||
existing_ids = [app.get('id') for app in platforms[platform]]
|
||||
if request.app.id in existing_ids:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"App with ID '{request.app.id}' already exists in {platform}",
|
||||
)
|
||||
|
||||
# Add new app
|
||||
app_dict = request.app.model_dump(exclude_none=True)
|
||||
platforms[platform].append(app_dict)
|
||||
config['platforms'] = platforms
|
||||
|
||||
_save_config(config)
|
||||
logger.info(f"Admin {admin.id} created app '{request.app.id}' for platform '{platform}'")
|
||||
|
||||
return request.app
|
||||
|
||||
|
||||
@router.put('/platforms/{platform}/{app_id}', response_model=AppDefinition)
|
||||
async def update_app(
|
||||
platform: str,
|
||||
app_id: str,
|
||||
request: UpdateAppRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""Update an existing app."""
|
||||
if platform not in VALID_PLATFORMS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Invalid platform: {platform}',
|
||||
)
|
||||
|
||||
config = _load_config()
|
||||
platforms = config.get('platforms', {})
|
||||
apps = platforms.get(platform, [])
|
||||
|
||||
# Find and update app
|
||||
app_index = None
|
||||
for i, app in enumerate(apps):
|
||||
if app.get('id') == app_id:
|
||||
app_index = i
|
||||
break
|
||||
|
||||
if app_index is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"App '{app_id}' not found in platform '{platform}'",
|
||||
)
|
||||
|
||||
# Update app
|
||||
app_dict = request.app.model_dump(exclude_none=True)
|
||||
apps[app_index] = app_dict
|
||||
platforms[platform] = apps
|
||||
config['platforms'] = platforms
|
||||
|
||||
_save_config(config)
|
||||
logger.info(f"Admin {admin.id} updated app '{app_id}' in platform '{platform}'")
|
||||
|
||||
return request.app
|
||||
|
||||
|
||||
@router.delete('/platforms/{platform}/{app_id}')
|
||||
async def delete_app(
|
||||
platform: str,
|
||||
app_id: str,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""Delete an app from a platform."""
|
||||
if platform not in VALID_PLATFORMS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Invalid platform: {platform}',
|
||||
)
|
||||
|
||||
config = _load_config()
|
||||
platforms = config.get('platforms', {})
|
||||
apps = platforms.get(platform, [])
|
||||
|
||||
# Find and remove app
|
||||
original_length = len(apps)
|
||||
apps = [app for app in apps if app.get('id') != app_id]
|
||||
|
||||
if len(apps) == original_length:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"App '{app_id}' not found in platform '{platform}'",
|
||||
)
|
||||
|
||||
platforms[platform] = apps
|
||||
config['platforms'] = platforms
|
||||
|
||||
_save_config(config)
|
||||
logger.info(f"Admin {admin.id} deleted app '{app_id}' from platform '{platform}'")
|
||||
|
||||
return {'status': 'deleted', 'app_id': app_id}
|
||||
|
||||
|
||||
@router.post('/platforms/{platform}/reorder')
|
||||
async def reorder_apps(
|
||||
platform: str,
|
||||
request: ReorderAppsRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""Reorder apps in a platform."""
|
||||
if platform not in VALID_PLATFORMS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Invalid platform: {platform}',
|
||||
)
|
||||
|
||||
config = _load_config()
|
||||
platforms = config.get('platforms', {})
|
||||
apps = platforms.get(platform, [])
|
||||
|
||||
# Create a map of apps by ID
|
||||
apps_map = {app.get('id'): app for app in apps}
|
||||
|
||||
# Verify all IDs exist
|
||||
for app_id in request.app_ids:
|
||||
if app_id not in apps_map:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"App '{app_id}' not found in platform '{platform}'",
|
||||
)
|
||||
|
||||
# Reorder apps
|
||||
reordered_apps = [apps_map[app_id] for app_id in request.app_ids]
|
||||
|
||||
# Add any apps that weren't in the reorder list (shouldn't happen but just in case)
|
||||
for app in apps:
|
||||
if app.get('id') not in request.app_ids:
|
||||
reordered_apps.append(app)
|
||||
|
||||
platforms[platform] = reordered_apps
|
||||
config['platforms'] = platforms
|
||||
|
||||
_save_config(config)
|
||||
logger.info(f"Admin {admin.id} reordered apps in platform '{platform}'")
|
||||
|
||||
return {'status': 'reordered', 'order': request.app_ids}
|
||||
|
||||
|
||||
@router.put('/branding', response_model=AppConfigBranding)
|
||||
async def update_branding(
|
||||
request: UpdateBrandingRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""Update branding configuration."""
|
||||
config = _load_config()
|
||||
|
||||
if 'config' not in config:
|
||||
config['config'] = {}
|
||||
|
||||
config['config']['branding'] = request.branding.model_dump()
|
||||
|
||||
_save_config(config)
|
||||
logger.info(f'Admin {admin.id} updated branding')
|
||||
|
||||
return request.branding
|
||||
|
||||
|
||||
@router.get('/branding', response_model=AppConfigBranding)
|
||||
async def get_branding(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""Get branding configuration."""
|
||||
config = _load_config()
|
||||
branding = config.get('config', {}).get('branding', {})
|
||||
return branding
|
||||
|
||||
|
||||
@router.post('/platforms/{platform}/copy/{app_id}')
|
||||
async def copy_app_to_platform(
|
||||
platform: str,
|
||||
app_id: str,
|
||||
target_platform: str,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""Copy an app from one platform to another."""
|
||||
if platform not in VALID_PLATFORMS or target_platform not in VALID_PLATFORMS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Invalid platform(s)',
|
||||
)
|
||||
|
||||
config = _load_config()
|
||||
platforms = config.get('platforms', {})
|
||||
source_apps = platforms.get(platform, [])
|
||||
|
||||
# Find source app
|
||||
source_app = None
|
||||
for app in source_apps:
|
||||
if app.get('id') == app_id:
|
||||
source_app = app.copy()
|
||||
break
|
||||
|
||||
if not source_app:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"App '{app_id}' not found in platform '{platform}'",
|
||||
)
|
||||
|
||||
# Generate new ID for copied app
|
||||
import time
|
||||
|
||||
new_id = f'{app_id}-copy-{int(time.time())}'
|
||||
source_app['id'] = new_id
|
||||
|
||||
# Add to target platform
|
||||
if target_platform not in platforms:
|
||||
platforms[target_platform] = []
|
||||
|
||||
platforms[target_platform].append(source_app)
|
||||
config['platforms'] = platforms
|
||||
|
||||
_save_config(config)
|
||||
logger.info(f"Admin {admin.id} copied app '{app_id}' from '{platform}' to '{target_platform}' as '{new_id}'")
|
||||
|
||||
return {'status': 'copied', 'new_id': new_id, 'target_platform': target_platform}
|
||||
|
||||
|
||||
# ============ RemnaWave Config Routes ============
|
||||
|
||||
|
||||
class RemnaWaveConfigStatus(BaseModel):
|
||||
"""Status of RemnaWave config integration."""
|
||||
|
||||
enabled: bool
|
||||
config_uuid: str | None = None
|
||||
|
||||
|
||||
class UpdateRemnaWaveUuidRequest(BaseModel):
|
||||
"""Request to update RemnaWave config UUID."""
|
||||
|
||||
uuid: str | None = None
|
||||
|
||||
|
||||
def _get_remnawave_config_uuid() -> str | None:
|
||||
"""Get RemnaWave config UUID from system settings or env."""
|
||||
try:
|
||||
return bot_configuration_service.get_current_value('CABINET_REMNA_SUB_CONFIG')
|
||||
except Exception:
|
||||
return settings.CABINET_REMNA_SUB_CONFIG
|
||||
|
||||
|
||||
@router.get('/remnawave/status', response_model=RemnaWaveConfigStatus)
|
||||
async def get_remnawave_config_status(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""Get RemnaWave config integration status."""
|
||||
config_uuid = _get_remnawave_config_uuid()
|
||||
return RemnaWaveConfigStatus(
|
||||
enabled=bool(config_uuid),
|
||||
config_uuid=config_uuid,
|
||||
)
|
||||
|
||||
|
||||
@router.put('/remnawave/uuid', response_model=RemnaWaveConfigStatus)
|
||||
async def set_remnawave_config_uuid(
|
||||
request: UpdateRemnaWaveUuidRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Set RemnaWave subscription config UUID."""
|
||||
uuid_value = request.uuid.strip() if request.uuid else None
|
||||
|
||||
# Validate UUID format if provided
|
||||
if uuid_value:
|
||||
import re
|
||||
|
||||
uuid_pattern = re.compile(r'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$')
|
||||
if not uuid_pattern.match(uuid_value):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Invalid UUID format',
|
||||
)
|
||||
|
||||
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}'")
|
||||
except Exception as e:
|
||||
logger.error(f'Error saving RemnaWave config UUID: {e}')
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to save configuration',
|
||||
)
|
||||
|
||||
return RemnaWaveConfigStatus(
|
||||
enabled=bool(uuid_value),
|
||||
config_uuid=uuid_value,
|
||||
)
|
||||
|
||||
|
||||
@router.get('/remnawave/config')
|
||||
async def get_remnawave_subscription_config(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""
|
||||
Fetch subscription page config from RemnaWave panel.
|
||||
Uses CABINET_REMNA_SUB_CONFIG setting for the config UUID.
|
||||
"""
|
||||
config_uuid = _get_remnawave_config_uuid()
|
||||
if not config_uuid:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='CABINET_REMNA_SUB_CONFIG is not configured',
|
||||
)
|
||||
|
||||
try:
|
||||
service = RemnaWaveService()
|
||||
async with service.get_api_client() as api:
|
||||
config = await api.get_subscription_page_config(config_uuid)
|
||||
if not config:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Subscription config '{config_uuid}' not found in RemnaWave",
|
||||
)
|
||||
|
||||
# Return the raw config data from RemnaWave
|
||||
return {
|
||||
'uuid': config.uuid,
|
||||
'name': config.name,
|
||||
'view_position': config.view_position,
|
||||
'config': config.config,
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f'Error fetching RemnaWave config: {e}')
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f'Failed to fetch config from RemnaWave: {e!s}',
|
||||
)
|
||||
|
||||
|
||||
@router.get('/remnawave/configs')
|
||||
async def list_remnawave_subscription_configs(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""List available subscription page configs from RemnaWave panel."""
|
||||
try:
|
||||
service = RemnaWaveService()
|
||||
async with service.get_api_client() as api:
|
||||
configs = await api.get_subscription_page_configs()
|
||||
return [
|
||||
{
|
||||
'uuid': c.uuid,
|
||||
'name': c.name,
|
||||
'view_position': c.view_position,
|
||||
}
|
||||
for c in configs
|
||||
]
|
||||
except Exception as e:
|
||||
logger.error(f'Error listing RemnaWave configs: {e}')
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f'Failed to fetch configs from RemnaWave: {e!s}',
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,726 @@
|
||||
"""Admin routes for broadcasts in cabinet."""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import distinct, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import BroadcastHistory, Subscription, SubscriptionStatus, Tariff, User
|
||||
from app.handlers.admin.messages import get_target_users_count
|
||||
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
|
||||
from ..schemas.broadcasts import (
|
||||
BroadcastButton,
|
||||
BroadcastButtonsResponse,
|
||||
BroadcastCreateRequest,
|
||||
BroadcastFilter,
|
||||
BroadcastFiltersResponse,
|
||||
BroadcastListResponse,
|
||||
BroadcastPreviewRequest,
|
||||
BroadcastPreviewResponse,
|
||||
BroadcastResponse,
|
||||
BroadcastTariffsResponse,
|
||||
CombinedBroadcastCreateRequest,
|
||||
EmailFilterItem,
|
||||
EmailFiltersResponse,
|
||||
EmailPreviewRequest,
|
||||
EmailPreviewResponse,
|
||||
TariffFilter,
|
||||
TariffForBroadcast,
|
||||
)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/broadcasts', tags=['Cabinet Admin Broadcasts'])
|
||||
|
||||
|
||||
# ============ Filter Labels ============
|
||||
|
||||
FILTER_LABELS = {
|
||||
'all': 'Все пользователи',
|
||||
'active': 'Активные подписки',
|
||||
'trial': 'Триальные',
|
||||
'no': 'Без подписки',
|
||||
'expiring': 'Истекают (3 дня)',
|
||||
'expired': 'Истекшие',
|
||||
'zero': 'Нулевой трафик',
|
||||
'active_zero': 'Активные с нулевым трафиком',
|
||||
'trial_zero': 'Триальные с нулевым трафиком',
|
||||
}
|
||||
|
||||
FILTER_GROUPS = {
|
||||
'all': 'basic',
|
||||
'active': 'subscription',
|
||||
'trial': 'subscription',
|
||||
'no': 'subscription',
|
||||
'expiring': 'subscription',
|
||||
'expired': 'subscription',
|
||||
'zero': 'traffic',
|
||||
'active_zero': 'traffic',
|
||||
'trial_zero': 'traffic',
|
||||
}
|
||||
|
||||
CUSTOM_FILTER_LABELS = {
|
||||
'custom_today': 'Регистрация сегодня',
|
||||
'custom_week': 'Регистрация за неделю',
|
||||
'custom_month': 'Регистрация за месяц',
|
||||
'custom_active_today': 'Активны сегодня',
|
||||
'custom_inactive_week': 'Неактивны 7+ дней',
|
||||
'custom_inactive_month': 'Неактивны 30+ дней',
|
||||
'custom_referrals': 'Пришли по рефералу',
|
||||
'custom_direct': 'Прямая регистрация',
|
||||
}
|
||||
|
||||
CUSTOM_FILTER_GROUPS = {
|
||||
'custom_today': 'registration',
|
||||
'custom_week': 'registration',
|
||||
'custom_month': 'registration',
|
||||
'custom_active_today': 'activity',
|
||||
'custom_inactive_week': 'activity',
|
||||
'custom_inactive_month': 'activity',
|
||||
'custom_referrals': 'source',
|
||||
'custom_direct': 'source',
|
||||
}
|
||||
|
||||
|
||||
# ============ 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."""
|
||||
progress = 0.0
|
||||
if broadcast.total_count > 0:
|
||||
progress = round((broadcast.sent_count + broadcast.failed_count) / broadcast.total_count * 100, 1)
|
||||
|
||||
return BroadcastResponse(
|
||||
id=broadcast.id,
|
||||
target_type=broadcast.target_type,
|
||||
message_text=broadcast.message_text,
|
||||
has_media=broadcast.has_media,
|
||||
media_type=broadcast.media_type,
|
||||
media_file_id=broadcast.media_file_id,
|
||||
media_caption=broadcast.media_caption,
|
||||
total_count=broadcast.total_count,
|
||||
sent_count=broadcast.sent_count,
|
||||
failed_count=broadcast.failed_count,
|
||||
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(
|
||||
select(Subscription.tariff_id, func.count(func.distinct(Subscription.user_id)).label('count'))
|
||||
.join(User, User.id == Subscription.user_id)
|
||||
.where(
|
||||
User.status == 'active',
|
||||
Subscription.status == SubscriptionStatus.ACTIVE.value,
|
||||
)
|
||||
.group_by(Subscription.tariff_id)
|
||||
)
|
||||
return {row.tariff_id: row.count for row in result.all()}
|
||||
|
||||
|
||||
def _validate_target(target: str, tariff_ids: set) -> bool:
|
||||
"""Validate target value."""
|
||||
if target in FILTER_LABELS:
|
||||
return True
|
||||
if target in CUSTOM_FILTER_LABELS:
|
||||
return True
|
||||
if target.startswith('tariff_'):
|
||||
try:
|
||||
tariff_id = int(target.split('_')[1])
|
||||
return tariff_id in tariff_ids
|
||||
except (ValueError, IndexError):
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
def _validate_buttons(buttons: list[str]) -> bool:
|
||||
"""Validate button keys."""
|
||||
return all(button in BROADCAST_BUTTONS for button in buttons)
|
||||
|
||||
|
||||
# ============ Endpoints ============
|
||||
|
||||
|
||||
@router.get('/filters', response_model=BroadcastFiltersResponse)
|
||||
async def get_filters(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> BroadcastFiltersResponse:
|
||||
"""Get all available filters with user counts."""
|
||||
# Basic filters
|
||||
filters = []
|
||||
for key, label in FILTER_LABELS.items():
|
||||
try:
|
||||
count = await get_target_users_count(db, key)
|
||||
except Exception as e:
|
||||
logger.warning(f'Failed to get count for filter {key}: {e}')
|
||||
count = 0
|
||||
filters.append(
|
||||
BroadcastFilter(
|
||||
key=key,
|
||||
label=label,
|
||||
count=count,
|
||||
group=FILTER_GROUPS.get(key),
|
||||
)
|
||||
)
|
||||
|
||||
# Custom filters
|
||||
custom_filters = []
|
||||
for key, label in CUSTOM_FILTER_LABELS.items():
|
||||
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}')
|
||||
count = 0
|
||||
custom_filters.append(
|
||||
BroadcastFilter(
|
||||
key=key,
|
||||
label=label,
|
||||
count=count,
|
||||
group=CUSTOM_FILTER_GROUPS.get(key),
|
||||
)
|
||||
)
|
||||
|
||||
# Tariff filters
|
||||
tariff_counts = await _get_tariff_user_counts(db)
|
||||
result = await db.execute(select(Tariff).where(Tariff.is_active == True).order_by(Tariff.name))
|
||||
tariffs = result.scalars().all()
|
||||
|
||||
tariff_filters = []
|
||||
for tariff in tariffs:
|
||||
tariff_filters.append(
|
||||
TariffFilter(
|
||||
key=f'tariff_{tariff.id}',
|
||||
label=tariff.name,
|
||||
tariff_id=tariff.id,
|
||||
count=tariff_counts.get(tariff.id, 0),
|
||||
)
|
||||
)
|
||||
|
||||
return BroadcastFiltersResponse(
|
||||
filters=filters,
|
||||
tariff_filters=tariff_filters,
|
||||
custom_filters=custom_filters,
|
||||
)
|
||||
|
||||
|
||||
@router.get('/tariffs', response_model=BroadcastTariffsResponse)
|
||||
async def get_tariffs(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> BroadcastTariffsResponse:
|
||||
"""Get tariffs for broadcast filtering."""
|
||||
tariff_counts = await _get_tariff_user_counts(db)
|
||||
result = await db.execute(select(Tariff).where(Tariff.is_active == True).order_by(Tariff.name))
|
||||
tariffs = result.scalars().all()
|
||||
|
||||
return BroadcastTariffsResponse(
|
||||
tariffs=[
|
||||
TariffForBroadcast(
|
||||
id=t.id,
|
||||
name=t.name,
|
||||
filter_key=f'tariff_{t.id}',
|
||||
active_users_count=tariff_counts.get(t.id, 0),
|
||||
)
|
||||
for t in tariffs
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@router.get('/buttons', response_model=BroadcastButtonsResponse)
|
||||
async def get_buttons(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
) -> BroadcastButtonsResponse:
|
||||
"""Get available buttons for broadcasts."""
|
||||
default_buttons = set(DEFAULT_BROADCAST_BUTTONS)
|
||||
buttons = []
|
||||
for key, config in BROADCAST_BUTTONS.items():
|
||||
buttons.append(
|
||||
BroadcastButton(
|
||||
key=key,
|
||||
label=config.get('default_text', key),
|
||||
default=key in default_buttons,
|
||||
)
|
||||
)
|
||||
return BroadcastButtonsResponse(buttons=buttons)
|
||||
|
||||
|
||||
@router.post('/preview', response_model=BroadcastPreviewResponse)
|
||||
async def preview_broadcast(
|
||||
request: BroadcastPreviewRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> BroadcastPreviewResponse:
|
||||
"""Preview broadcast recipients count."""
|
||||
# Get tariff IDs for validation
|
||||
result = await db.execute(select(Tariff.id))
|
||||
tariff_ids = {row[0] for row in result.all()}
|
||||
|
||||
if not _validate_target(request.target, tariff_ids):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Invalid target: {request.target}',
|
||||
)
|
||||
|
||||
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}')
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to count recipients',
|
||||
)
|
||||
|
||||
return BroadcastPreviewResponse(target=request.target, count=count)
|
||||
|
||||
|
||||
@router.post('', response_model=BroadcastResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_broadcast(
|
||||
request: BroadcastCreateRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> BroadcastResponse:
|
||||
"""Create and start a broadcast."""
|
||||
# Validate target
|
||||
result = await db.execute(select(Tariff.id))
|
||||
tariff_ids = {row[0] for row in result.all()}
|
||||
|
||||
if not _validate_target(request.target, tariff_ids):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Invalid target: {request.target}',
|
||||
)
|
||||
|
||||
# Validate buttons
|
||||
if not _validate_buttons(request.selected_buttons):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Invalid button key',
|
||||
)
|
||||
|
||||
message_text = request.message_text.strip()
|
||||
if not message_text:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Message text must not be empty',
|
||||
)
|
||||
|
||||
media_payload = request.media
|
||||
|
||||
# Create broadcast record
|
||||
broadcast = BroadcastHistory(
|
||||
target_type=request.target,
|
||||
message_text=message_text,
|
||||
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.username or f'Admin #{admin.id}',
|
||||
)
|
||||
db.add(broadcast)
|
||||
await db.commit()
|
||||
await db.refresh(broadcast)
|
||||
|
||||
# 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 message_text,
|
||||
)
|
||||
|
||||
# Create broadcast config
|
||||
config = BroadcastConfig(
|
||||
target=request.target,
|
||||
message_text=message_text,
|
||||
selected_buttons=request.selected_buttons,
|
||||
media=media_config,
|
||||
initiator_name=admin.username or f'Admin #{admin.id}',
|
||||
)
|
||||
|
||||
# Start 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}'")
|
||||
|
||||
return _serialize_broadcast(broadcast)
|
||||
|
||||
|
||||
@router.get('', response_model=BroadcastListResponse)
|
||||
async def list_broadcasts(
|
||||
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),
|
||||
) -> BroadcastListResponse:
|
||||
"""Get list of broadcasts with pagination."""
|
||||
total = await db.scalar(select(func.count(BroadcastHistory.id))) or 0
|
||||
|
||||
result = await db.execute(
|
||||
select(BroadcastHistory).order_by(BroadcastHistory.created_at.desc()).offset(offset).limit(limit)
|
||||
)
|
||||
broadcasts = result.scalars().all()
|
||||
|
||||
return BroadcastListResponse(
|
||||
items=[_serialize_broadcast(b) for b in broadcasts],
|
||||
total=int(total),
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
|
||||
# ============ 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(f'Failed to get count for email filter {key}: {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(f'Failed to get email count for target {request.target}: {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(f"Admin {admin.id} created {request.channel} broadcast {broadcast.id} for target '{request.target}'")
|
||||
|
||||
return _serialize_broadcast(broadcast)
|
||||
|
||||
|
||||
@router.get('/{broadcast_id}', response_model=BroadcastResponse)
|
||||
async def get_broadcast(
|
||||
broadcast_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> BroadcastResponse:
|
||||
"""Get broadcast details."""
|
||||
broadcast = await db.get(BroadcastHistory, broadcast_id)
|
||||
if not broadcast:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Broadcast not found',
|
||||
)
|
||||
return _serialize_broadcast(broadcast)
|
||||
|
||||
|
||||
@router.post('/{broadcast_id}/stop', response_model=BroadcastResponse)
|
||||
async def stop_broadcast(
|
||||
broadcast_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> BroadcastResponse:
|
||||
"""Stop a running broadcast (telegram or email)."""
|
||||
broadcast = await db.get(BroadcastHistory, broadcast_id)
|
||||
if not broadcast:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Broadcast not found',
|
||||
)
|
||||
|
||||
if broadcast.status not in {'queued', 'in_progress', 'cancelling'}:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Broadcast is not running',
|
||||
)
|
||||
|
||||
# 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()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(broadcast)
|
||||
|
||||
logger.info(f'Admin {admin.id} stopped broadcast {broadcast_id}')
|
||||
|
||||
return _serialize_broadcast(broadcast)
|
||||
@@ -0,0 +1,253 @@
|
||||
"""Admin routes for per-section cabinet button style configuration."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
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 = logging.getLogger(__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 %s updated button styles for sections: %s', admin.telegram_id, 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 %s reset button styles to defaults', admin.telegram_id)
|
||||
|
||||
return _build_response(DEFAULT_BUTTON_STYLES)
|
||||
@@ -0,0 +1,507 @@
|
||||
"""Admin routes for managing advertising campaigns in cabinet."""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.campaign import (
|
||||
create_campaign,
|
||||
delete_campaign,
|
||||
get_campaign_by_id,
|
||||
get_campaign_by_start_parameter,
|
||||
get_campaign_statistics,
|
||||
get_campaigns_count,
|
||||
get_campaigns_list,
|
||||
get_campaigns_overview,
|
||||
update_campaign,
|
||||
)
|
||||
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 (
|
||||
AdvertisingCampaignRegistration,
|
||||
Subscription,
|
||||
Tariff,
|
||||
User,
|
||||
)
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
from ..schemas.campaigns import (
|
||||
CampaignCreateRequest,
|
||||
CampaignDetailResponse,
|
||||
CampaignListItem,
|
||||
CampaignListResponse,
|
||||
CampaignRegistrationItem,
|
||||
CampaignRegistrationsResponse,
|
||||
CampaignsOverviewResponse,
|
||||
CampaignStatisticsResponse,
|
||||
CampaignToggleResponse,
|
||||
CampaignUpdateRequest,
|
||||
ServerSquadInfo,
|
||||
TariffInfo,
|
||||
)
|
||||
from ..schemas.tariffs import TariffListItem
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/campaigns', tags=['Cabinet Admin Campaigns'])
|
||||
|
||||
|
||||
def _get_deep_link(start_parameter: str) -> str:
|
||||
"""Generate deep link for campaign."""
|
||||
bot_username = settings.get_bot_username()
|
||||
if bot_username:
|
||||
return f'https://t.me/{bot_username}?start={start_parameter}'
|
||||
return f'?start={start_parameter}'
|
||||
|
||||
|
||||
@router.get('/overview', response_model=CampaignsOverviewResponse)
|
||||
async def get_overview(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get campaigns overview statistics."""
|
||||
overview = await get_campaigns_overview(db)
|
||||
|
||||
# Count tariff bonuses
|
||||
tariff_result = await db.execute(
|
||||
select(func.count(AdvertisingCampaignRegistration.id)).where(
|
||||
AdvertisingCampaignRegistration.bonus_type == 'tariff'
|
||||
)
|
||||
)
|
||||
tariff_count = tariff_result.scalar() or 0
|
||||
|
||||
return CampaignsOverviewResponse(
|
||||
total=overview['total'],
|
||||
active=overview['active'],
|
||||
inactive=overview['inactive'],
|
||||
total_registrations=overview['registrations'],
|
||||
total_balance_issued_kopeks=overview['balance_total'],
|
||||
total_balance_issued_rubles=overview['balance_total'] / 100,
|
||||
total_subscription_issued=overview['subscription_total'],
|
||||
total_tariff_issued=tariff_count,
|
||||
)
|
||||
|
||||
|
||||
@router.get('/available-servers', response_model=list[ServerSquadInfo])
|
||||
async def get_available_servers(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get list of available server squads for campaign subscription bonus."""
|
||||
servers, _ = await get_all_server_squads(db, available_only=False)
|
||||
return [
|
||||
ServerSquadInfo(
|
||||
id=server.id,
|
||||
squad_uuid=server.squad_uuid,
|
||||
display_name=server.display_name,
|
||||
country_code=server.country_code,
|
||||
)
|
||||
for server in servers
|
||||
]
|
||||
|
||||
|
||||
@router.get('/available-tariffs', response_model=list[TariffListItem])
|
||||
async def get_available_tariffs(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get list of available tariffs for campaign tariff bonus."""
|
||||
tariffs = await get_all_tariffs(db, include_inactive=False)
|
||||
return [
|
||||
TariffListItem(
|
||||
id=tariff.id,
|
||||
name=tariff.name,
|
||||
description=tariff.description,
|
||||
is_active=tariff.is_active,
|
||||
is_trial_available=tariff.is_trial_available,
|
||||
is_daily=tariff.is_daily,
|
||||
daily_price_kopeks=tariff.daily_price_kopeks or 0,
|
||||
allow_traffic_topup=tariff.allow_traffic_topup,
|
||||
traffic_limit_gb=tariff.traffic_limit_gb,
|
||||
device_limit=tariff.device_limit,
|
||||
tier_level=tariff.tier_level,
|
||||
display_order=tariff.display_order,
|
||||
servers_count=len(tariff.allowed_squads or []),
|
||||
subscriptions_count=0,
|
||||
created_at=tariff.created_at,
|
||||
)
|
||||
for tariff in tariffs
|
||||
]
|
||||
|
||||
|
||||
@router.get('', response_model=CampaignListResponse)
|
||||
async def list_campaigns(
|
||||
include_inactive: bool = True,
|
||||
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),
|
||||
):
|
||||
"""Get list of all campaigns."""
|
||||
campaigns = await get_campaigns_list(db, offset=offset, limit=limit, include_inactive=include_inactive)
|
||||
total = await get_campaigns_count(db)
|
||||
|
||||
items = []
|
||||
for campaign in campaigns:
|
||||
# Get quick stats
|
||||
stats = await get_campaign_statistics(db, campaign.id)
|
||||
items.append(
|
||||
CampaignListItem(
|
||||
id=campaign.id,
|
||||
name=campaign.name,
|
||||
start_parameter=campaign.start_parameter,
|
||||
bonus_type=campaign.bonus_type,
|
||||
is_active=campaign.is_active,
|
||||
registrations_count=stats['registrations'],
|
||||
total_revenue_kopeks=stats['total_revenue_kopeks'],
|
||||
conversion_rate=stats['conversion_rate'],
|
||||
created_at=campaign.created_at,
|
||||
)
|
||||
)
|
||||
|
||||
return CampaignListResponse(campaigns=items, total=total)
|
||||
|
||||
|
||||
@router.get('/{campaign_id}', response_model=CampaignDetailResponse)
|
||||
async def get_campaign(
|
||||
campaign_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get detailed campaign info."""
|
||||
campaign = await get_campaign_by_id(db, campaign_id)
|
||||
if not campaign:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Campaign not found',
|
||||
)
|
||||
|
||||
tariff_info = None
|
||||
if campaign.tariff:
|
||||
tariff_info = TariffInfo(
|
||||
id=campaign.tariff.id,
|
||||
name=campaign.tariff.name,
|
||||
)
|
||||
|
||||
return CampaignDetailResponse(
|
||||
id=campaign.id,
|
||||
name=campaign.name,
|
||||
start_parameter=campaign.start_parameter,
|
||||
bonus_type=campaign.bonus_type,
|
||||
is_active=campaign.is_active,
|
||||
balance_bonus_kopeks=campaign.balance_bonus_kopeks or 0,
|
||||
balance_bonus_rubles=(campaign.balance_bonus_kopeks or 0) / 100,
|
||||
subscription_duration_days=campaign.subscription_duration_days,
|
||||
subscription_traffic_gb=campaign.subscription_traffic_gb,
|
||||
subscription_device_limit=campaign.subscription_device_limit,
|
||||
subscription_squads=campaign.subscription_squads or [],
|
||||
tariff_id=campaign.tariff_id,
|
||||
tariff_duration_days=campaign.tariff_duration_days,
|
||||
tariff=tariff_info,
|
||||
created_by=campaign.created_by,
|
||||
created_at=campaign.created_at,
|
||||
updated_at=campaign.updated_at,
|
||||
deep_link=_get_deep_link(campaign.start_parameter),
|
||||
)
|
||||
|
||||
|
||||
@router.get('/{campaign_id}/stats', response_model=CampaignStatisticsResponse)
|
||||
async def get_campaign_stats(
|
||||
campaign_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get detailed campaign statistics."""
|
||||
campaign = await get_campaign_by_id(db, campaign_id)
|
||||
if not campaign:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Campaign not found',
|
||||
)
|
||||
|
||||
stats = await get_campaign_statistics(db, campaign_id)
|
||||
|
||||
return CampaignStatisticsResponse(
|
||||
id=campaign.id,
|
||||
name=campaign.name,
|
||||
start_parameter=campaign.start_parameter,
|
||||
bonus_type=campaign.bonus_type,
|
||||
is_active=campaign.is_active,
|
||||
registrations=stats['registrations'],
|
||||
balance_issued_kopeks=stats['balance_issued'],
|
||||
balance_issued_rubles=stats['balance_issued'] / 100,
|
||||
subscription_issued=stats['subscription_issued'],
|
||||
last_registration=stats['last_registration'],
|
||||
total_revenue_kopeks=stats['total_revenue_kopeks'],
|
||||
total_revenue_rubles=stats['total_revenue_kopeks'] / 100,
|
||||
avg_revenue_per_user_kopeks=stats['avg_revenue_per_user_kopeks'],
|
||||
avg_revenue_per_user_rubles=stats['avg_revenue_per_user_kopeks'] / 100,
|
||||
avg_first_payment_kopeks=stats['avg_first_payment_kopeks'],
|
||||
avg_first_payment_rubles=stats['avg_first_payment_kopeks'] / 100,
|
||||
trial_users_count=stats['trial_users_count'],
|
||||
active_trials_count=stats['active_trials_count'],
|
||||
conversion_count=stats['conversion_count'],
|
||||
paid_users_count=stats['paid_users_count'],
|
||||
conversion_rate=stats['conversion_rate'],
|
||||
trial_conversion_rate=stats['trial_conversion_rate'],
|
||||
deep_link=_get_deep_link(campaign.start_parameter),
|
||||
)
|
||||
|
||||
|
||||
@router.get('/{campaign_id}/registrations', response_model=CampaignRegistrationsResponse)
|
||||
async def get_campaign_registrations(
|
||||
campaign_id: int,
|
||||
page: int = Query(1, ge=1),
|
||||
per_page: int = Query(50, ge=1, le=100),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get list of users registered through campaign."""
|
||||
campaign = await get_campaign_by_id(db, campaign_id)
|
||||
if not campaign:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Campaign not found',
|
||||
)
|
||||
|
||||
offset = (page - 1) * per_page
|
||||
|
||||
# Get registrations with user info
|
||||
result = await db.execute(
|
||||
select(AdvertisingCampaignRegistration, User)
|
||||
.join(User, AdvertisingCampaignRegistration.user_id == User.id)
|
||||
.where(AdvertisingCampaignRegistration.campaign_id == campaign_id)
|
||||
.order_by(AdvertisingCampaignRegistration.created_at.desc())
|
||||
.offset(offset)
|
||||
.limit(per_page)
|
||||
)
|
||||
rows = result.all()
|
||||
|
||||
# Count total
|
||||
count_result = await db.execute(
|
||||
select(func.count(AdvertisingCampaignRegistration.id)).where(
|
||||
AdvertisingCampaignRegistration.campaign_id == campaign_id
|
||||
)
|
||||
)
|
||||
total = count_result.scalar() or 0
|
||||
|
||||
items = []
|
||||
for reg, user in rows:
|
||||
# Check if user has subscription
|
||||
sub_result = await db.execute(
|
||||
select(Subscription)
|
||||
.where(
|
||||
Subscription.user_id == user.id,
|
||||
Subscription.status == 'active',
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
has_sub = sub_result.scalar_one_or_none() is not None
|
||||
|
||||
items.append(
|
||||
CampaignRegistrationItem(
|
||||
id=reg.id,
|
||||
user_id=user.id,
|
||||
telegram_id=user.telegram_id,
|
||||
username=user.username,
|
||||
first_name=user.first_name,
|
||||
bonus_type=reg.bonus_type,
|
||||
balance_bonus_kopeks=reg.balance_bonus_kopeks or 0,
|
||||
subscription_duration_days=reg.subscription_duration_days,
|
||||
tariff_id=reg.tariff_id,
|
||||
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_paid=user.has_had_paid_subscription or False,
|
||||
)
|
||||
)
|
||||
|
||||
return CampaignRegistrationsResponse(
|
||||
registrations=items,
|
||||
total=total,
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
)
|
||||
|
||||
|
||||
@router.post('', response_model=CampaignDetailResponse)
|
||||
async def create_new_campaign(
|
||||
request: CampaignCreateRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Create a new advertising campaign."""
|
||||
# Check if start_parameter is unique
|
||||
existing = await get_campaign_by_start_parameter(db, request.start_parameter)
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Campaign with start parameter '{request.start_parameter}' already exists",
|
||||
)
|
||||
|
||||
# Validate tariff exists if tariff bonus type
|
||||
if request.bonus_type == 'tariff':
|
||||
if not request.tariff_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Tariff ID is required for tariff bonus type',
|
||||
)
|
||||
tariff_result = await db.execute(select(Tariff).where(Tariff.id == request.tariff_id))
|
||||
tariff = tariff_result.scalar_one_or_none()
|
||||
if not tariff:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Tariff not found',
|
||||
)
|
||||
|
||||
campaign = await create_campaign(
|
||||
db,
|
||||
name=request.name,
|
||||
start_parameter=request.start_parameter,
|
||||
bonus_type=request.bonus_type,
|
||||
created_by=admin.id,
|
||||
balance_bonus_kopeks=request.balance_bonus_kopeks,
|
||||
subscription_duration_days=request.subscription_duration_days,
|
||||
subscription_traffic_gb=request.subscription_traffic_gb,
|
||||
subscription_device_limit=request.subscription_device_limit,
|
||||
subscription_squads=request.subscription_squads,
|
||||
tariff_id=request.tariff_id,
|
||||
tariff_duration_days=request.tariff_duration_days,
|
||||
is_active=request.is_active,
|
||||
)
|
||||
|
||||
# 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}')
|
||||
|
||||
return await get_campaign(campaign.id, admin, db)
|
||||
|
||||
|
||||
@router.put('/{campaign_id}', response_model=CampaignDetailResponse)
|
||||
async def update_existing_campaign(
|
||||
campaign_id: int,
|
||||
request: CampaignUpdateRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update an existing campaign."""
|
||||
campaign = await get_campaign_by_id(db, campaign_id)
|
||||
if not campaign:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Campaign not found',
|
||||
)
|
||||
|
||||
# Check if start_parameter is unique (if changing)
|
||||
if request.start_parameter and request.start_parameter != campaign.start_parameter:
|
||||
existing = await get_campaign_by_start_parameter(db, request.start_parameter)
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Campaign with start parameter '{request.start_parameter}' already exists",
|
||||
)
|
||||
|
||||
# Validate tariff if changing to tariff bonus type
|
||||
if request.bonus_type == 'tariff' or (campaign.bonus_type == 'tariff' and request.tariff_id):
|
||||
tariff_id = request.tariff_id or campaign.tariff_id
|
||||
if tariff_id:
|
||||
tariff_result = await db.execute(select(Tariff).where(Tariff.id == tariff_id))
|
||||
tariff = tariff_result.scalar_one_or_none()
|
||||
if not tariff:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Tariff not found',
|
||||
)
|
||||
|
||||
# Build updates
|
||||
updates = {}
|
||||
if request.name is not None:
|
||||
updates['name'] = request.name
|
||||
if request.start_parameter is not None:
|
||||
updates['start_parameter'] = request.start_parameter
|
||||
if request.bonus_type is not None:
|
||||
updates['bonus_type'] = request.bonus_type
|
||||
if request.is_active is not None:
|
||||
updates['is_active'] = request.is_active
|
||||
if request.balance_bonus_kopeks is not None:
|
||||
updates['balance_bonus_kopeks'] = request.balance_bonus_kopeks
|
||||
if request.subscription_duration_days is not None:
|
||||
updates['subscription_duration_days'] = request.subscription_duration_days
|
||||
if request.subscription_traffic_gb is not None:
|
||||
updates['subscription_traffic_gb'] = request.subscription_traffic_gb
|
||||
if request.subscription_device_limit is not None:
|
||||
updates['subscription_device_limit'] = request.subscription_device_limit
|
||||
if request.subscription_squads is not None:
|
||||
updates['subscription_squads'] = request.subscription_squads
|
||||
if request.tariff_id is not None:
|
||||
updates['tariff_id'] = request.tariff_id
|
||||
if request.tariff_duration_days is not None:
|
||||
updates['tariff_duration_days'] = request.tariff_duration_days
|
||||
|
||||
if updates:
|
||||
await update_campaign(db, campaign, **updates)
|
||||
|
||||
logger.info(f'Admin {admin.id} updated campaign {campaign_id}')
|
||||
|
||||
return await get_campaign(campaign_id, admin, db)
|
||||
|
||||
|
||||
@router.delete('/{campaign_id}')
|
||||
async def delete_existing_campaign(
|
||||
campaign_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Delete a campaign."""
|
||||
campaign = await get_campaign_by_id(db, campaign_id)
|
||||
if not campaign:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Campaign not found',
|
||||
)
|
||||
|
||||
# Check if campaign has registrations
|
||||
reg_count = len(campaign.registrations) if campaign.registrations else 0
|
||||
if reg_count > 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Cannot delete campaign with {reg_count} registrations. Deactivate it instead.',
|
||||
)
|
||||
|
||||
await delete_campaign(db, campaign)
|
||||
logger.info(f'Admin {admin.id} deleted campaign {campaign_id}: {campaign.name}')
|
||||
|
||||
return {'message': 'Campaign deleted successfully'}
|
||||
|
||||
|
||||
@router.post('/{campaign_id}/toggle', response_model=CampaignToggleResponse)
|
||||
async def toggle_campaign(
|
||||
campaign_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Toggle campaign active status."""
|
||||
campaign = await get_campaign_by_id(db, campaign_id)
|
||||
if not campaign:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Campaign not found',
|
||||
)
|
||||
|
||||
new_status = not campaign.is_active
|
||||
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}')
|
||||
|
||||
return CampaignToggleResponse(
|
||||
id=campaign_id,
|
||||
is_active=new_status,
|
||||
message=f'Campaign {status_text}',
|
||||
)
|
||||
@@ -0,0 +1,680 @@
|
||||
"""Admin routes for managing email notification templates."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import User
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
from ..services.email_template_overrides import (
|
||||
delete_template_override,
|
||||
get_all_overrides,
|
||||
get_overrides_for_type,
|
||||
save_template_override,
|
||||
)
|
||||
from ..services.email_templates import EmailNotificationTemplates
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/email-templates', tags=['Admin Email Templates'])
|
||||
|
||||
|
||||
# ============ Template type metadata ============
|
||||
|
||||
TEMPLATE_TYPES = [
|
||||
{
|
||||
'type': 'balance_topup',
|
||||
'label': {'ru': 'Пополнение баланса', 'en': 'Balance Top-up', 'zh': '余额充值', 'ua': 'Поповнення балансу'},
|
||||
'description': {
|
||||
'ru': 'Уведомление о пополнении баланса',
|
||||
'en': 'Balance top-up notification',
|
||||
'zh': '余额充值通知',
|
||||
'ua': 'Сповіщення про поповнення балансу',
|
||||
},
|
||||
'context_vars': ['amount', 'balance'],
|
||||
},
|
||||
{
|
||||
'type': 'balance_change',
|
||||
'label': {'ru': 'Изменение баланса', 'en': 'Balance Change', 'zh': '余额变动', 'ua': 'Зміна балансу'},
|
||||
'description': {
|
||||
'ru': 'Уведомление об изменении баланса',
|
||||
'en': 'Balance change notification',
|
||||
'zh': '余额变动通知',
|
||||
'ua': 'Сповіщення про зміну балансу',
|
||||
},
|
||||
'context_vars': ['amount', 'balance'],
|
||||
},
|
||||
{
|
||||
'type': 'subscription_expiring',
|
||||
'label': {
|
||||
'ru': 'Подписка истекает',
|
||||
'en': 'Subscription Expiring',
|
||||
'zh': '订阅即将到期',
|
||||
'ua': 'Підписка закінчується',
|
||||
},
|
||||
'description': {
|
||||
'ru': 'Предупреждение об истечении подписки',
|
||||
'en': 'Subscription expiring warning',
|
||||
'zh': '订阅即将到期警告',
|
||||
'ua': 'Попередження про закінчення підписки',
|
||||
},
|
||||
'context_vars': ['days_left', 'expires_at'],
|
||||
},
|
||||
{
|
||||
'type': 'subscription_expired',
|
||||
'label': {
|
||||
'ru': 'Подписка истекла',
|
||||
'en': 'Subscription Expired',
|
||||
'zh': '订阅已到期',
|
||||
'ua': 'Підписка закінчилась',
|
||||
},
|
||||
'description': {
|
||||
'ru': 'Уведомление об истечении подписки',
|
||||
'en': 'Subscription expired notification',
|
||||
'zh': '订阅已到期通知',
|
||||
'ua': 'Сповіщення про закінчення підписки',
|
||||
},
|
||||
'context_vars': [],
|
||||
},
|
||||
{
|
||||
'type': 'subscription_renewed',
|
||||
'label': {
|
||||
'ru': 'Подписка продлена',
|
||||
'en': 'Subscription Renewed',
|
||||
'zh': '订阅已续期',
|
||||
'ua': 'Підписка продовжена',
|
||||
},
|
||||
'description': {
|
||||
'ru': 'Уведомление о продлении подписки',
|
||||
'en': 'Subscription renewed notification',
|
||||
'zh': '订阅已续期通知',
|
||||
'ua': 'Сповіщення про продовження підписки',
|
||||
},
|
||||
'context_vars': ['new_end_date', 'tariff_name'],
|
||||
},
|
||||
{
|
||||
'type': 'subscription_activated',
|
||||
'label': {
|
||||
'ru': 'Подписка активирована',
|
||||
'en': 'Subscription Activated',
|
||||
'zh': '订阅已激活',
|
||||
'ua': 'Підписка активована',
|
||||
},
|
||||
'description': {
|
||||
'ru': 'Уведомление об активации подписки',
|
||||
'en': 'Subscription activated notification',
|
||||
'zh': '订阅已激活通知',
|
||||
'ua': 'Сповіщення про активацію підписки',
|
||||
},
|
||||
'context_vars': ['tariff_name', 'end_date'],
|
||||
},
|
||||
{
|
||||
'type': 'autopay_success',
|
||||
'label': {
|
||||
'ru': 'Автоплатёж успешен',
|
||||
'en': 'Autopay Success',
|
||||
'zh': '自动续费成功',
|
||||
'ua': 'Автоплатіж успішний',
|
||||
},
|
||||
'description': {
|
||||
'ru': 'Уведомление об успешном автоплатеже',
|
||||
'en': 'Autopay success notification',
|
||||
'zh': '自动续费成功通知',
|
||||
'ua': 'Сповіщення про успішний автоплатіж',
|
||||
},
|
||||
'context_vars': ['amount', 'balance', 'new_end_date'],
|
||||
},
|
||||
{
|
||||
'type': 'autopay_failed',
|
||||
'label': {
|
||||
'ru': 'Автоплатёж не удался',
|
||||
'en': 'Autopay Failed',
|
||||
'zh': '自动续费失败',
|
||||
'ua': 'Автоплатіж не вдався',
|
||||
},
|
||||
'description': {
|
||||
'ru': 'Уведомление о неудачном автоплатеже',
|
||||
'en': 'Autopay failed notification',
|
||||
'zh': '自动续费失败通知',
|
||||
'ua': 'Сповіщення про невдалий автоплатіж',
|
||||
},
|
||||
'context_vars': ['reason'],
|
||||
},
|
||||
{
|
||||
'type': 'autopay_insufficient_funds',
|
||||
'label': {
|
||||
'ru': 'Недостаточно средств (автоплатёж)',
|
||||
'en': 'Insufficient Funds (Autopay)',
|
||||
'zh': '余额不足(自动续费)',
|
||||
'ua': 'Недостатньо коштів (автоплатіж)',
|
||||
},
|
||||
'description': {
|
||||
'ru': 'Уведомление о нехватке средств для автоплатежа',
|
||||
'en': 'Insufficient funds for autopay notification',
|
||||
'zh': '自动续费余额不足通知',
|
||||
'ua': 'Сповіщення про нестачу коштів для автоплатежу',
|
||||
},
|
||||
'context_vars': ['required_amount', 'balance'],
|
||||
},
|
||||
{
|
||||
'type': 'daily_debit',
|
||||
'label': {'ru': 'Суточное списание', 'en': 'Daily Debit', 'zh': '每日扣费', 'ua': 'Добове списання'},
|
||||
'description': {
|
||||
'ru': 'Уведомление о суточном списании',
|
||||
'en': 'Daily debit notification',
|
||||
'zh': '每日扣费通知',
|
||||
'ua': 'Сповіщення про добове списання',
|
||||
},
|
||||
'context_vars': ['amount', 'balance'],
|
||||
},
|
||||
{
|
||||
'type': 'daily_insufficient_funds',
|
||||
'label': {
|
||||
'ru': 'Недостаточно средств (суточное)',
|
||||
'en': 'Insufficient Funds (Daily)',
|
||||
'zh': '余额不足(每日)',
|
||||
'ua': 'Недостатньо коштів (добове)',
|
||||
},
|
||||
'description': {
|
||||
'ru': 'Уведомление о нехватке средств для суточного списания',
|
||||
'en': 'Insufficient funds for daily debit',
|
||||
'zh': '每日扣费余额不足通知',
|
||||
'ua': 'Сповіщення про нестачу коштів для добового списання',
|
||||
},
|
||||
'context_vars': ['required_amount', 'balance'],
|
||||
},
|
||||
{
|
||||
'type': 'ban_notification',
|
||||
'label': {'ru': 'Блокировка аккаунта', 'en': 'Account Banned', 'zh': '账户被封禁', 'ua': 'Блокування акаунту'},
|
||||
'description': {
|
||||
'ru': 'Уведомление о блокировке аккаунта',
|
||||
'en': 'Account banned notification',
|
||||
'zh': '账户被封禁通知',
|
||||
'ua': 'Сповіщення про блокування акаунту',
|
||||
},
|
||||
'context_vars': ['reason'],
|
||||
},
|
||||
{
|
||||
'type': 'unban_notification',
|
||||
'label': {
|
||||
'ru': 'Разблокировка аккаунта',
|
||||
'en': 'Account Unbanned',
|
||||
'zh': '账户已解封',
|
||||
'ua': 'Розблокування акаунту',
|
||||
},
|
||||
'description': {
|
||||
'ru': 'Уведомление о разблокировке аккаунта',
|
||||
'en': 'Account unbanned notification',
|
||||
'zh': '账户已解封通知',
|
||||
'ua': 'Сповіщення про розблокування акаунту',
|
||||
},
|
||||
'context_vars': [],
|
||||
},
|
||||
{
|
||||
'type': 'warning_notification',
|
||||
'label': {'ru': 'Предупреждение', 'en': 'Warning', 'zh': '警告', 'ua': 'Попередження'},
|
||||
'description': {
|
||||
'ru': 'Предупреждение пользователю',
|
||||
'en': 'Warning notification',
|
||||
'zh': '警告通知',
|
||||
'ua': 'Попередження користувачу',
|
||||
},
|
||||
'context_vars': ['message'],
|
||||
},
|
||||
{
|
||||
'type': 'referral_bonus',
|
||||
'label': {'ru': 'Реферальный бонус', 'en': 'Referral Bonus', 'zh': '推荐奖励', 'ua': 'Реферальний бонус'},
|
||||
'description': {
|
||||
'ru': 'Уведомление о начислении реферального бонуса',
|
||||
'en': 'Referral bonus notification',
|
||||
'zh': '推荐奖励通知',
|
||||
'ua': 'Сповіщення про нарахування реферального бонусу',
|
||||
},
|
||||
'context_vars': ['amount', 'referral_name'],
|
||||
},
|
||||
{
|
||||
'type': 'referral_registered',
|
||||
'label': {'ru': 'Новый реферал', 'en': 'New Referral', 'zh': '新推荐用户', 'ua': 'Новий реферал'},
|
||||
'description': {
|
||||
'ru': 'Уведомление о регистрации реферала',
|
||||
'en': 'New referral registered notification',
|
||||
'zh': '新推荐用户注册通知',
|
||||
'ua': 'Сповіщення про реєстрацію реферала',
|
||||
},
|
||||
'context_vars': ['referral_name'],
|
||||
},
|
||||
{
|
||||
'type': 'traffic_reset',
|
||||
'label': {'ru': 'Сброс трафика', 'en': 'Traffic Reset', 'zh': '流量重置', 'ua': 'Скидання трафіку'},
|
||||
'description': {
|
||||
'ru': 'Уведомление о сбросе трафика',
|
||||
'en': 'Traffic reset notification',
|
||||
'zh': '流量重置通知',
|
||||
'ua': 'Сповіщення про скидання трафіку',
|
||||
},
|
||||
'context_vars': ['traffic_limit'],
|
||||
},
|
||||
{
|
||||
'type': 'payment_received',
|
||||
'label': {'ru': 'Платёж получен', 'en': 'Payment Received', 'zh': '收到付款', 'ua': 'Платіж отримано'},
|
||||
'description': {
|
||||
'ru': 'Уведомление о получении платежа',
|
||||
'en': 'Payment received notification',
|
||||
'zh': '收到付款通知',
|
||||
'ua': 'Сповіщення про отримання платежу',
|
||||
},
|
||||
'context_vars': ['amount', 'payment_method'],
|
||||
},
|
||||
{
|
||||
'type': 'email_verification',
|
||||
'label': {
|
||||
'ru': 'Подтверждение email',
|
||||
'en': 'Email Verification',
|
||||
'zh': '邮箱验证',
|
||||
'ua': 'Підтвердження email',
|
||||
},
|
||||
'description': {
|
||||
'ru': 'Письмо для подтверждения email адреса при регистрации',
|
||||
'en': 'Email address verification letter sent during registration',
|
||||
'zh': '注册时发送的邮箱验证邮件',
|
||||
'ua': 'Лист для підтвердження email адреси при реєстрації',
|
||||
},
|
||||
'context_vars': ['username', 'verification_url', 'expire_hours'],
|
||||
},
|
||||
{
|
||||
'type': 'password_reset',
|
||||
'label': {'ru': 'Сброс пароля', 'en': 'Password Reset', 'zh': '重置密码', 'ua': 'Скидання пароля'},
|
||||
'description': {
|
||||
'ru': 'Письмо для сброса пароля',
|
||||
'en': 'Password reset email',
|
||||
'zh': '密码重置邮件',
|
||||
'ua': 'Лист для скидання пароля',
|
||||
},
|
||||
'context_vars': ['username', 'reset_url', 'expire_hours'],
|
||||
},
|
||||
]
|
||||
|
||||
SAMPLE_CONTEXTS: dict[str, dict[str, Any]] = {
|
||||
'balance_topup': {
|
||||
'formatted_amount': '500.00 ₽',
|
||||
'formatted_balance': '1500.00 ₽',
|
||||
'amount_rubles': 500,
|
||||
'new_balance_rubles': 1500,
|
||||
},
|
||||
'balance_change': {
|
||||
'formatted_amount': '-200.00 ₽',
|
||||
'formatted_balance': '1300.00 ₽',
|
||||
'amount_rubles': -200,
|
||||
'new_balance_rubles': 1300,
|
||||
},
|
||||
'subscription_expiring': {'days_left': 3, 'expires_at': '2025-01-30'},
|
||||
'subscription_expired': {},
|
||||
'subscription_renewed': {'new_end_date': '2025-02-28', 'tariff_name': 'Premium'},
|
||||
'subscription_activated': {'tariff_name': 'Premium', 'end_date': '2025-02-28'},
|
||||
'autopay_success': {'formatted_amount': '300.00 ₽', 'formatted_balance': '200.00 ₽', 'new_end_date': '2025-02-28'},
|
||||
'autopay_failed': {'reason': 'Card declined'},
|
||||
'autopay_insufficient_funds': {'formatted_required': '300.00 ₽', 'formatted_balance': '50.00 ₽'},
|
||||
'daily_debit': {'formatted_amount': '10.00 ₽', 'formatted_balance': '490.00 ₽'},
|
||||
'daily_insufficient_funds': {'formatted_required': '10.00 ₽', 'formatted_balance': '5.00 ₽'},
|
||||
'ban_notification': {'reason': 'Violation of terms of service'},
|
||||
'unban_notification': {},
|
||||
'warning_notification': {'message': 'Please review our terms of service'},
|
||||
'referral_bonus': {'formatted_amount': '100.00 ₽', 'referral_name': 'John'},
|
||||
'referral_registered': {'referral_name': 'John'},
|
||||
'traffic_reset': {'traffic_limit': '100 GB'},
|
||||
'payment_received': {'formatted_amount': '500.00 ₽', 'payment_method': 'YooKassa'},
|
||||
'email_verification': {
|
||||
'username': 'John',
|
||||
'verification_url': 'https://example.com/verify?token=abc123',
|
||||
'expire_hours': 24,
|
||||
},
|
||||
'password_reset': {'username': 'John', 'reset_url': 'https://example.com/reset?token=abc123', 'expire_hours': 1},
|
||||
}
|
||||
|
||||
AVAILABLE_LANGUAGES = ['ru', 'en', 'zh', 'ua', 'fa']
|
||||
|
||||
|
||||
# ============ Schemas ============
|
||||
|
||||
|
||||
class EmailTemplateUpdate(BaseModel):
|
||||
"""Request to update an email template."""
|
||||
|
||||
subject: str = Field(..., min_length=1, max_length=500)
|
||||
body_html: str = Field(..., min_length=1)
|
||||
|
||||
|
||||
class EmailTemplatePreviewRequest(BaseModel):
|
||||
"""Request to preview an email template."""
|
||||
|
||||
language: str = Field(default='ru')
|
||||
subject: str = Field(default='')
|
||||
body_html: str = Field(default='')
|
||||
|
||||
|
||||
class EmailTemplateSendTestRequest(BaseModel):
|
||||
"""Request to send a test email."""
|
||||
|
||||
language: str = Field(default='ru')
|
||||
email: str = Field(default='')
|
||||
|
||||
|
||||
# ============ Endpoints ============
|
||||
|
||||
|
||||
@router.get('', summary='List all email template types')
|
||||
async def list_template_types(
|
||||
_admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> dict[str, Any]:
|
||||
"""List all available email template types with override status."""
|
||||
overrides = await get_all_overrides(db)
|
||||
|
||||
# Build a map of overrides by type
|
||||
override_map: dict[str, dict[str, bool]] = {}
|
||||
for o in overrides:
|
||||
ntype = o['notification_type']
|
||||
if ntype not in override_map:
|
||||
override_map[ntype] = {}
|
||||
override_map[ntype][o['language']] = o['is_active']
|
||||
|
||||
result = []
|
||||
for tpl_type in TEMPLATE_TYPES:
|
||||
type_key = tpl_type['type']
|
||||
languages = {}
|
||||
for lang in AVAILABLE_LANGUAGES:
|
||||
languages[lang] = {
|
||||
'has_custom': lang in override_map.get(type_key, {}),
|
||||
}
|
||||
result.append(
|
||||
{
|
||||
**tpl_type,
|
||||
'languages': languages,
|
||||
}
|
||||
)
|
||||
|
||||
return {'items': result, 'available_languages': AVAILABLE_LANGUAGES}
|
||||
|
||||
|
||||
@router.get('/{notification_type}', summary='Get templates for a notification type')
|
||||
async def get_templates_for_type(
|
||||
notification_type: str,
|
||||
_admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> dict[str, Any]:
|
||||
"""Get all language templates for a specific notification type."""
|
||||
# Validate type
|
||||
valid_types = [t['type'] for t in TEMPLATE_TYPES]
|
||||
if notification_type not in valid_types:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f'Unknown template type: {notification_type}',
|
||||
)
|
||||
|
||||
# Get overrides from DB
|
||||
overrides = await get_overrides_for_type(notification_type, db)
|
||||
override_map = {o['language']: o for o in overrides}
|
||||
|
||||
# Get defaults from hardcoded templates
|
||||
templates_instance = EmailNotificationTemplates()
|
||||
sample_context = SAMPLE_CONTEXTS.get(notification_type, {})
|
||||
|
||||
# Get type metadata
|
||||
type_meta = next(t for t in TEMPLATE_TYPES if t['type'] == notification_type)
|
||||
|
||||
# Build combined result per language
|
||||
languages = {}
|
||||
for lang in AVAILABLE_LANGUAGES:
|
||||
# Get default template
|
||||
try:
|
||||
from app.services.notification_delivery_service import NotificationType
|
||||
|
||||
ntype_enum = NotificationType(notification_type)
|
||||
default_template = templates_instance.get_template(ntype_enum, lang, sample_context)
|
||||
except Exception:
|
||||
default_template = None
|
||||
|
||||
default_subject = ''
|
||||
default_body_html = ''
|
||||
if default_template:
|
||||
default_subject = default_template.get('subject', '')
|
||||
default_body_html = default_template.get('body_html', '')
|
||||
|
||||
# Check for override
|
||||
override = override_map.get(lang)
|
||||
if override:
|
||||
languages[lang] = {
|
||||
'subject': override['subject'],
|
||||
'body_html': override['body_html'],
|
||||
'is_default': False,
|
||||
'default_subject': default_subject,
|
||||
'default_body_html': default_body_html,
|
||||
}
|
||||
else:
|
||||
languages[lang] = {
|
||||
'subject': default_subject,
|
||||
'body_html': default_body_html,
|
||||
'is_default': True,
|
||||
'default_subject': default_subject,
|
||||
'default_body_html': default_body_html,
|
||||
}
|
||||
|
||||
return {
|
||||
'notification_type': notification_type,
|
||||
'label': type_meta['label'],
|
||||
'description': type_meta['description'],
|
||||
'context_vars': type_meta['context_vars'],
|
||||
'languages': languages,
|
||||
}
|
||||
|
||||
|
||||
@router.put('/{notification_type}/{language}', summary='Save custom template')
|
||||
async def update_template(
|
||||
notification_type: str,
|
||||
language: str,
|
||||
data: EmailTemplateUpdate,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> dict[str, Any]:
|
||||
"""Save a custom email template override."""
|
||||
valid_types = [t['type'] for t in TEMPLATE_TYPES]
|
||||
if notification_type not in valid_types:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f'Unknown template type: {notification_type}',
|
||||
)
|
||||
|
||||
if language not in AVAILABLE_LANGUAGES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Invalid language: {language}. Available: {AVAILABLE_LANGUAGES}',
|
||||
)
|
||||
|
||||
result = await save_template_override(
|
||||
notification_type=notification_type,
|
||||
language=language,
|
||||
subject=data.subject,
|
||||
body_html=data.body_html,
|
||||
db=db,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'Админ %s обновил email шаблон %s/%s',
|
||||
admin.id,
|
||||
notification_type,
|
||||
language,
|
||||
)
|
||||
|
||||
return {'status': 'ok', 'template': result}
|
||||
|
||||
|
||||
@router.delete('/{notification_type}/{language}', summary='Reset template to default')
|
||||
async def reset_template(
|
||||
notification_type: str,
|
||||
language: str,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> dict[str, Any]:
|
||||
"""Delete custom template override, reverting to default."""
|
||||
valid_types = [t['type'] for t in TEMPLATE_TYPES]
|
||||
if notification_type not in valid_types:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f'Unknown template type: {notification_type}',
|
||||
)
|
||||
|
||||
deleted = await delete_template_override(notification_type, language, db)
|
||||
|
||||
if deleted:
|
||||
logger.info(
|
||||
'Админ %s сбросил email шаблон %s/%s к дефолту',
|
||||
admin.id,
|
||||
notification_type,
|
||||
language,
|
||||
)
|
||||
|
||||
return {'status': 'ok', 'was_custom': deleted}
|
||||
|
||||
|
||||
@router.post('/{notification_type}/preview', summary='Preview rendered template')
|
||||
async def preview_template(
|
||||
notification_type: str,
|
||||
data: EmailTemplatePreviewRequest,
|
||||
_admin: User = Depends(get_current_admin_user),
|
||||
) -> dict[str, Any]:
|
||||
"""Preview a rendered email template with sample data."""
|
||||
valid_types = [t['type'] for t in TEMPLATE_TYPES]
|
||||
if notification_type not in valid_types:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f'Unknown template type: {notification_type}',
|
||||
)
|
||||
|
||||
templates_instance = EmailNotificationTemplates()
|
||||
language = data.language if data.language in AVAILABLE_LANGUAGES else 'ru'
|
||||
|
||||
if data.body_html:
|
||||
# Preview custom content wrapped in base template
|
||||
rendered_html = templates_instance._get_base_template(data.body_html, language)
|
||||
subject = data.subject or notification_type
|
||||
else:
|
||||
# Preview default template
|
||||
sample_context = SAMPLE_CONTEXTS.get(notification_type, {})
|
||||
try:
|
||||
from app.services.notification_delivery_service import NotificationType
|
||||
|
||||
ntype_enum = NotificationType(notification_type)
|
||||
default_template = templates_instance.get_template(ntype_enum, language, sample_context)
|
||||
except Exception:
|
||||
default_template = None
|
||||
|
||||
if default_template:
|
||||
rendered_html = default_template['body_html']
|
||||
subject = default_template['subject']
|
||||
else:
|
||||
rendered_html = '<p>Template not found</p>'
|
||||
subject = 'N/A'
|
||||
|
||||
return {
|
||||
'subject': subject,
|
||||
'body_html': rendered_html,
|
||||
}
|
||||
|
||||
|
||||
@router.post('/{notification_type}/test', summary='Send test email')
|
||||
async def send_test_email(
|
||||
notification_type: str,
|
||||
data: EmailTemplateSendTestRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> dict[str, Any]:
|
||||
"""Send a test email to the admin's email address."""
|
||||
from app.cabinet.services.email_service import email_service
|
||||
|
||||
if not email_service.is_configured():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='SMTP is not configured',
|
||||
)
|
||||
|
||||
to_email = data.email or admin.email
|
||||
if not to_email:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='No email address provided and admin has no email',
|
||||
)
|
||||
|
||||
valid_types = [t['type'] for t in TEMPLATE_TYPES]
|
||||
if notification_type not in valid_types:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f'Unknown template type: {notification_type}',
|
||||
)
|
||||
|
||||
language = data.language if data.language in AVAILABLE_LANGUAGES else 'ru'
|
||||
sample_context = SAMPLE_CONTEXTS.get(notification_type, {})
|
||||
templates_instance = EmailNotificationTemplates()
|
||||
|
||||
# Check for DB override
|
||||
from ..services.email_template_overrides import get_template_override
|
||||
|
||||
override = await get_template_override(notification_type, language, db)
|
||||
|
||||
if override:
|
||||
subject = override['subject']
|
||||
body_html = templates_instance._get_base_template(override['body_html'], language)
|
||||
else:
|
||||
try:
|
||||
from app.services.notification_delivery_service import NotificationType
|
||||
|
||||
ntype_enum = NotificationType(notification_type)
|
||||
default_template = templates_instance.get_template(ntype_enum, language, sample_context)
|
||||
except Exception:
|
||||
default_template = None
|
||||
|
||||
if default_template:
|
||||
subject = default_template['subject']
|
||||
body_html = default_template['body_html']
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Template not found',
|
||||
)
|
||||
|
||||
subject = f'[TEST] {subject}'
|
||||
|
||||
try:
|
||||
success = await asyncio.to_thread(
|
||||
email_service.send_email,
|
||||
to_email=to_email,
|
||||
subject=subject,
|
||||
body_html=body_html,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error('Ошибка отправки тестового email: %s', e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f'Failed to send test email: {e!s}',
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to send test email',
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'Админ %s отправил тестовый email %s/%s на %s',
|
||||
admin.id,
|
||||
notification_type,
|
||||
language,
|
||||
to_email,
|
||||
)
|
||||
|
||||
return {'status': 'ok', 'sent_to': to_email}
|
||||
@@ -0,0 +1,228 @@
|
||||
"""Admin routes for payment method configuration in cabinet."""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import User
|
||||
from app.services.payment_method_config_service import (
|
||||
_get_method_defaults,
|
||||
get_all_configs,
|
||||
get_all_promo_groups,
|
||||
get_config_by_method_id,
|
||||
update_config,
|
||||
update_sort_order,
|
||||
)
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/payment-methods', tags=['Cabinet Admin Payment Methods'])
|
||||
|
||||
|
||||
# ============ Schemas ============
|
||||
|
||||
|
||||
class SubOptionInfo(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
|
||||
|
||||
class PaymentMethodConfigResponse(BaseModel):
|
||||
method_id: str
|
||||
sort_order: int
|
||||
is_enabled: bool
|
||||
display_name: str | None = None
|
||||
default_display_name: str
|
||||
sub_options: dict | None = None
|
||||
available_sub_options: list[SubOptionInfo] | None = None
|
||||
min_amount_kopeks: int | None = None
|
||||
max_amount_kopeks: int | None = None
|
||||
default_min_amount_kopeks: int
|
||||
default_max_amount_kopeks: int
|
||||
user_type_filter: str
|
||||
first_topup_filter: str
|
||||
promo_group_filter_mode: str
|
||||
allowed_promo_group_ids: list[int] = Field(default_factory=list)
|
||||
is_provider_configured: bool
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class PaymentMethodConfigUpdateRequest(BaseModel):
|
||||
is_enabled: bool | None = None
|
||||
display_name: str | None = Field(default=None, description='Null to reset to default')
|
||||
sub_options: dict | None = None
|
||||
min_amount_kopeks: int | None = Field(default=None, ge=0)
|
||||
max_amount_kopeks: int | None = Field(default=None, ge=0)
|
||||
user_type_filter: str | None = Field(default=None, pattern='^(all|telegram|email)$')
|
||||
first_topup_filter: str | None = Field(default=None, pattern='^(any|yes|no)$')
|
||||
promo_group_filter_mode: str | None = Field(default=None, pattern='^(all|selected)$')
|
||||
allowed_promo_group_ids: list[int] | None = None
|
||||
# Allow explicitly resetting display_name to null
|
||||
reset_display_name: bool = False
|
||||
reset_min_amount: bool = False
|
||||
reset_max_amount: bool = False
|
||||
|
||||
|
||||
class SortOrderRequest(BaseModel):
|
||||
method_ids: list[str]
|
||||
|
||||
|
||||
class PromoGroupSimple(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# ============ Helpers ============
|
||||
|
||||
|
||||
def _enrich_config(config, defaults: dict) -> PaymentMethodConfigResponse:
|
||||
"""Enrich a PaymentMethodConfig with env-var defaults."""
|
||||
method_def = defaults.get(config.method_id, {})
|
||||
|
||||
available_sub_options = None
|
||||
raw_options = method_def.get('available_sub_options')
|
||||
if raw_options:
|
||||
available_sub_options = [SubOptionInfo(**opt) for opt in raw_options]
|
||||
|
||||
return PaymentMethodConfigResponse(
|
||||
method_id=config.method_id,
|
||||
sort_order=config.sort_order,
|
||||
is_enabled=config.is_enabled,
|
||||
display_name=config.display_name,
|
||||
default_display_name=method_def.get('default_display_name', config.method_id),
|
||||
sub_options=config.sub_options,
|
||||
available_sub_options=available_sub_options,
|
||||
min_amount_kopeks=config.min_amount_kopeks,
|
||||
max_amount_kopeks=config.max_amount_kopeks,
|
||||
default_min_amount_kopeks=method_def.get('default_min', 1000),
|
||||
default_max_amount_kopeks=method_def.get('default_max', 10000000),
|
||||
user_type_filter=config.user_type_filter,
|
||||
first_topup_filter=config.first_topup_filter,
|
||||
promo_group_filter_mode=config.promo_group_filter_mode,
|
||||
allowed_promo_group_ids=[pg.id for pg in config.allowed_promo_groups],
|
||||
is_provider_configured=method_def.get('is_configured', False),
|
||||
created_at=config.created_at,
|
||||
updated_at=config.updated_at,
|
||||
)
|
||||
|
||||
|
||||
# ============ Routes ============
|
||||
|
||||
|
||||
@router.get('', response_model=list[PaymentMethodConfigResponse])
|
||||
async def list_payment_methods(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""List all payment method configurations."""
|
||||
configs = await get_all_configs(db)
|
||||
defaults = _get_method_defaults()
|
||||
return [_enrich_config(c, defaults) for c in configs]
|
||||
|
||||
|
||||
@router.get('/promo-groups', response_model=list[PromoGroupSimple])
|
||||
async def list_promo_groups(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""List all promo groups for filter selector."""
|
||||
groups = await get_all_promo_groups(db)
|
||||
return [PromoGroupSimple(id=g.id, name=g.name) for g in groups]
|
||||
|
||||
|
||||
@router.get('/{method_id}', response_model=PaymentMethodConfigResponse)
|
||||
async def get_payment_method(
|
||||
method_id: str,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get a single payment method configuration."""
|
||||
config = await get_config_by_method_id(db, method_id)
|
||||
if not config:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f'Payment method not found: {method_id}',
|
||||
)
|
||||
defaults = _get_method_defaults()
|
||||
return _enrich_config(config, defaults)
|
||||
|
||||
|
||||
@router.put('/order')
|
||||
async def update_payment_methods_order(
|
||||
request: SortOrderRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""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}')
|
||||
return {'success': True}
|
||||
|
||||
|
||||
@router.put('/{method_id}', response_model=PaymentMethodConfigResponse)
|
||||
async def update_payment_method(
|
||||
method_id: str,
|
||||
request: PaymentMethodConfigUpdateRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update a payment method configuration."""
|
||||
# Build update data dict
|
||||
data = {}
|
||||
|
||||
if request.is_enabled is not None:
|
||||
data['is_enabled'] = request.is_enabled
|
||||
|
||||
if request.reset_display_name:
|
||||
data['display_name'] = None
|
||||
elif request.display_name is not None:
|
||||
data['display_name'] = request.display_name.strip() or None
|
||||
|
||||
if request.sub_options is not None:
|
||||
data['sub_options'] = request.sub_options
|
||||
|
||||
if request.reset_min_amount:
|
||||
data['min_amount_kopeks'] = None
|
||||
elif request.min_amount_kopeks is not None:
|
||||
data['min_amount_kopeks'] = request.min_amount_kopeks
|
||||
|
||||
if request.reset_max_amount:
|
||||
data['max_amount_kopeks'] = None
|
||||
elif request.max_amount_kopeks is not None:
|
||||
data['max_amount_kopeks'] = request.max_amount_kopeks
|
||||
|
||||
if request.user_type_filter is not None:
|
||||
data['user_type_filter'] = request.user_type_filter
|
||||
|
||||
if request.first_topup_filter is not None:
|
||||
data['first_topup_filter'] = request.first_topup_filter
|
||||
|
||||
if request.promo_group_filter_mode is not None:
|
||||
data['promo_group_filter_mode'] = request.promo_group_filter_mode
|
||||
|
||||
promo_group_ids = request.allowed_promo_group_ids
|
||||
|
||||
config = await update_config(db, method_id, data, promo_group_ids)
|
||||
if not config:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f'Payment method not found: {method_id}',
|
||||
)
|
||||
|
||||
logger.info(f'Admin {admin.id} updated payment method config: {method_id}')
|
||||
|
||||
defaults = _get_method_defaults()
|
||||
return _enrich_config(config, defaults)
|
||||
@@ -0,0 +1,420 @@
|
||||
"""Admin routes for payment verification in cabinet."""
|
||||
|
||||
import logging
|
||||
import math
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import PaymentMethod, User
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.services.payment_verification_service import (
|
||||
SUPPORTED_MANUAL_CHECK_METHODS,
|
||||
PendingPayment,
|
||||
get_payment_record,
|
||||
list_recent_pending_payments,
|
||||
method_display_name,
|
||||
run_manual_check,
|
||||
)
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/payments', tags=['Cabinet Admin Payments'])
|
||||
|
||||
|
||||
# ============ Schemas ============
|
||||
|
||||
|
||||
class PendingPaymentResponse(BaseModel):
|
||||
"""Pending payment details."""
|
||||
|
||||
id: int
|
||||
method: str
|
||||
method_display: str
|
||||
identifier: str
|
||||
amount_kopeks: int
|
||||
amount_rubles: float
|
||||
status: str
|
||||
status_emoji: str
|
||||
status_text: str
|
||||
is_paid: bool
|
||||
is_checkable: bool
|
||||
created_at: datetime
|
||||
expires_at: datetime | None = None
|
||||
payment_url: str | None = None
|
||||
user_id: int | None = None
|
||||
user_telegram_id: int | None = None
|
||||
user_username: str | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class PendingPaymentListResponse(BaseModel):
|
||||
"""Paginated list of pending payments."""
|
||||
|
||||
items: list[PendingPaymentResponse]
|
||||
total: int
|
||||
page: int
|
||||
per_page: int
|
||||
pages: int
|
||||
|
||||
|
||||
class ManualCheckResponse(BaseModel):
|
||||
"""Response after manual payment status check."""
|
||||
|
||||
success: bool
|
||||
message: str
|
||||
payment: PendingPaymentResponse | None = None
|
||||
status_changed: bool = False
|
||||
old_status: str | None = None
|
||||
new_status: str | None = None
|
||||
|
||||
|
||||
class PaymentsStatsResponse(BaseModel):
|
||||
"""Statistics about pending payments."""
|
||||
|
||||
total_pending: int
|
||||
by_method: dict
|
||||
|
||||
|
||||
# ============ Helper functions ============
|
||||
|
||||
|
||||
def _get_status_info(record: PendingPayment) -> tuple[str, str]:
|
||||
"""Get status emoji and text for a pending payment."""
|
||||
status_str = (record.status or '').lower()
|
||||
|
||||
if record.is_paid:
|
||||
return '✅', 'Оплачено'
|
||||
|
||||
if record.method == PaymentMethod.PAL24:
|
||||
mapping = {
|
||||
'new': ('⏳', 'Ожидает оплаты'),
|
||||
'process': ('⌛', 'Обрабатывается'),
|
||||
'success': ('✅', 'Оплачено'),
|
||||
'fail': ('❌', 'Ошибка'),
|
||||
'canceled': ('❌', 'Отменено'),
|
||||
}
|
||||
return mapping.get(status_str, ('❓', 'Неизвестно'))
|
||||
|
||||
if record.method == PaymentMethod.MULENPAY:
|
||||
mapping = {
|
||||
'created': ('⏳', 'Ожидает оплаты'),
|
||||
'processing': ('⌛', 'Обрабатывается'),
|
||||
'hold': ('🔒', 'На удержании'),
|
||||
'success': ('✅', 'Оплачено'),
|
||||
'canceled': ('❌', 'Отменено'),
|
||||
'error': ('❌', 'Ошибка'),
|
||||
}
|
||||
return mapping.get(status_str, ('❓', 'Неизвестно'))
|
||||
|
||||
if record.method == PaymentMethod.WATA:
|
||||
mapping = {
|
||||
'opened': ('⏳', 'Ожидает оплаты'),
|
||||
'pending': ('⏳', 'Ожидает оплаты'),
|
||||
'processing': ('⌛', 'Обрабатывается'),
|
||||
'paid': ('✅', 'Оплачено'),
|
||||
'closed': ('✅', 'Оплачено'),
|
||||
'declined': ('❌', 'Отклонено'),
|
||||
'canceled': ('❌', 'Отменено'),
|
||||
'expired': ('⌛', 'Истёк'),
|
||||
}
|
||||
return mapping.get(status_str, ('❓', 'Неизвестно'))
|
||||
|
||||
if record.method == PaymentMethod.PLATEGA:
|
||||
mapping = {
|
||||
'pending': ('⏳', 'Ожидает оплаты'),
|
||||
'inprogress': ('⌛', 'Обрабатывается'),
|
||||
'confirmed': ('✅', 'Оплачено'),
|
||||
'failed': ('❌', 'Ошибка'),
|
||||
'canceled': ('❌', 'Отменено'),
|
||||
'expired': ('⌛', 'Истёк'),
|
||||
}
|
||||
return mapping.get(status_str, ('❓', 'Неизвестно'))
|
||||
|
||||
if record.method == PaymentMethod.HELEKET:
|
||||
if status_str in {'pending', 'created', 'waiting', 'check', 'processing'}:
|
||||
return '⏳', 'Ожидает оплаты'
|
||||
if status_str in {'paid', 'paid_over'}:
|
||||
return '✅', 'Оплачено'
|
||||
if status_str in {'cancel', 'canceled', 'fail', 'failed', 'expired'}:
|
||||
return '❌', 'Отменено'
|
||||
return '❓', 'Неизвестно'
|
||||
|
||||
if record.method == PaymentMethod.YOOKASSA:
|
||||
mapping = {
|
||||
'pending': ('⏳', 'Ожидает оплаты'),
|
||||
'waiting_for_capture': ('⌛', 'Обрабатывается'),
|
||||
'succeeded': ('✅', 'Оплачено'),
|
||||
'canceled': ('❌', 'Отменено'),
|
||||
}
|
||||
return mapping.get(status_str, ('❓', 'Неизвестно'))
|
||||
|
||||
if record.method == PaymentMethod.CRYPTOBOT:
|
||||
mapping = {
|
||||
'active': ('⏳', 'Ожидает оплаты'),
|
||||
'paid': ('✅', 'Оплачено'),
|
||||
'expired': ('⌛', 'Истёк'),
|
||||
}
|
||||
return mapping.get(status_str, ('❓', 'Неизвестно'))
|
||||
|
||||
if record.method == PaymentMethod.CLOUDPAYMENTS:
|
||||
mapping = {
|
||||
'pending': ('⏳', 'Ожидает оплаты'),
|
||||
'authorized': ('⌛', 'Авторизовано'),
|
||||
'completed': ('✅', 'Оплачено'),
|
||||
'failed': ('❌', 'Ошибка'),
|
||||
}
|
||||
return mapping.get(status_str, ('❓', 'Неизвестно'))
|
||||
|
||||
if record.method == PaymentMethod.FREEKASSA:
|
||||
mapping = {
|
||||
'pending': ('⏳', 'Ожидает оплаты'),
|
||||
'success': ('✅', 'Оплачено'),
|
||||
'paid': ('✅', 'Оплачено'),
|
||||
'canceled': ('❌', 'Отменено'),
|
||||
'error': ('❌', 'Ошибка'),
|
||||
}
|
||||
return mapping.get(status_str, ('❓', 'Неизвестно'))
|
||||
|
||||
return '❓', 'Неизвестно'
|
||||
|
||||
|
||||
def _is_checkable(record: PendingPayment) -> bool:
|
||||
"""Check if payment can be manually checked."""
|
||||
if record.method not in SUPPORTED_MANUAL_CHECK_METHODS:
|
||||
return False
|
||||
if not record.is_recent():
|
||||
return False
|
||||
status_str = (record.status or '').lower()
|
||||
if record.method == PaymentMethod.PAL24:
|
||||
return status_str in {'new', 'process'}
|
||||
if record.method == PaymentMethod.MULENPAY:
|
||||
return status_str in {'created', 'processing', 'hold'}
|
||||
if record.method == PaymentMethod.WATA:
|
||||
return status_str in {'opened', 'pending', 'processing', 'inprogress', 'in_progress'}
|
||||
if record.method == PaymentMethod.PLATEGA:
|
||||
return status_str in {'pending', 'inprogress', 'in_progress'}
|
||||
if record.method == PaymentMethod.HELEKET:
|
||||
return status_str not in {'paid', 'paid_over', 'cancel', 'canceled', 'fail', 'failed', 'expired'}
|
||||
if record.method == PaymentMethod.YOOKASSA:
|
||||
return status_str in {'pending', 'waiting_for_capture'}
|
||||
if record.method == PaymentMethod.CRYPTOBOT:
|
||||
return status_str in {'active'}
|
||||
if record.method == PaymentMethod.CLOUDPAYMENTS:
|
||||
return status_str in {'pending', 'authorized'}
|
||||
if record.method == PaymentMethod.FREEKASSA:
|
||||
return status_str in {'pending', 'created', 'processing'}
|
||||
return False
|
||||
|
||||
|
||||
def _get_payment_url(record: PendingPayment) -> str | None:
|
||||
"""Extract payment URL from record."""
|
||||
payment = record.payment
|
||||
payment_url = getattr(payment, 'payment_url', None)
|
||||
|
||||
if record.method == PaymentMethod.PAL24:
|
||||
payment_url = getattr(payment, 'link_url', None) or getattr(payment, 'link_page_url', None) or payment_url
|
||||
elif record.method == PaymentMethod.WATA:
|
||||
payment_url = getattr(payment, 'url', None) or payment_url
|
||||
elif record.method == PaymentMethod.YOOKASSA:
|
||||
payment_url = getattr(payment, 'confirmation_url', None) or payment_url
|
||||
elif record.method == PaymentMethod.CRYPTOBOT:
|
||||
payment_url = (
|
||||
getattr(payment, 'bot_invoice_url', None)
|
||||
or getattr(payment, 'mini_app_invoice_url', None)
|
||||
or getattr(payment, 'web_app_invoice_url', None)
|
||||
or payment_url
|
||||
)
|
||||
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:
|
||||
payment_url = getattr(payment, 'payment_url', None) or payment_url
|
||||
|
||||
return payment_url
|
||||
|
||||
|
||||
def _record_to_response(record: PendingPayment) -> PendingPaymentResponse:
|
||||
"""Convert PendingPayment to API response."""
|
||||
status_emoji, status_text = _get_status_info(record)
|
||||
return PendingPaymentResponse(
|
||||
id=record.local_id,
|
||||
method=record.method.value,
|
||||
method_display=method_display_name(record.method),
|
||||
identifier=record.identifier,
|
||||
amount_kopeks=record.amount_kopeks,
|
||||
amount_rubles=record.amount_kopeks / 100,
|
||||
status=record.status or '',
|
||||
status_emoji=status_emoji,
|
||||
status_text=status_text,
|
||||
is_paid=record.is_paid,
|
||||
is_checkable=_is_checkable(record),
|
||||
created_at=record.created_at,
|
||||
expires_at=record.expires_at,
|
||||
payment_url=_get_payment_url(record),
|
||||
user_id=record.user.id if record.user else None,
|
||||
user_telegram_id=record.user.telegram_id if record.user else None,
|
||||
user_username=record.user.username if record.user else None,
|
||||
)
|
||||
|
||||
|
||||
# ============ Routes ============
|
||||
|
||||
|
||||
@router.get('', response_model=PendingPaymentListResponse)
|
||||
async def get_all_pending_payments(
|
||||
page: int = Query(1, ge=1, description='Page number'),
|
||||
per_page: int = Query(20, ge=1, le=100, description='Items per page'),
|
||||
method_filter: str | None = Query(None, description='Filter by payment method'),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get all pending payments for admin verification."""
|
||||
all_pending = await list_recent_pending_payments(db)
|
||||
|
||||
# Apply method filter if specified
|
||||
if method_filter:
|
||||
try:
|
||||
filter_method = PaymentMethod(method_filter)
|
||||
all_pending = [p for p in all_pending if p.method == filter_method]
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
total = len(all_pending)
|
||||
pages = math.ceil(total / per_page) if total > 0 else 1
|
||||
|
||||
# Paginate
|
||||
start_idx = (page - 1) * per_page
|
||||
page_payments = all_pending[start_idx : start_idx + per_page]
|
||||
|
||||
items = [_record_to_response(p) for p in page_payments]
|
||||
|
||||
return PendingPaymentListResponse(
|
||||
items=items,
|
||||
total=total,
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
pages=pages,
|
||||
)
|
||||
|
||||
|
||||
@router.get('/stats', response_model=PaymentsStatsResponse)
|
||||
async def get_payments_stats(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get statistics about pending payments."""
|
||||
all_pending = await list_recent_pending_payments(db)
|
||||
|
||||
by_method = {}
|
||||
for p in all_pending:
|
||||
method_name = method_display_name(p.method)
|
||||
if method_name not in by_method:
|
||||
by_method[method_name] = 0
|
||||
by_method[method_name] += 1
|
||||
|
||||
return PaymentsStatsResponse(
|
||||
total_pending=len(all_pending),
|
||||
by_method=by_method,
|
||||
)
|
||||
|
||||
|
||||
@router.get('/{method}/{payment_id}', response_model=PendingPaymentResponse)
|
||||
async def get_pending_payment_details(
|
||||
method: str,
|
||||
payment_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get details of a specific pending payment."""
|
||||
try:
|
||||
payment_method = PaymentMethod(method)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Invalid payment method: {method}',
|
||||
)
|
||||
|
||||
record = await get_payment_record(db, payment_method, payment_id)
|
||||
|
||||
if not record:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Payment not found',
|
||||
)
|
||||
|
||||
return _record_to_response(record)
|
||||
|
||||
|
||||
@router.post('/{method}/{payment_id}/check', response_model=ManualCheckResponse)
|
||||
async def check_payment_status(
|
||||
method: str,
|
||||
payment_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Manually check and update payment status."""
|
||||
try:
|
||||
payment_method = PaymentMethod(method)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Invalid payment method: {method}',
|
||||
)
|
||||
|
||||
# Get current record
|
||||
record = await get_payment_record(db, payment_method, payment_id)
|
||||
|
||||
if not record:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Payment not found',
|
||||
)
|
||||
|
||||
# Check if manual check is available
|
||||
if not _is_checkable(record):
|
||||
return ManualCheckResponse(
|
||||
success=False,
|
||||
message='Ручная проверка недоступна для этого платежа',
|
||||
payment=_record_to_response(record),
|
||||
status_changed=False,
|
||||
)
|
||||
|
||||
old_status = record.status
|
||||
old_is_paid = record.is_paid
|
||||
|
||||
# Run manual check
|
||||
payment_service = PaymentService()
|
||||
updated = await run_manual_check(db, payment_method, payment_id, payment_service)
|
||||
|
||||
if not updated:
|
||||
return ManualCheckResponse(
|
||||
success=False,
|
||||
message='Не удалось проверить статус платежа',
|
||||
payment=_record_to_response(record),
|
||||
status_changed=False,
|
||||
)
|
||||
|
||||
status_changed = updated.status != old_status or updated.is_paid != old_is_paid
|
||||
|
||||
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}')
|
||||
else:
|
||||
message = 'Статус не изменился'
|
||||
|
||||
return ManualCheckResponse(
|
||||
success=True,
|
||||
message=message,
|
||||
payment=_record_to_response(updated),
|
||||
status_changed=status_changed,
|
||||
old_status=old_status,
|
||||
new_status=updated.status,
|
||||
)
|
||||
@@ -0,0 +1,397 @@
|
||||
"""Admin routes for pinned messages in cabinet."""
|
||||
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
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 = logging.getLogger(__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(f'Admin {admin.id} created pinned message #{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.utcnow()
|
||||
await db.commit()
|
||||
await db.refresh(msg)
|
||||
|
||||
logger.info(f'Admin {admin.id} updated pinned message #{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.utcnow()
|
||||
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(f'Admin {admin.id} deactivated pinned message #{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(f'Admin {admin.id} unpinned active message: unpinned={unpinned_count}, failed={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.utcnow())
|
||||
)
|
||||
|
||||
msg.is_active = True
|
||||
msg.updated_at = datetime.utcnow()
|
||||
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(f'Admin {admin.id} activated pinned message #{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(f'Admin {admin.id} broadcast pinned message #{message_id}: sent={sent_count}, failed={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(f'Admin {admin.id} deleted pinned message #{message_id}')
|
||||
@@ -0,0 +1,637 @@
|
||||
"""Admin promo offers routes for cabinet."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.discount_offer import (
|
||||
count_discount_offers,
|
||||
list_discount_offers,
|
||||
upsert_discount_offer,
|
||||
)
|
||||
from app.database.crud.promo_offer_log import list_promo_offer_logs
|
||||
from app.database.crud.promo_offer_template import (
|
||||
ensure_default_templates,
|
||||
get_promo_offer_template_by_id,
|
||||
list_promo_offer_templates,
|
||||
update_promo_offer_template,
|
||||
)
|
||||
from app.database.crud.user import get_user_by_email, get_user_by_telegram_id
|
||||
from app.database.models import DiscountOffer, PromoOfferLog, PromoOfferTemplate, User
|
||||
from app.handlers.admin.messages import get_custom_users, get_target_users
|
||||
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__)
|
||||
|
||||
router = APIRouter(prefix='/admin/promo-offers', tags=['Admin Promo Offers'])
|
||||
|
||||
|
||||
# ============== Schemas ==============
|
||||
|
||||
|
||||
class PromoOfferUserInfo(BaseModel):
|
||||
id: int
|
||||
telegram_id: int | None = None # Can be None for email-only users
|
||||
email: str | None = None
|
||||
username: str | None = None
|
||||
first_name: str | None = None
|
||||
last_name: str | None = None
|
||||
full_name: str | None = None
|
||||
|
||||
|
||||
class PromoOfferResponse(BaseModel):
|
||||
id: int
|
||||
user_id: int
|
||||
subscription_id: int | None = None
|
||||
notification_type: str | None = None
|
||||
discount_percent: int | None = None
|
||||
bonus_amount_kopeks: int | None = None
|
||||
expires_at: datetime | None = None
|
||||
claimed_at: datetime | None = None
|
||||
is_active: bool
|
||||
effect_type: str | None = None
|
||||
extra_data: dict[str, Any] = Field(default_factory=dict)
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
user: PromoOfferUserInfo | None = None
|
||||
|
||||
|
||||
class PromoOfferListResponse(BaseModel):
|
||||
items: list[PromoOfferResponse]
|
||||
total: int
|
||||
limit: int
|
||||
offset: int
|
||||
|
||||
|
||||
class PromoOfferTemplateResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
offer_type: str
|
||||
message_text: str
|
||||
button_text: str
|
||||
valid_hours: int
|
||||
discount_percent: int
|
||||
bonus_amount_kopeks: int
|
||||
active_discount_hours: int | None = None
|
||||
test_duration_hours: int | None = None
|
||||
test_squad_uuids: list[str] = Field(default_factory=list)
|
||||
is_active: bool
|
||||
created_by: int | None = None
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
|
||||
|
||||
class PromoOfferTemplateListResponse(BaseModel):
|
||||
items: list[PromoOfferTemplateResponse]
|
||||
|
||||
|
||||
class PromoOfferTemplateUpdateRequest(BaseModel):
|
||||
name: str | None = None
|
||||
message_text: str | None = None
|
||||
button_text: str | None = None
|
||||
valid_hours: int | None = Field(None, ge=1)
|
||||
discount_percent: int | None = Field(None, ge=0)
|
||||
bonus_amount_kopeks: int | None = Field(None, ge=0)
|
||||
active_discount_hours: int | None = Field(None, ge=1)
|
||||
test_duration_hours: int | None = Field(None, ge=1)
|
||||
test_squad_uuids: list[str] | None = None
|
||||
is_active: bool | None = None
|
||||
|
||||
|
||||
class PromoOfferBroadcastRequest(BaseModel):
|
||||
notification_type: str = Field(..., min_length=1)
|
||||
valid_hours: int = Field(..., ge=1)
|
||||
discount_percent: int = Field(0, ge=0)
|
||||
bonus_amount_kopeks: int = Field(0, ge=0)
|
||||
effect_type: str = Field('percent_discount', min_length=1)
|
||||
extra_data: dict[str, Any] = Field(default_factory=dict)
|
||||
target: str | None = None
|
||||
user_id: int | None = None
|
||||
telegram_id: int | None = None
|
||||
email: str | None = Field(None, description='User email (for email-only users)')
|
||||
# Telegram notification options
|
||||
send_notification: bool = Field(False, description='Send Telegram notification to users')
|
||||
message_text: str | None = Field(None, description='Custom message text (HTML)')
|
||||
button_text: str | None = Field(None, description='Button text')
|
||||
|
||||
|
||||
class PromoOfferBroadcastResponse(BaseModel):
|
||||
created_offers: int
|
||||
user_ids: list[int]
|
||||
target: str | None = None
|
||||
notifications_sent: int = 0
|
||||
notifications_failed: int = 0
|
||||
|
||||
|
||||
class PromoOfferLogOfferInfo(BaseModel):
|
||||
id: int
|
||||
notification_type: str | None = None
|
||||
discount_percent: int | None = None
|
||||
bonus_amount_kopeks: int | None = None
|
||||
effect_type: str | None = None
|
||||
expires_at: datetime | None = None
|
||||
claimed_at: datetime | None = None
|
||||
is_active: bool | None = None
|
||||
|
||||
|
||||
class PromoOfferLogResponse(BaseModel):
|
||||
id: int
|
||||
user_id: int | None = None
|
||||
offer_id: int | None = None
|
||||
action: str
|
||||
source: str | None = None
|
||||
percent: int | None = None
|
||||
effect_type: str | None = None
|
||||
details: dict[str, Any] = Field(default_factory=dict)
|
||||
created_at: datetime
|
||||
user: PromoOfferUserInfo | None = None
|
||||
offer: PromoOfferLogOfferInfo | None = None
|
||||
|
||||
|
||||
class PromoOfferLogListResponse(BaseModel):
|
||||
items: list[PromoOfferLogResponse]
|
||||
total: int
|
||||
limit: int
|
||||
offset: int
|
||||
|
||||
|
||||
# ============== Helpers ==============
|
||||
|
||||
|
||||
def _serialize_user(user: User | None) -> PromoOfferUserInfo | None:
|
||||
if not user:
|
||||
return None
|
||||
return PromoOfferUserInfo(
|
||||
id=user.id,
|
||||
telegram_id=user.telegram_id,
|
||||
email=user.email,
|
||||
username=user.username,
|
||||
first_name=user.first_name,
|
||||
last_name=user.last_name,
|
||||
full_name=getattr(user, 'full_name', None),
|
||||
)
|
||||
|
||||
|
||||
def _serialize_offer(offer: DiscountOffer) -> PromoOfferResponse:
|
||||
return PromoOfferResponse(
|
||||
id=offer.id,
|
||||
user_id=offer.user_id,
|
||||
subscription_id=offer.subscription_id,
|
||||
notification_type=offer.notification_type,
|
||||
discount_percent=offer.discount_percent,
|
||||
bonus_amount_kopeks=offer.bonus_amount_kopeks,
|
||||
expires_at=offer.expires_at,
|
||||
claimed_at=offer.claimed_at,
|
||||
is_active=offer.is_active,
|
||||
effect_type=offer.effect_type,
|
||||
extra_data=offer.extra_data or {},
|
||||
created_at=offer.created_at,
|
||||
updated_at=offer.updated_at,
|
||||
user=_serialize_user(getattr(offer, 'user', None)),
|
||||
)
|
||||
|
||||
|
||||
def _serialize_template(template: PromoOfferTemplate) -> PromoOfferTemplateResponse:
|
||||
return PromoOfferTemplateResponse(
|
||||
id=template.id,
|
||||
name=template.name,
|
||||
offer_type=template.offer_type,
|
||||
message_text=template.message_text,
|
||||
button_text=template.button_text,
|
||||
valid_hours=template.valid_hours,
|
||||
discount_percent=template.discount_percent,
|
||||
bonus_amount_kopeks=template.bonus_amount_kopeks,
|
||||
active_discount_hours=template.active_discount_hours,
|
||||
test_duration_hours=template.test_duration_hours,
|
||||
test_squad_uuids=[str(uuid) for uuid in (template.test_squad_uuids or [])],
|
||||
is_active=template.is_active,
|
||||
created_by=template.created_by,
|
||||
created_at=template.created_at,
|
||||
updated_at=template.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def _serialize_log(entry: PromoOfferLog) -> PromoOfferLogResponse:
|
||||
user_info = _serialize_user(getattr(entry, 'user', None))
|
||||
|
||||
offer = getattr(entry, 'offer', None)
|
||||
offer_info: PromoOfferLogOfferInfo | None = None
|
||||
if offer:
|
||||
offer_info = PromoOfferLogOfferInfo(
|
||||
id=offer.id,
|
||||
notification_type=offer.notification_type,
|
||||
discount_percent=offer.discount_percent,
|
||||
bonus_amount_kopeks=offer.bonus_amount_kopeks,
|
||||
effect_type=offer.effect_type,
|
||||
expires_at=offer.expires_at,
|
||||
claimed_at=offer.claimed_at,
|
||||
is_active=offer.is_active,
|
||||
)
|
||||
|
||||
return PromoOfferLogResponse(
|
||||
id=entry.id,
|
||||
user_id=entry.user_id,
|
||||
offer_id=entry.offer_id,
|
||||
action=entry.action,
|
||||
source=entry.source,
|
||||
percent=entry.percent,
|
||||
effect_type=entry.effect_type,
|
||||
details=entry.details or {},
|
||||
created_at=entry.created_at,
|
||||
user=user_info,
|
||||
offer=offer_info,
|
||||
)
|
||||
|
||||
|
||||
async def _resolve_target_users(db: AsyncSession, target: str) -> list[User]:
|
||||
normalized = target.strip().lower()
|
||||
if normalized.startswith('custom_'):
|
||||
criteria = normalized[len('custom_') :]
|
||||
return await get_custom_users(db, criteria)
|
||||
return await get_target_users(db, normalized)
|
||||
|
||||
|
||||
# ============== Template Endpoints ==============
|
||||
|
||||
|
||||
@router.get('/templates', response_model=PromoOfferTemplateListResponse)
|
||||
async def list_templates(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> PromoOfferTemplateListResponse:
|
||||
"""Get list of promo offer templates."""
|
||||
templates = await list_promo_offer_templates(db)
|
||||
|
||||
# Initialize default templates if none exist
|
||||
if not templates:
|
||||
templates = await ensure_default_templates(db, created_by=admin.id)
|
||||
|
||||
return PromoOfferTemplateListResponse(items=[_serialize_template(template) for template in templates])
|
||||
|
||||
|
||||
@router.get('/templates/{template_id}', response_model=PromoOfferTemplateResponse)
|
||||
async def get_template(
|
||||
template_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> PromoOfferTemplateResponse:
|
||||
"""Get a promo offer template."""
|
||||
template = await get_promo_offer_template_by_id(db, template_id)
|
||||
if not template:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Template not found')
|
||||
return _serialize_template(template)
|
||||
|
||||
|
||||
@router.patch('/templates/{template_id}', response_model=PromoOfferTemplateResponse)
|
||||
async def update_template(
|
||||
template_id: int,
|
||||
payload: PromoOfferTemplateUpdateRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> PromoOfferTemplateResponse:
|
||||
"""Update a promo offer template."""
|
||||
template = await get_promo_offer_template_by_id(db, template_id)
|
||||
if not template:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Template not found')
|
||||
|
||||
if payload.test_squad_uuids is not None:
|
||||
normalized_squads = [str(uuid).strip() for uuid in payload.test_squad_uuids if str(uuid).strip()]
|
||||
else:
|
||||
normalized_squads = None
|
||||
|
||||
updated_template = await update_promo_offer_template(
|
||||
db,
|
||||
template,
|
||||
name=payload.name,
|
||||
message_text=payload.message_text,
|
||||
button_text=payload.button_text,
|
||||
valid_hours=payload.valid_hours,
|
||||
discount_percent=payload.discount_percent,
|
||||
bonus_amount_kopeks=payload.bonus_amount_kopeks,
|
||||
active_discount_hours=payload.active_discount_hours,
|
||||
test_duration_hours=payload.test_duration_hours,
|
||||
test_squad_uuids=normalized_squads,
|
||||
is_active=payload.is_active,
|
||||
)
|
||||
|
||||
return _serialize_template(updated_template)
|
||||
|
||||
|
||||
# ============== Offer Endpoints ==============
|
||||
|
||||
|
||||
@router.get('', response_model=PromoOfferListResponse)
|
||||
async def list_offers(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
user_id: int | None = Query(None, ge=1),
|
||||
is_active: bool | None = Query(None),
|
||||
) -> PromoOfferListResponse:
|
||||
"""Get list of promo offers."""
|
||||
offers = await list_discount_offers(
|
||||
db,
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
user_id=user_id,
|
||||
is_active=is_active,
|
||||
)
|
||||
total = await count_discount_offers(
|
||||
db,
|
||||
user_id=user_id,
|
||||
is_active=is_active,
|
||||
)
|
||||
|
||||
return PromoOfferListResponse(
|
||||
items=[_serialize_offer(offer) for offer in offers],
|
||||
total=total,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
|
||||
def _get_bot() -> Bot:
|
||||
"""Create bot instance for sending notifications."""
|
||||
return Bot(
|
||||
token=settings.BOT_TOKEN,
|
||||
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
|
||||
)
|
||||
|
||||
|
||||
def _build_default_promo_message(
|
||||
discount_percent: int,
|
||||
bonus_amount_kopeks: int,
|
||||
valid_hours: int,
|
||||
) -> str:
|
||||
"""Build default promo notification message."""
|
||||
lines = ['🎁 <b>Специальное предложение для вас!</b>\n']
|
||||
|
||||
if discount_percent > 0:
|
||||
lines.append(f'🔥 Скидка <b>{discount_percent}%</b> на подписку')
|
||||
if bonus_amount_kopeks > 0:
|
||||
bonus_rub = bonus_amount_kopeks / 100
|
||||
lines.append(f'💰 Бонус <b>{bonus_rub:.0f}₽</b> на баланс')
|
||||
|
||||
lines.append(f'\n⏰ Предложение действует <b>{valid_hours} ч.</b>')
|
||||
lines.append('\nНажмите кнопку ниже, чтобы активировать!')
|
||||
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
async def _send_promo_notifications(
|
||||
offers_to_notify: list[tuple[User, DiscountOffer]],
|
||||
message_text: str | None,
|
||||
button_text: str | None,
|
||||
discount_percent: int,
|
||||
bonus_amount_kopeks: int,
|
||||
valid_hours: int,
|
||||
) -> tuple[int, int]:
|
||||
"""Send Telegram notifications for promo offers.
|
||||
|
||||
Returns:
|
||||
Tuple of (sent_count, failed_count)
|
||||
"""
|
||||
if not offers_to_notify:
|
||||
return 0, 0
|
||||
|
||||
bot = _get_bot()
|
||||
sent = 0
|
||||
failed = 0
|
||||
|
||||
# Build message text
|
||||
text = message_text or _build_default_promo_message(
|
||||
discount_percent=discount_percent,
|
||||
bonus_amount_kopeks=bonus_amount_kopeks,
|
||||
valid_hours=valid_hours,
|
||||
)
|
||||
|
||||
# Default button text
|
||||
btn_text = button_text or '🎁 Получить'
|
||||
|
||||
semaphore = asyncio.Semaphore(20)
|
||||
|
||||
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}')
|
||||
return False
|
||||
|
||||
async with semaphore:
|
||||
try:
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
build_miniapp_or_callback_button(
|
||||
text=btn_text,
|
||||
callback_data=f'claim_discount_{offer.id}',
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text='❌ Закрыть',
|
||||
callback_data='promo_offer_close',
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
await bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=text,
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
return True
|
||||
except (TelegramForbiddenError, TelegramBadRequest) as exc:
|
||||
logger.warning(
|
||||
'Failed to send promo notification to user %s: %s',
|
||||
user.telegram_id,
|
||||
exc,
|
||||
)
|
||||
return False
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
'Error sending promo notification to user %s: %s',
|
||||
user.telegram_id,
|
||||
exc,
|
||||
)
|
||||
return False
|
||||
|
||||
# Send in batches
|
||||
batch_size = 50
|
||||
for i in range(0, len(offers_to_notify), batch_size):
|
||||
batch = offers_to_notify[i : i + batch_size]
|
||||
tasks = [send_single(user, offer) for user, offer in batch]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
for result in results:
|
||||
if isinstance(result, bool) and result:
|
||||
sent += 1
|
||||
else:
|
||||
failed += 1
|
||||
|
||||
# Small delay between batches
|
||||
if i + batch_size < len(offers_to_notify):
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
# Close bot session
|
||||
await bot.session.close()
|
||||
|
||||
return sent, failed
|
||||
|
||||
|
||||
@router.post('/broadcast', response_model=PromoOfferBroadcastResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def broadcast_offer(
|
||||
payload: PromoOfferBroadcastRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> PromoOfferBroadcastResponse:
|
||||
"""Broadcast promo offer to users with optional Telegram notification."""
|
||||
recipients: dict[int, User] = {}
|
||||
|
||||
# Resolve target segment
|
||||
if payload.target:
|
||||
users = await _resolve_target_users(db, payload.target)
|
||||
recipients.update({user.id: user for user in users if user and user.id})
|
||||
|
||||
# Resolve specific user
|
||||
target_user_id = payload.user_id
|
||||
user: User | None = None
|
||||
|
||||
if payload.telegram_id is not None:
|
||||
user = await get_user_by_telegram_id(db, payload.telegram_id)
|
||||
if not user:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, 'User not found by telegram_id')
|
||||
if target_user_id and target_user_id != user.id:
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
'Provided user_id does not match telegram_id',
|
||||
)
|
||||
target_user_id = user.id
|
||||
|
||||
# Support email lookup for email-only users
|
||||
if payload.email is not None and user is None:
|
||||
user = await get_user_by_email(db, payload.email)
|
||||
if not user:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, 'User not found by email')
|
||||
if target_user_id and target_user_id != user.id:
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
'Provided user_id does not match email',
|
||||
)
|
||||
target_user_id = user.id
|
||||
|
||||
if target_user_id is not None:
|
||||
if user is None:
|
||||
user = await db.get(User, target_user_id)
|
||||
if not user:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, 'User not found')
|
||||
recipients[target_user_id] = user
|
||||
|
||||
if not recipients:
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
'No recipients: specify target or user',
|
||||
)
|
||||
|
||||
# Create offers for all recipients and collect (user, offer) pairs
|
||||
created_offers = 0
|
||||
offers_to_notify: list[tuple[User, DiscountOffer]] = []
|
||||
|
||||
for recipient in recipients.values():
|
||||
offer = await upsert_discount_offer(
|
||||
db,
|
||||
user_id=recipient.id,
|
||||
subscription_id=None,
|
||||
notification_type=payload.notification_type.strip(),
|
||||
discount_percent=payload.discount_percent,
|
||||
bonus_amount_kopeks=payload.bonus_amount_kopeks,
|
||||
valid_hours=payload.valid_hours,
|
||||
effect_type=payload.effect_type,
|
||||
extra_data=payload.extra_data,
|
||||
)
|
||||
if offer:
|
||||
created_offers += 1
|
||||
offers_to_notify.append((recipient, offer))
|
||||
|
||||
# Send Telegram notifications if requested
|
||||
notifications_sent = 0
|
||||
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=rendered_message_text,
|
||||
button_text=payload.button_text,
|
||||
discount_percent=payload.discount_percent,
|
||||
bonus_amount_kopeks=payload.bonus_amount_kopeks,
|
||||
valid_hours=payload.valid_hours,
|
||||
)
|
||||
|
||||
return PromoOfferBroadcastResponse(
|
||||
created_offers=created_offers,
|
||||
user_ids=list(recipients.keys()),
|
||||
target=payload.target,
|
||||
notifications_sent=notifications_sent,
|
||||
notifications_failed=notifications_failed,
|
||||
)
|
||||
|
||||
|
||||
# ============== Log Endpoints ==============
|
||||
|
||||
|
||||
@router.get('/logs', response_model=PromoOfferLogListResponse)
|
||||
async def get_logs(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
user_id: int | None = Query(None, ge=1),
|
||||
action: str | None = Query(None, min_length=1),
|
||||
) -> PromoOfferLogListResponse:
|
||||
"""Get promo offer logs."""
|
||||
logs, total = await list_promo_offer_logs(
|
||||
db,
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
user_id=user_id,
|
||||
action=action,
|
||||
)
|
||||
|
||||
return PromoOfferLogListResponse(
|
||||
items=[_serialize_log(entry) for entry in logs],
|
||||
total=int(total),
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
@@ -0,0 +1,660 @@
|
||||
"""Admin promocodes routes for cabinet."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.crud.promo_group import (
|
||||
count_promo_group_members,
|
||||
count_promo_groups,
|
||||
create_promo_group,
|
||||
delete_promo_group,
|
||||
get_promo_group_by_id,
|
||||
get_promo_groups_with_counts,
|
||||
update_promo_group,
|
||||
)
|
||||
from app.database.crud.promocode import (
|
||||
create_promocode,
|
||||
delete_promocode,
|
||||
get_promocode_by_code,
|
||||
get_promocode_by_id,
|
||||
get_promocode_statistics,
|
||||
get_promocodes_count,
|
||||
get_promocodes_list,
|
||||
update_promocode,
|
||||
)
|
||||
from app.database.models import PromoCode, PromoCodeType, PromoCodeUse, PromoGroup, User
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
|
||||
|
||||
router = APIRouter(prefix='/admin/promocodes', tags=['Admin Promocodes'])
|
||||
|
||||
|
||||
# ============== Schemas ==============
|
||||
|
||||
|
||||
class PromoCodeResponse(BaseModel):
|
||||
id: int
|
||||
code: str
|
||||
type: PromoCodeType
|
||||
balance_bonus_kopeks: int
|
||||
balance_bonus_rubles: float
|
||||
subscription_days: int
|
||||
max_uses: int
|
||||
current_uses: int
|
||||
uses_left: int
|
||||
is_active: bool
|
||||
is_valid: bool
|
||||
first_purchase_only: bool
|
||||
valid_from: datetime
|
||||
valid_until: datetime | None = None
|
||||
promo_group_id: int | None = None
|
||||
created_by: int | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class PromoCodeListResponse(BaseModel):
|
||||
items: list[PromoCodeResponse]
|
||||
total: int
|
||||
limit: int
|
||||
offset: int
|
||||
|
||||
|
||||
class PromoCodeRecentUse(BaseModel):
|
||||
id: int
|
||||
user_id: int
|
||||
user_username: str | None = None
|
||||
user_full_name: str | None = None
|
||||
user_telegram_id: int | None = None
|
||||
used_at: datetime
|
||||
|
||||
|
||||
class PromoCodeDetailResponse(PromoCodeResponse):
|
||||
total_uses: int
|
||||
today_uses: int
|
||||
recent_uses: list[PromoCodeRecentUse] = Field(default_factory=list)
|
||||
|
||||
|
||||
class PromoCodeCreateRequest(BaseModel):
|
||||
code: str = Field(..., min_length=1, max_length=50)
|
||||
type: PromoCodeType
|
||||
balance_bonus_kopeks: int = 0
|
||||
subscription_days: int = 0
|
||||
max_uses: int = Field(default=1, ge=0)
|
||||
valid_from: datetime | None = None
|
||||
valid_until: datetime | None = None
|
||||
is_active: bool = True
|
||||
first_purchase_only: bool = False
|
||||
promo_group_id: int | None = None
|
||||
|
||||
|
||||
class PromoCodeUpdateRequest(BaseModel):
|
||||
code: str | None = Field(default=None, min_length=1, max_length=50)
|
||||
type: PromoCodeType | None = None
|
||||
balance_bonus_kopeks: int | None = None
|
||||
subscription_days: int | None = None
|
||||
max_uses: int | None = Field(default=None, ge=0)
|
||||
valid_from: datetime | None = None
|
||||
valid_until: datetime | None = None
|
||||
is_active: bool | None = None
|
||||
first_purchase_only: bool | None = None
|
||||
promo_group_id: int | None = None
|
||||
|
||||
|
||||
# ============== PromoGroup Schemas ==============
|
||||
|
||||
|
||||
class PromoGroupResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
server_discount_percent: int
|
||||
traffic_discount_percent: int
|
||||
device_discount_percent: int
|
||||
period_discounts: dict[int, int] = Field(default_factory=dict)
|
||||
auto_assign_total_spent_kopeks: int | None = None
|
||||
apply_discounts_to_addons: bool
|
||||
is_default: bool
|
||||
members_count: int = 0
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
|
||||
|
||||
class PromoGroupListResponse(BaseModel):
|
||||
items: list[PromoGroupResponse]
|
||||
total: int
|
||||
limit: int
|
||||
offset: int
|
||||
|
||||
|
||||
class PromoGroupCreateRequest(BaseModel):
|
||||
name: str
|
||||
server_discount_percent: int = 0
|
||||
traffic_discount_percent: int = 0
|
||||
device_discount_percent: int = 0
|
||||
period_discounts: dict[int, int] | None = None
|
||||
auto_assign_total_spent_kopeks: int | None = None
|
||||
apply_discounts_to_addons: bool = True
|
||||
is_default: bool = False
|
||||
|
||||
|
||||
class PromoGroupUpdateRequest(BaseModel):
|
||||
name: str | None = None
|
||||
server_discount_percent: int | None = None
|
||||
traffic_discount_percent: int | None = None
|
||||
device_discount_percent: int | None = None
|
||||
period_discounts: dict[int, int] | None = None
|
||||
auto_assign_total_spent_kopeks: int | None = None
|
||||
apply_discounts_to_addons: bool | None = None
|
||||
is_default: bool | None = None
|
||||
|
||||
|
||||
# ============== Helpers ==============
|
||||
|
||||
|
||||
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)
|
||||
if value.tzinfo is not None:
|
||||
return value.replace(tzinfo=None)
|
||||
return value
|
||||
|
||||
|
||||
def _serialize_promocode(promocode: PromoCode) -> PromoCodeResponse:
|
||||
promo_type = PromoCodeType(promocode.type)
|
||||
return PromoCodeResponse(
|
||||
id=promocode.id,
|
||||
code=promocode.code,
|
||||
type=promo_type,
|
||||
balance_bonus_kopeks=promocode.balance_bonus_kopeks,
|
||||
balance_bonus_rubles=round(promocode.balance_bonus_kopeks / 100, 2),
|
||||
subscription_days=promocode.subscription_days,
|
||||
max_uses=promocode.max_uses,
|
||||
current_uses=promocode.current_uses,
|
||||
uses_left=promocode.uses_left,
|
||||
is_active=promocode.is_active,
|
||||
is_valid=promocode.is_valid,
|
||||
first_purchase_only=promocode.first_purchase_only,
|
||||
valid_from=promocode.valid_from,
|
||||
valid_until=promocode.valid_until,
|
||||
promo_group_id=promocode.promo_group_id,
|
||||
created_by=promocode.created_by,
|
||||
created_at=promocode.created_at,
|
||||
updated_at=promocode.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def _serialize_recent_use(use: PromoCodeUse) -> PromoCodeRecentUse:
|
||||
return PromoCodeRecentUse(
|
||||
id=use.id,
|
||||
user_id=use.user_id,
|
||||
user_username=getattr(use, 'user_username', None),
|
||||
user_full_name=getattr(use, 'user_full_name', None),
|
||||
user_telegram_id=getattr(use, 'user_telegram_id', None),
|
||||
used_at=use.used_at,
|
||||
)
|
||||
|
||||
|
||||
def _normalize_period_discounts(group: PromoGroup) -> dict[int, int]:
|
||||
raw = group.period_discounts or {}
|
||||
normalized: dict[int, int] = {}
|
||||
if isinstance(raw, dict):
|
||||
for key, value in raw.items():
|
||||
try:
|
||||
normalized[int(key)] = int(value)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return normalized
|
||||
|
||||
|
||||
def _serialize_promo_group(group: PromoGroup, members_count: int = 0) -> PromoGroupResponse:
|
||||
return PromoGroupResponse(
|
||||
id=group.id,
|
||||
name=group.name,
|
||||
server_discount_percent=group.server_discount_percent,
|
||||
traffic_discount_percent=group.traffic_discount_percent,
|
||||
device_discount_percent=group.device_discount_percent,
|
||||
period_discounts=_normalize_period_discounts(group),
|
||||
auto_assign_total_spent_kopeks=group.auto_assign_total_spent_kopeks,
|
||||
apply_discounts_to_addons=group.apply_discounts_to_addons,
|
||||
is_default=group.is_default,
|
||||
members_count=members_count,
|
||||
created_at=getattr(group, 'created_at', None),
|
||||
updated_at=getattr(group, 'updated_at', None),
|
||||
)
|
||||
|
||||
|
||||
def _validate_create_payload(payload: PromoCodeCreateRequest) -> None:
|
||||
code = payload.code.strip()
|
||||
if not code:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Code must not be empty')
|
||||
|
||||
normalized_valid_from = _normalize_datetime(payload.valid_from)
|
||||
normalized_valid_until = _normalize_datetime(payload.valid_until)
|
||||
|
||||
if payload.type == PromoCodeType.BALANCE and payload.balance_bonus_kopeks <= 0:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Balance bonus must be positive for balance promo codes')
|
||||
|
||||
if payload.type in {PromoCodeType.SUBSCRIPTION_DAYS, PromoCodeType.TRIAL_SUBSCRIPTION}:
|
||||
if payload.subscription_days <= 0:
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST, 'Subscription days must be positive for this promo code type'
|
||||
)
|
||||
|
||||
if payload.type == PromoCodeType.DISCOUNT:
|
||||
if payload.balance_bonus_kopeks <= 0 or payload.balance_bonus_kopeks > 100:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Discount percent must be between 1 and 100')
|
||||
if payload.subscription_days <= 0:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Discount validity hours must be positive')
|
||||
|
||||
if normalized_valid_from and normalized_valid_until and normalized_valid_from > normalized_valid_until:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, 'valid_from cannot be greater than valid_until')
|
||||
|
||||
|
||||
def _validate_update_payload(payload: PromoCodeUpdateRequest, promocode: PromoCode) -> None:
|
||||
if payload.code is not None and not payload.code.strip():
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Code must not be empty')
|
||||
|
||||
if payload.type is not None:
|
||||
new_type = payload.type
|
||||
else:
|
||||
new_type = PromoCodeType(promocode.type)
|
||||
|
||||
balance_bonus = (
|
||||
payload.balance_bonus_kopeks if payload.balance_bonus_kopeks is not None else promocode.balance_bonus_kopeks
|
||||
)
|
||||
subscription_days = (
|
||||
payload.subscription_days if payload.subscription_days is not None else promocode.subscription_days
|
||||
)
|
||||
|
||||
if new_type == PromoCodeType.BALANCE and balance_bonus <= 0:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Balance bonus must be positive for balance promo codes')
|
||||
|
||||
if new_type in {PromoCodeType.SUBSCRIPTION_DAYS, PromoCodeType.TRIAL_SUBSCRIPTION}:
|
||||
if subscription_days <= 0:
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST, 'Subscription days must be positive for this promo code type'
|
||||
)
|
||||
|
||||
if new_type == PromoCodeType.DISCOUNT:
|
||||
if balance_bonus <= 0 or balance_bonus > 100:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Discount percent must be between 1 and 100')
|
||||
if subscription_days <= 0:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Discount validity hours must be positive')
|
||||
|
||||
valid_from = _normalize_datetime(payload.valid_from) if payload.valid_from is not None else promocode.valid_from
|
||||
valid_until = _normalize_datetime(payload.valid_until) if payload.valid_until is not None else promocode.valid_until
|
||||
|
||||
if valid_from and valid_until and valid_from > valid_until:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, 'valid_from cannot be greater than valid_until')
|
||||
|
||||
if payload.max_uses is not None and payload.max_uses != 0 and payload.max_uses < promocode.current_uses:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, 'max_uses cannot be less than current uses')
|
||||
|
||||
|
||||
# ============== Promocode Endpoints ==============
|
||||
|
||||
|
||||
@router.get('', response_model=PromoCodeListResponse)
|
||||
async def list_promocodes(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
is_active: bool | None = Query(default=None),
|
||||
) -> PromoCodeListResponse:
|
||||
"""Get list of all promocodes."""
|
||||
total = await get_promocodes_count(db, is_active=is_active) or 0
|
||||
promocodes = await get_promocodes_list(db, offset=offset, limit=limit, is_active=is_active)
|
||||
|
||||
return PromoCodeListResponse(
|
||||
items=[_serialize_promocode(promocode) for promocode in promocodes],
|
||||
total=int(total),
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
|
||||
@router.get('/{promocode_id}', response_model=PromoCodeDetailResponse)
|
||||
async def get_promocode(
|
||||
promocode_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> PromoCodeDetailResponse:
|
||||
"""Get promocode details with usage statistics."""
|
||||
promocode = await get_promocode_by_id(db, promocode_id)
|
||||
if not promocode:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Promo code not found')
|
||||
|
||||
stats = await get_promocode_statistics(db, promocode_id)
|
||||
base = _serialize_promocode(promocode)
|
||||
recent_uses = [_serialize_recent_use(use) for use in stats.get('recent_uses', [])]
|
||||
|
||||
return PromoCodeDetailResponse(
|
||||
**base.model_dump(),
|
||||
total_uses=stats.get('total_uses', 0),
|
||||
today_uses=stats.get('today_uses', 0),
|
||||
recent_uses=recent_uses,
|
||||
)
|
||||
|
||||
|
||||
@router.post('', response_model=PromoCodeResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_promocode_endpoint(
|
||||
payload: PromoCodeCreateRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> PromoCodeResponse:
|
||||
"""Create a new promocode."""
|
||||
_validate_create_payload(payload)
|
||||
|
||||
normalized_code = payload.code.strip().upper()
|
||||
normalized_valid_from = _normalize_datetime(payload.valid_from)
|
||||
normalized_valid_until = _normalize_datetime(payload.valid_until)
|
||||
|
||||
existing = await get_promocode_by_code(db, normalized_code)
|
||||
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=effective_max_uses,
|
||||
valid_until=normalized_valid_until,
|
||||
created_by=admin.id,
|
||||
)
|
||||
|
||||
update_fields = {}
|
||||
if normalized_valid_from is not None:
|
||||
update_fields['valid_from'] = normalized_valid_from
|
||||
if payload.is_active is not None and payload.is_active != promocode.is_active:
|
||||
update_fields['is_active'] = payload.is_active
|
||||
if normalized_valid_until is not None:
|
||||
update_fields['valid_until'] = normalized_valid_until
|
||||
if payload.first_purchase_only:
|
||||
update_fields['first_purchase_only'] = payload.first_purchase_only
|
||||
if payload.promo_group_id is not None:
|
||||
update_fields['promo_group_id'] = payload.promo_group_id
|
||||
|
||||
if update_fields:
|
||||
promocode = await update_promocode(db, promocode, **update_fields)
|
||||
|
||||
return _serialize_promocode(promocode)
|
||||
|
||||
|
||||
@router.patch('/{promocode_id}', response_model=PromoCodeResponse)
|
||||
async def update_promocode_endpoint(
|
||||
promocode_id: int,
|
||||
payload: PromoCodeUpdateRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> PromoCodeResponse:
|
||||
"""Update an existing promocode."""
|
||||
promocode = await get_promocode_by_id(db, promocode_id)
|
||||
if not promocode:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Promo code not found')
|
||||
|
||||
_validate_update_payload(payload, promocode)
|
||||
|
||||
updates: dict[str, Any] = {}
|
||||
|
||||
if payload.code is not None:
|
||||
normalized_code = payload.code.strip().upper()
|
||||
if normalized_code != promocode.code:
|
||||
existing = await get_promocode_by_code(db, normalized_code)
|
||||
if existing and existing.id != promocode_id:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Promo code with this code already exists')
|
||||
updates['code'] = normalized_code
|
||||
|
||||
if payload.type is not None:
|
||||
updates['type'] = payload.type.value
|
||||
|
||||
if payload.balance_bonus_kopeks is not None:
|
||||
updates['balance_bonus_kopeks'] = payload.balance_bonus_kopeks
|
||||
|
||||
if payload.subscription_days is not None:
|
||||
updates['subscription_days'] = payload.subscription_days
|
||||
|
||||
if payload.max_uses is not None:
|
||||
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)
|
||||
|
||||
if payload.valid_until is not None:
|
||||
updates['valid_until'] = _normalize_datetime(payload.valid_until)
|
||||
|
||||
if payload.is_active is not None:
|
||||
updates['is_active'] = payload.is_active
|
||||
|
||||
if payload.first_purchase_only is not None:
|
||||
updates['first_purchase_only'] = payload.first_purchase_only
|
||||
|
||||
if payload.promo_group_id is not None:
|
||||
updates['promo_group_id'] = payload.promo_group_id
|
||||
|
||||
if not updates:
|
||||
return _serialize_promocode(promocode)
|
||||
|
||||
promocode = await update_promocode(db, promocode, **updates)
|
||||
return _serialize_promocode(promocode)
|
||||
|
||||
|
||||
@router.delete(
|
||||
'/{promocode_id}',
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
response_class=Response,
|
||||
)
|
||||
async def delete_promocode_endpoint(
|
||||
promocode_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> Response:
|
||||
"""Delete a promocode."""
|
||||
promocode = await get_promocode_by_id(db, promocode_id)
|
||||
if not promocode:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Promo code not found')
|
||||
|
||||
success = await delete_promocode(db, promocode)
|
||||
if not success:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Failed to delete promo code')
|
||||
|
||||
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'])
|
||||
|
||||
|
||||
@promo_groups_router.get('', response_model=PromoGroupListResponse)
|
||||
async def list_promo_groups(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
) -> PromoGroupListResponse:
|
||||
"""Get list of all promo groups."""
|
||||
total = await count_promo_groups(db)
|
||||
groups_with_counts = await get_promo_groups_with_counts(
|
||||
db,
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
return PromoGroupListResponse(
|
||||
items=[_serialize_promo_group(group, members_count=count) for group, count in groups_with_counts],
|
||||
total=total,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
|
||||
@promo_groups_router.get('/{group_id}', response_model=PromoGroupResponse)
|
||||
async def get_promo_group(
|
||||
group_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> PromoGroupResponse:
|
||||
"""Get promo group details."""
|
||||
group = await get_promo_group_by_id(db, group_id)
|
||||
if not group:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Promo group not found')
|
||||
|
||||
members_count = await count_promo_group_members(db, group_id)
|
||||
return _serialize_promo_group(group, members_count=members_count)
|
||||
|
||||
|
||||
@promo_groups_router.post('', response_model=PromoGroupResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_promo_group_endpoint(
|
||||
payload: PromoGroupCreateRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> PromoGroupResponse:
|
||||
"""Create a new promo group."""
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
try:
|
||||
group = await create_promo_group(
|
||||
db,
|
||||
name=payload.name,
|
||||
server_discount_percent=payload.server_discount_percent,
|
||||
traffic_discount_percent=payload.traffic_discount_percent,
|
||||
device_discount_percent=payload.device_discount_percent,
|
||||
period_discounts=payload.period_discounts,
|
||||
auto_assign_total_spent_kopeks=payload.auto_assign_total_spent_kopeks,
|
||||
apply_discounts_to_addons=payload.apply_discounts_to_addons,
|
||||
is_default=payload.is_default,
|
||||
)
|
||||
except IntegrityError:
|
||||
await db.rollback()
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
'Promo group with this name already exists',
|
||||
)
|
||||
|
||||
return _serialize_promo_group(group, members_count=0)
|
||||
|
||||
|
||||
@promo_groups_router.patch('/{group_id}', response_model=PromoGroupResponse)
|
||||
async def update_promo_group_endpoint(
|
||||
group_id: int,
|
||||
payload: PromoGroupUpdateRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> PromoGroupResponse:
|
||||
"""Update a promo group."""
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
group = await get_promo_group_by_id(db, group_id)
|
||||
if not group:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Promo group not found')
|
||||
|
||||
try:
|
||||
group = await update_promo_group(
|
||||
db,
|
||||
group,
|
||||
name=payload.name,
|
||||
server_discount_percent=payload.server_discount_percent,
|
||||
traffic_discount_percent=payload.traffic_discount_percent,
|
||||
device_discount_percent=payload.device_discount_percent,
|
||||
period_discounts=payload.period_discounts,
|
||||
auto_assign_total_spent_kopeks=payload.auto_assign_total_spent_kopeks,
|
||||
apply_discounts_to_addons=payload.apply_discounts_to_addons,
|
||||
is_default=payload.is_default,
|
||||
)
|
||||
except IntegrityError:
|
||||
await db.rollback()
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
'Promo group with this name already exists',
|
||||
)
|
||||
|
||||
members_count = await count_promo_group_members(db, group_id)
|
||||
return _serialize_promo_group(group, members_count=members_count)
|
||||
|
||||
|
||||
@promo_groups_router.delete('/{group_id}', status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_promo_group_endpoint(
|
||||
group_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> Response:
|
||||
"""Delete a promo group."""
|
||||
group = await get_promo_group_by_id(db, group_id)
|
||||
if not group:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Promo group not found')
|
||||
|
||||
success = await delete_promo_group(db, group)
|
||||
if not success:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Cannot delete default promo group')
|
||||
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
@@ -0,0 +1,983 @@
|
||||
"""Admin routes for RemnaWave management in cabinet."""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.crud.server_squad import (
|
||||
count_active_users_for_squad,
|
||||
get_all_server_squads,
|
||||
get_server_squad_by_uuid,
|
||||
sync_with_remnawave,
|
||||
)
|
||||
from app.database.models import User
|
||||
from app.utils.cache import cache
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
from ..schemas.remnawave import (
|
||||
AutoSyncRunResponse,
|
||||
# Auto Sync
|
||||
AutoSyncStatus,
|
||||
AutoSyncToggleRequest,
|
||||
Bandwidth,
|
||||
ConnectionStatus,
|
||||
# Inbounds
|
||||
InboundsListResponse,
|
||||
# Migration
|
||||
MigrationPreviewResponse,
|
||||
MigrationRequest,
|
||||
MigrationResponse,
|
||||
MigrationStats,
|
||||
NodeActionRequest,
|
||||
NodeActionResponse,
|
||||
# Nodes
|
||||
NodeInfo,
|
||||
NodesListResponse,
|
||||
NodesOverview,
|
||||
NodeStatisticsResponse,
|
||||
NodeUsageResponse,
|
||||
# Status & Connection
|
||||
RemnaWaveStatusResponse,
|
||||
ServerInfo,
|
||||
SquadActionRequest,
|
||||
SquadCreateRequest,
|
||||
SquadDetailResponse,
|
||||
SquadOperationResponse,
|
||||
SquadsListResponse,
|
||||
SquadUpdateRequest,
|
||||
# Squads
|
||||
SquadWithLocalInfo,
|
||||
# Manual Sync
|
||||
SyncMode,
|
||||
SyncResponse,
|
||||
# System Statistics
|
||||
SystemStatsResponse,
|
||||
SystemSummary,
|
||||
TrafficPeriod,
|
||||
TrafficPeriods,
|
||||
)
|
||||
|
||||
|
||||
try:
|
||||
from app.services.remnawave_service import (
|
||||
RemnaWaveConfigurationError,
|
||||
RemnaWaveService,
|
||||
)
|
||||
except Exception:
|
||||
RemnaWaveConfigurationError = None
|
||||
RemnaWaveService = None
|
||||
|
||||
try:
|
||||
from app.services.remnawave_sync_service import remnawave_sync_service
|
||||
except Exception:
|
||||
remnawave_sync_service = None
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/remnawave', tags=['Cabinet Admin RemnaWave'])
|
||||
|
||||
|
||||
# ============ Helpers ============
|
||||
|
||||
|
||||
def _get_service() -> RemnaWaveService:
|
||||
"""Get RemnaWave service instance."""
|
||||
if RemnaWaveService is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail='RemnaWave service is not available',
|
||||
)
|
||||
return RemnaWaveService()
|
||||
|
||||
|
||||
def _ensure_configured(service: RemnaWaveService) -> None:
|
||||
"""Ensure RemnaWave is configured."""
|
||||
if not service.is_configured:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=service.configuration_error or 'RemnaWave API is not configured',
|
||||
)
|
||||
|
||||
|
||||
def _parse_datetime(value: Any) -> datetime | None:
|
||||
"""Parse datetime from various formats."""
|
||||
if isinstance(value, datetime):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
return datetime.fromisoformat(value)
|
||||
except ValueError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _serialize_node(node_data: dict[str, Any]) -> NodeInfo:
|
||||
"""Serialize node data to NodeInfo model."""
|
||||
return NodeInfo(
|
||||
uuid=node_data.get('uuid', ''),
|
||||
name=node_data.get('name', ''),
|
||||
address=node_data.get('address', ''),
|
||||
country_code=node_data.get('country_code'),
|
||||
is_connected=bool(node_data.get('is_connected')),
|
||||
is_disabled=bool(node_data.get('is_disabled')),
|
||||
is_node_online=bool(node_data.get('is_node_online')),
|
||||
is_xray_running=bool(node_data.get('is_xray_running')),
|
||||
users_online=node_data.get('users_online'),
|
||||
traffic_used_bytes=node_data.get('traffic_used_bytes'),
|
||||
traffic_limit_bytes=node_data.get('traffic_limit_bytes'),
|
||||
last_status_change=_parse_datetime(node_data.get('last_status_change')),
|
||||
last_status_message=node_data.get('last_status_message'),
|
||||
xray_uptime=node_data.get('xray_uptime'),
|
||||
is_traffic_tracking_active=bool(node_data.get('is_traffic_tracking_active', False)),
|
||||
traffic_reset_day=node_data.get('traffic_reset_day'),
|
||||
notify_percent=node_data.get('notify_percent'),
|
||||
consumption_multiplier=float(node_data.get('consumption_multiplier', 1.0)),
|
||||
cpu_count=node_data.get('cpu_count'),
|
||||
cpu_model=node_data.get('cpu_model'),
|
||||
total_ram=node_data.get('total_ram'),
|
||||
created_at=_parse_datetime(node_data.get('created_at')),
|
||||
updated_at=_parse_datetime(node_data.get('updated_at')),
|
||||
provider_uuid=node_data.get('provider_uuid'),
|
||||
)
|
||||
|
||||
|
||||
# ============ Status & Connection ============
|
||||
|
||||
|
||||
@router.get('/status', response_model=RemnaWaveStatusResponse)
|
||||
async def get_remnawave_status(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
) -> RemnaWaveStatusResponse:
|
||||
"""Get RemnaWave configuration and connection status."""
|
||||
service = _get_service()
|
||||
|
||||
connection_info: ConnectionStatus | None = None
|
||||
connection_result = await service.test_api_connection()
|
||||
|
||||
if connection_result:
|
||||
connection_info = ConnectionStatus(**connection_result)
|
||||
|
||||
return RemnaWaveStatusResponse(
|
||||
is_configured=service.is_configured,
|
||||
configuration_error=service.configuration_error,
|
||||
connection=connection_info,
|
||||
)
|
||||
|
||||
|
||||
# ============ System Statistics ============
|
||||
|
||||
|
||||
@router.get('/system', response_model=SystemStatsResponse)
|
||||
async def get_system_statistics(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
) -> SystemStatsResponse:
|
||||
"""Get full system statistics from RemnaWave."""
|
||||
service = _get_service()
|
||||
_ensure_configured(service)
|
||||
|
||||
stats = await service.get_system_statistics()
|
||||
if not stats or 'system' not in stats:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail='Failed to get RemnaWave statistics',
|
||||
)
|
||||
|
||||
system_data = stats.get('system', {})
|
||||
server_data = stats.get('server_info', {})
|
||||
bandwidth_data = stats.get('bandwidth', {})
|
||||
traffic_data = stats.get('traffic_periods', {})
|
||||
|
||||
return SystemStatsResponse(
|
||||
system=SystemSummary(
|
||||
users_online=system_data.get('users_online', 0),
|
||||
total_users=system_data.get('total_users', 0),
|
||||
active_connections=system_data.get('active_connections', 0),
|
||||
nodes_online=system_data.get('nodes_online', 0),
|
||||
users_last_day=system_data.get('users_last_day', 0),
|
||||
users_last_week=system_data.get('users_last_week', 0),
|
||||
users_never_online=system_data.get('users_never_online', 0),
|
||||
total_user_traffic=system_data.get('total_user_traffic', 0),
|
||||
),
|
||||
users_by_status=stats.get('users_by_status', {}),
|
||||
server_info=ServerInfo(
|
||||
cpu_cores=server_data.get('cpu_cores', 0),
|
||||
cpu_physical_cores=server_data.get('cpu_physical_cores', 0),
|
||||
memory_total=server_data.get('memory_total', 0),
|
||||
memory_used=server_data.get('memory_used', 0),
|
||||
memory_free=server_data.get('memory_free', 0),
|
||||
memory_available=server_data.get('memory_available', 0),
|
||||
uptime_seconds=server_data.get('uptime_seconds', 0),
|
||||
),
|
||||
bandwidth=Bandwidth(
|
||||
realtime_download=bandwidth_data.get('realtime_download', 0),
|
||||
realtime_upload=bandwidth_data.get('realtime_upload', 0),
|
||||
realtime_total=bandwidth_data.get('realtime_total', 0),
|
||||
),
|
||||
traffic_periods=TrafficPeriods(
|
||||
last_2_days=TrafficPeriod(**traffic_data.get('last_2_days', {'current': 0, 'previous': 0})),
|
||||
last_7_days=TrafficPeriod(**traffic_data.get('last_7_days', {'current': 0, 'previous': 0})),
|
||||
last_30_days=TrafficPeriod(**traffic_data.get('last_30_days', {'current': 0, 'previous': 0})),
|
||||
current_month=TrafficPeriod(**traffic_data.get('current_month', {'current': 0, 'previous': 0})),
|
||||
current_year=TrafficPeriod(**traffic_data.get('current_year', {'current': 0, 'previous': 0})),
|
||||
),
|
||||
nodes_realtime=stats.get('nodes_realtime', []),
|
||||
nodes_weekly=stats.get('nodes_weekly', []),
|
||||
last_updated=_parse_datetime(stats.get('last_updated')),
|
||||
)
|
||||
|
||||
|
||||
# ============ Nodes ============
|
||||
|
||||
|
||||
@router.get('/nodes', response_model=NodesListResponse)
|
||||
async def list_nodes(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
) -> NodesListResponse:
|
||||
"""Get list of all nodes."""
|
||||
service = _get_service()
|
||||
_ensure_configured(service)
|
||||
|
||||
nodes = await service.get_all_nodes()
|
||||
serialized = [_serialize_node(node) for node in nodes]
|
||||
|
||||
return NodesListResponse(items=serialized, total=len(serialized))
|
||||
|
||||
|
||||
@router.get('/nodes/overview', response_model=NodesOverview)
|
||||
async def get_nodes_overview(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
) -> NodesOverview:
|
||||
"""Get nodes overview with statistics."""
|
||||
service = _get_service()
|
||||
_ensure_configured(service)
|
||||
|
||||
nodes = await service.get_all_nodes()
|
||||
|
||||
total = len(nodes)
|
||||
online = sum(1 for n in nodes if n.get('is_connected') and not n.get('is_disabled'))
|
||||
disabled = sum(1 for n in nodes if n.get('is_disabled'))
|
||||
offline = total - online - disabled
|
||||
total_users_online = sum(n.get('users_online', 0) or 0 for n in nodes)
|
||||
|
||||
return NodesOverview(
|
||||
total=total,
|
||||
online=online,
|
||||
offline=offline,
|
||||
disabled=disabled,
|
||||
total_users_online=total_users_online,
|
||||
nodes=[_serialize_node(n) for n in nodes],
|
||||
)
|
||||
|
||||
|
||||
@router.get('/nodes/realtime')
|
||||
async def get_nodes_realtime(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Get realtime node usage data."""
|
||||
service = _get_service()
|
||||
_ensure_configured(service)
|
||||
|
||||
return await service.get_nodes_realtime_usage()
|
||||
|
||||
|
||||
@router.get('/nodes/{node_uuid}', response_model=NodeInfo)
|
||||
async def get_node_details(
|
||||
node_uuid: str,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
) -> NodeInfo:
|
||||
"""Get detailed information about a specific node."""
|
||||
service = _get_service()
|
||||
_ensure_configured(service)
|
||||
|
||||
node = await service.get_node_details(node_uuid)
|
||||
if not node:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Node not found',
|
||||
)
|
||||
|
||||
return _serialize_node(node)
|
||||
|
||||
|
||||
@router.get('/nodes/{node_uuid}/statistics', response_model=NodeStatisticsResponse)
|
||||
async def get_node_statistics(
|
||||
node_uuid: str,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
) -> NodeStatisticsResponse:
|
||||
"""Get node statistics with usage history."""
|
||||
service = _get_service()
|
||||
_ensure_configured(service)
|
||||
|
||||
stats = await service.get_node_statistics(node_uuid)
|
||||
if not stats or not stats.get('node'):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Node not found or no statistics available',
|
||||
)
|
||||
|
||||
return NodeStatisticsResponse(
|
||||
node=_serialize_node(stats['node']),
|
||||
realtime=stats.get('realtime'),
|
||||
usage_history=stats.get('usage_history') or [],
|
||||
last_updated=_parse_datetime(stats.get('last_updated')),
|
||||
)
|
||||
|
||||
|
||||
@router.get('/nodes/{node_uuid}/usage', response_model=NodeUsageResponse)
|
||||
async def get_node_usage(
|
||||
node_uuid: str,
|
||||
start: datetime | None = Query(default=None),
|
||||
end: datetime | None = Query(default=None),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
) -> NodeUsageResponse:
|
||||
"""Get node usage history for a date range."""
|
||||
service = _get_service()
|
||||
_ensure_configured(service)
|
||||
|
||||
end_dt = end or datetime.utcnow()
|
||||
start_dt = start or (end_dt - timedelta(days=7))
|
||||
|
||||
if start_dt >= end_dt:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Invalid date range',
|
||||
)
|
||||
|
||||
usage = await service.get_node_user_usage_by_range(node_uuid, start_dt, end_dt)
|
||||
return NodeUsageResponse(items=usage or [])
|
||||
|
||||
|
||||
@router.post('/nodes/{node_uuid}/action', response_model=NodeActionResponse)
|
||||
async def perform_node_action(
|
||||
node_uuid: str,
|
||||
payload: NodeActionRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
) -> NodeActionResponse:
|
||||
"""Perform an action on a node (enable/disable/restart)."""
|
||||
service = _get_service()
|
||||
_ensure_configured(service)
|
||||
|
||||
# Get current node state for toggle operations
|
||||
if payload.action in ('enable', 'disable'):
|
||||
nodes = await service.get_all_nodes()
|
||||
node = next((n for n in nodes if n.get('uuid') == node_uuid), None)
|
||||
if not node:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Node not found',
|
||||
)
|
||||
|
||||
success = await service.manage_node(node_uuid, payload.action)
|
||||
|
||||
messages = {
|
||||
'enable': 'Node enabled',
|
||||
'disable': 'Node disabled',
|
||||
'restart': 'Node restart initiated',
|
||||
}
|
||||
|
||||
if success:
|
||||
logger.info(f'Admin {admin.telegram_id} performed {payload.action} on node {node_uuid}')
|
||||
return NodeActionResponse(
|
||||
success=True,
|
||||
message=messages.get(payload.action, 'Action completed'),
|
||||
is_disabled=payload.action == 'disable' if payload.action in ('enable', 'disable') else None,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Failed to {payload.action} node',
|
||||
)
|
||||
|
||||
|
||||
@router.post('/nodes/restart-all', response_model=NodeActionResponse)
|
||||
async def restart_all_nodes(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
) -> NodeActionResponse:
|
||||
"""Restart all nodes."""
|
||||
service = _get_service()
|
||||
_ensure_configured(service)
|
||||
|
||||
success = await service.restart_all_nodes()
|
||||
|
||||
if success:
|
||||
logger.info(f'Admin {admin.telegram_id} restarted all nodes')
|
||||
return NodeActionResponse(success=True, message='All nodes restart initiated')
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Failed to restart all nodes',
|
||||
)
|
||||
|
||||
|
||||
# ============ Squads (Internal Squads) ============
|
||||
|
||||
|
||||
@router.get('/squads', response_model=SquadsListResponse)
|
||||
async def list_squads(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> SquadsListResponse:
|
||||
"""Get list of all squads with local database info."""
|
||||
service = _get_service()
|
||||
_ensure_configured(service)
|
||||
|
||||
# Get squads from RemnaWave
|
||||
rw_squads = await service.get_all_squads()
|
||||
|
||||
# Get local squads from DB
|
||||
local_squads, _ = await get_all_server_squads(db, page=1, limit=1000)
|
||||
local_by_uuid = {s.squad_uuid: s for s in local_squads}
|
||||
|
||||
items = []
|
||||
for squad in rw_squads:
|
||||
local = local_by_uuid.get(squad.get('uuid'))
|
||||
items.append(
|
||||
SquadWithLocalInfo(
|
||||
uuid=squad.get('uuid', ''),
|
||||
name=squad.get('name', ''),
|
||||
members_count=squad.get('members_count', 0),
|
||||
inbounds_count=squad.get('inbounds_count', 0),
|
||||
inbounds=squad.get('inbounds', []),
|
||||
local_id=local.id if local else None,
|
||||
display_name=local.display_name if local else None,
|
||||
country_code=local.country_code if local else None,
|
||||
is_available=local.is_available if local else None,
|
||||
is_trial_eligible=local.is_trial_eligible if local else None,
|
||||
price_kopeks=local.price_kopeks if local else None,
|
||||
max_users=local.max_users if local else None,
|
||||
current_users=local.current_users if local else None,
|
||||
is_synced=local is not None,
|
||||
)
|
||||
)
|
||||
|
||||
return SquadsListResponse(items=items, total=len(items))
|
||||
|
||||
|
||||
@router.get('/squads/{squad_uuid}', response_model=SquadDetailResponse)
|
||||
async def get_squad_details(
|
||||
squad_uuid: str,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> SquadDetailResponse:
|
||||
"""Get detailed information about a squad."""
|
||||
service = _get_service()
|
||||
_ensure_configured(service)
|
||||
|
||||
# Get squad from RemnaWave
|
||||
squad = await service.get_squad_details(squad_uuid)
|
||||
if not squad:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Squad not found',
|
||||
)
|
||||
|
||||
# Get local info from DB
|
||||
local = await get_server_squad_by_uuid(db, squad_uuid)
|
||||
active_subs = await count_active_users_for_squad(db, squad_uuid) if local else 0
|
||||
|
||||
return SquadDetailResponse(
|
||||
uuid=squad.get('uuid', ''),
|
||||
name=squad.get('name', ''),
|
||||
members_count=squad.get('members_count', 0),
|
||||
inbounds_count=squad.get('inbounds_count', 0),
|
||||
inbounds=squad.get('inbounds', []),
|
||||
local_id=local.id if local else None,
|
||||
display_name=local.display_name if local else None,
|
||||
country_code=local.country_code if local else None,
|
||||
description=local.description if local else None,
|
||||
is_available=local.is_available if local else None,
|
||||
is_trial_eligible=local.is_trial_eligible if local else None,
|
||||
price_kopeks=local.price_kopeks if local else None,
|
||||
max_users=local.max_users if local else None,
|
||||
current_users=local.current_users if local else None,
|
||||
sort_order=local.sort_order if local else None,
|
||||
is_synced=local is not None,
|
||||
active_subscriptions=active_subs,
|
||||
)
|
||||
|
||||
|
||||
@router.post('/squads', response_model=SquadOperationResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_squad(
|
||||
payload: SquadCreateRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
) -> SquadOperationResponse:
|
||||
"""Create a new squad in RemnaWave."""
|
||||
service = _get_service()
|
||||
_ensure_configured(service)
|
||||
|
||||
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})')
|
||||
return SquadOperationResponse(
|
||||
success=True,
|
||||
message='Squad created successfully',
|
||||
data={'uuid': squad_uuid},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Failed to create squad',
|
||||
)
|
||||
|
||||
|
||||
@router.patch('/squads/{squad_uuid}', response_model=SquadOperationResponse)
|
||||
async def update_squad(
|
||||
squad_uuid: str,
|
||||
payload: SquadUpdateRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
) -> SquadOperationResponse:
|
||||
"""Update a squad in RemnaWave."""
|
||||
service = _get_service()
|
||||
_ensure_configured(service)
|
||||
|
||||
if payload.name is None and payload.inbound_uuids is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='No update data provided',
|
||||
)
|
||||
|
||||
success = await service.update_squad(
|
||||
squad_uuid,
|
||||
name=payload.name,
|
||||
inbounds=payload.inbound_uuids,
|
||||
)
|
||||
|
||||
if success:
|
||||
logger.info(f'Admin {admin.telegram_id} updated squad {squad_uuid}')
|
||||
return SquadOperationResponse(success=True, message='Squad updated')
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Failed to update squad',
|
||||
)
|
||||
|
||||
|
||||
@router.post('/squads/{squad_uuid}/action', response_model=SquadOperationResponse)
|
||||
async def perform_squad_action(
|
||||
squad_uuid: str,
|
||||
payload: SquadActionRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
) -> SquadOperationResponse:
|
||||
"""Perform an action on a squad."""
|
||||
service = _get_service()
|
||||
_ensure_configured(service)
|
||||
|
||||
action = payload.action
|
||||
success = False
|
||||
message = 'Unknown action'
|
||||
|
||||
if action == 'add_all_users':
|
||||
success = await service.add_all_users_to_squad(squad_uuid)
|
||||
message = 'Users added' if success else 'Failed to add users'
|
||||
elif action == 'remove_all_users':
|
||||
success = await service.remove_all_users_from_squad(squad_uuid)
|
||||
message = 'Users removed' if success else 'Failed to remove users'
|
||||
elif action == 'delete':
|
||||
success = await service.delete_squad(squad_uuid)
|
||||
message = 'Squad deleted' if success else 'Failed to delete squad'
|
||||
elif action == 'rename':
|
||||
if not payload.name:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Name is required for rename action',
|
||||
)
|
||||
success = await service.rename_squad(squad_uuid, payload.name)
|
||||
message = 'Squad renamed' if success else 'Failed to rename squad'
|
||||
elif action == 'update_inbounds':
|
||||
if not payload.inbound_uuids:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Inbound UUIDs are required',
|
||||
)
|
||||
success = await service.update_squad_inbounds(squad_uuid, payload.inbound_uuids)
|
||||
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}')
|
||||
|
||||
return SquadOperationResponse(success=success, message=message)
|
||||
|
||||
|
||||
@router.delete('/squads/{squad_uuid}', response_model=SquadOperationResponse)
|
||||
async def delete_squad(
|
||||
squad_uuid: str,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
) -> SquadOperationResponse:
|
||||
"""Delete a squad."""
|
||||
service = _get_service()
|
||||
_ensure_configured(service)
|
||||
|
||||
success = await service.delete_squad(squad_uuid)
|
||||
|
||||
if success:
|
||||
logger.info(f'Admin {admin.telegram_id} deleted squad {squad_uuid}')
|
||||
return SquadOperationResponse(success=True, message='Squad deleted')
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Failed to delete squad',
|
||||
)
|
||||
|
||||
|
||||
# ============ Migration ============
|
||||
|
||||
|
||||
@router.get('/squads/{squad_uuid}/migration-preview', response_model=MigrationPreviewResponse)
|
||||
async def preview_migration(
|
||||
squad_uuid: str,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> MigrationPreviewResponse:
|
||||
"""Get migration preview for a squad."""
|
||||
squad = await get_server_squad_by_uuid(db, squad_uuid)
|
||||
if not squad:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Squad not found in local database',
|
||||
)
|
||||
|
||||
users_to_migrate = await count_active_users_for_squad(db, squad_uuid)
|
||||
|
||||
return MigrationPreviewResponse(
|
||||
squad_uuid=squad.squad_uuid,
|
||||
squad_name=squad.display_name,
|
||||
current_users=squad.current_users or 0,
|
||||
max_users=squad.max_users,
|
||||
users_to_migrate=users_to_migrate,
|
||||
)
|
||||
|
||||
|
||||
@router.post('/squads/migrate', response_model=MigrationResponse)
|
||||
async def migrate_squad_users(
|
||||
payload: MigrationRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> MigrationResponse:
|
||||
"""Migrate users from one squad to another."""
|
||||
service = _get_service()
|
||||
_ensure_configured(service)
|
||||
|
||||
source_uuid = payload.source_uuid.strip()
|
||||
target_uuid = payload.target_uuid.strip()
|
||||
|
||||
if source_uuid == target_uuid:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Source and target squads must be different',
|
||||
)
|
||||
|
||||
source = await get_server_squad_by_uuid(db, source_uuid)
|
||||
if not source:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Source squad not found',
|
||||
)
|
||||
|
||||
target = await get_server_squad_by_uuid(db, target_uuid)
|
||||
if not target:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Target squad not found',
|
||||
)
|
||||
|
||||
try:
|
||||
result = await service.migrate_squad_users(
|
||||
db,
|
||||
source_uuid=source.squad_uuid,
|
||||
target_uuid=target.squad_uuid,
|
||||
)
|
||||
except RemnaWaveConfigurationError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=str(exc),
|
||||
)
|
||||
|
||||
if not result.get('success'):
|
||||
return MigrationResponse(
|
||||
success=False,
|
||||
message=result.get('message') or 'Migration failed',
|
||||
error=result.get('error'),
|
||||
)
|
||||
|
||||
logger.info(f'Admin {admin.telegram_id} migrated users from {source_uuid} to {target_uuid}')
|
||||
|
||||
return MigrationResponse(
|
||||
success=True,
|
||||
message=result.get('message') or 'Migration completed',
|
||||
data=MigrationStats(
|
||||
source_uuid=source.squad_uuid,
|
||||
target_uuid=target.squad_uuid,
|
||||
total=result.get('total', 0),
|
||||
updated=result.get('updated', 0),
|
||||
panel_updated=result.get('panel_updated', 0),
|
||||
panel_failed=result.get('panel_failed', 0),
|
||||
source_removed=result.get('source_removed', 0),
|
||||
target_added=result.get('target_added', 0),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# ============ Inbounds ============
|
||||
|
||||
|
||||
@router.get('/inbounds', response_model=InboundsListResponse)
|
||||
async def list_inbounds(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
) -> InboundsListResponse:
|
||||
"""Get list of all available inbounds."""
|
||||
service = _get_service()
|
||||
_ensure_configured(service)
|
||||
|
||||
inbounds = await service.get_all_inbounds()
|
||||
return InboundsListResponse(items=inbounds or [], total=len(inbounds or []))
|
||||
|
||||
|
||||
# ============ Auto Sync ============
|
||||
|
||||
|
||||
@router.get('/sync/auto/status', response_model=AutoSyncStatus)
|
||||
async def get_auto_sync_status(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
) -> AutoSyncStatus:
|
||||
"""Get auto sync status."""
|
||||
if remnawave_sync_service is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail='Auto sync service is not available',
|
||||
)
|
||||
|
||||
status_obj = remnawave_sync_service.get_status()
|
||||
|
||||
return AutoSyncStatus(
|
||||
enabled=status_obj.enabled,
|
||||
times=[t.strftime('%H:%M') for t in status_obj.times] if status_obj.times else [],
|
||||
next_run=status_obj.next_run,
|
||||
is_running=status_obj.is_running,
|
||||
last_run_started_at=status_obj.last_run_started_at,
|
||||
last_run_finished_at=status_obj.last_run_finished_at,
|
||||
last_run_success=status_obj.last_run_success,
|
||||
last_run_reason=status_obj.last_run_reason,
|
||||
last_run_error=status_obj.last_run_error,
|
||||
last_user_stats=status_obj.last_user_stats,
|
||||
last_server_stats=status_obj.last_server_stats,
|
||||
)
|
||||
|
||||
|
||||
@router.post('/sync/auto/toggle', response_model=SyncResponse)
|
||||
async def toggle_auto_sync(
|
||||
payload: AutoSyncToggleRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
) -> SyncResponse:
|
||||
"""Toggle auto sync on/off."""
|
||||
if remnawave_sync_service is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail='Auto sync service is not available',
|
||||
)
|
||||
|
||||
# This would need to update settings - for now just return info
|
||||
# In production, this should update REMNAWAVE_AUTO_SYNC_ENABLED setting
|
||||
current_status = remnawave_sync_service.get_status()
|
||||
|
||||
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')
|
||||
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')
|
||||
return SyncResponse(
|
||||
success=True,
|
||||
message='Auto sync setting change requested. Restart may be required.',
|
||||
)
|
||||
return SyncResponse(
|
||||
success=True,
|
||||
message='No change needed',
|
||||
)
|
||||
|
||||
|
||||
@router.post('/sync/auto/run', response_model=AutoSyncRunResponse)
|
||||
async def run_auto_sync_now(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
) -> AutoSyncRunResponse:
|
||||
"""Run auto sync immediately."""
|
||||
if remnawave_sync_service is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail='Auto sync service is not available',
|
||||
)
|
||||
|
||||
logger.info(f'Admin {admin.telegram_id} triggered manual sync')
|
||||
result = await remnawave_sync_service.run_sync_now(reason='manual')
|
||||
|
||||
return AutoSyncRunResponse(
|
||||
started=result.get('started', False),
|
||||
success=result.get('success'),
|
||||
error=result.get('error'),
|
||||
user_stats=result.get('user_stats'),
|
||||
server_stats=result.get('server_stats'),
|
||||
reason='manual',
|
||||
)
|
||||
|
||||
|
||||
# ============ Manual Sync ============
|
||||
|
||||
|
||||
@router.post('/sync/from-panel', response_model=SyncResponse)
|
||||
async def sync_from_panel(
|
||||
payload: SyncMode,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> SyncResponse:
|
||||
"""Sync users from RemnaWave panel to bot."""
|
||||
service = _get_service()
|
||||
_ensure_configured(service)
|
||||
|
||||
try:
|
||||
stats = await service.sync_users_from_panel(db, payload.mode)
|
||||
logger.info(f'Admin {admin.telegram_id} synced from panel (mode: {payload.mode})')
|
||||
return SyncResponse(
|
||||
success=True,
|
||||
message='Sync from panel completed',
|
||||
data=stats,
|
||||
)
|
||||
except RemnaWaveConfigurationError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=str(exc),
|
||||
)
|
||||
|
||||
|
||||
@router.post('/sync/to-panel', response_model=SyncResponse)
|
||||
async def sync_to_panel(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> SyncResponse:
|
||||
"""Sync users from bot to RemnaWave panel."""
|
||||
service = _get_service()
|
||||
_ensure_configured(service)
|
||||
|
||||
stats = await service.sync_users_to_panel(db)
|
||||
logger.info(f'Admin {admin.telegram_id} synced to panel')
|
||||
|
||||
return SyncResponse(
|
||||
success=True,
|
||||
message='Sync to panel completed',
|
||||
data=stats,
|
||||
)
|
||||
|
||||
|
||||
@router.post('/sync/servers', response_model=SyncResponse)
|
||||
async def sync_servers(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> SyncResponse:
|
||||
"""Sync servers/squads from RemnaWave."""
|
||||
service = _get_service()
|
||||
_ensure_configured(service)
|
||||
|
||||
squads = await service.get_all_squads()
|
||||
if not squads:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail='Failed to get squads from RemnaWave',
|
||||
)
|
||||
|
||||
created, updated, removed = await sync_with_remnawave(db, squads)
|
||||
|
||||
try:
|
||||
await cache.delete_pattern('available_countries*')
|
||||
except Exception as e:
|
||||
logger.warning(f'Failed to clear countries cache: {e}')
|
||||
|
||||
logger.info(f'Admin {admin.telegram_id} synced servers: created={created}, updated={updated}, removed={removed}')
|
||||
|
||||
return SyncResponse(
|
||||
success=True,
|
||||
message='Servers synced successfully',
|
||||
data={
|
||||
'created': created,
|
||||
'updated': updated,
|
||||
'removed': removed,
|
||||
'total': len(squads),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post('/sync/subscriptions/validate', response_model=SyncResponse)
|
||||
async def validate_subscriptions(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> SyncResponse:
|
||||
"""Validate and fix subscriptions."""
|
||||
service = _get_service()
|
||||
_ensure_configured(service)
|
||||
|
||||
stats = await service.validate_and_fix_subscriptions(db)
|
||||
logger.info(f'Admin {admin.telegram_id} validated subscriptions')
|
||||
|
||||
return SyncResponse(
|
||||
success=True,
|
||||
message='Subscriptions validated',
|
||||
data=stats,
|
||||
)
|
||||
|
||||
|
||||
@router.post('/sync/subscriptions/cleanup', response_model=SyncResponse)
|
||||
async def cleanup_subscriptions(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> SyncResponse:
|
||||
"""Cleanup orphaned subscriptions."""
|
||||
service = _get_service()
|
||||
_ensure_configured(service)
|
||||
|
||||
stats = await service.cleanup_orphaned_subscriptions(db)
|
||||
logger.info(f'Admin {admin.telegram_id} cleaned up subscriptions')
|
||||
|
||||
return SyncResponse(
|
||||
success=True,
|
||||
message='Cleanup completed',
|
||||
data=stats,
|
||||
)
|
||||
|
||||
|
||||
@router.post('/sync/subscriptions/statuses', response_model=SyncResponse)
|
||||
async def sync_subscription_statuses(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> SyncResponse:
|
||||
"""Sync subscription statuses."""
|
||||
service = _get_service()
|
||||
_ensure_configured(service)
|
||||
|
||||
stats = await service.sync_subscription_statuses(db)
|
||||
logger.info(f'Admin {admin.telegram_id} synced subscription statuses')
|
||||
|
||||
return SyncResponse(
|
||||
success=True,
|
||||
message='Subscription statuses synced',
|
||||
data=stats,
|
||||
)
|
||||
|
||||
|
||||
@router.get('/sync/recommendations', response_model=SyncResponse)
|
||||
async def get_sync_recommendations(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> SyncResponse:
|
||||
"""Get sync recommendations."""
|
||||
service = _get_service()
|
||||
_ensure_configured(service)
|
||||
|
||||
data = await service.get_sync_recommendations(db)
|
||||
|
||||
return SyncResponse(
|
||||
success=True,
|
||||
message='Recommendations retrieved',
|
||||
data=data,
|
||||
)
|
||||
@@ -0,0 +1,330 @@
|
||||
"""Admin routes for managing servers in cabinet."""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import String, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.crud.server_squad import (
|
||||
count_active_users_for_squad,
|
||||
get_all_server_squads,
|
||||
get_server_squad_by_id,
|
||||
sync_with_remnawave,
|
||||
update_server_squad,
|
||||
update_server_squad_promo_groups,
|
||||
)
|
||||
from app.database.models import PromoGroup, ServerSquad, Subscription, Tariff, User
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
from ..schemas.servers import (
|
||||
PromoGroupInfo,
|
||||
ServerDetailResponse,
|
||||
ServerListItem,
|
||||
ServerListResponse,
|
||||
ServerStatsResponse,
|
||||
ServerSyncResponse,
|
||||
ServerToggleResponse,
|
||||
ServerTrialToggleResponse,
|
||||
ServerUpdateRequest,
|
||||
)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/servers', tags=['Cabinet Admin Servers'])
|
||||
|
||||
|
||||
async def _get_server_promo_groups(db: AsyncSession, server: ServerSquad) -> list[PromoGroupInfo]:
|
||||
"""Get promo group info for server."""
|
||||
result = await db.execute(select(PromoGroup).order_by(PromoGroup.name))
|
||||
all_groups = result.scalars().all()
|
||||
|
||||
selected_ids = {pg.id for pg in server.allowed_promo_groups} if server.allowed_promo_groups else set()
|
||||
|
||||
return [
|
||||
PromoGroupInfo(
|
||||
id=pg.id,
|
||||
name=pg.name,
|
||||
is_selected=pg.id in selected_ids,
|
||||
)
|
||||
for pg in all_groups
|
||||
]
|
||||
|
||||
|
||||
async def _get_tariffs_using_server(db: AsyncSession, squad_uuid: str) -> list[str]:
|
||||
"""Get list of tariff names using this server."""
|
||||
# Get all tariffs and filter in Python since JSON array queries are DB-specific
|
||||
result = await db.execute(select(Tariff.name, Tariff.allowed_squads))
|
||||
tariff_names = []
|
||||
for name, allowed_squads in result.fetchall():
|
||||
if allowed_squads and squad_uuid in allowed_squads:
|
||||
tariff_names.append(name)
|
||||
return tariff_names
|
||||
|
||||
|
||||
@router.get('', response_model=ServerListResponse)
|
||||
async def list_servers(
|
||||
include_unavailable: bool = True,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get list of all servers."""
|
||||
servers, total = await get_all_server_squads(
|
||||
db,
|
||||
available_only=not include_unavailable,
|
||||
)
|
||||
|
||||
items = []
|
||||
for server in servers:
|
||||
items.append(
|
||||
ServerListItem(
|
||||
id=server.id,
|
||||
squad_uuid=server.squad_uuid,
|
||||
display_name=server.display_name,
|
||||
original_name=server.original_name,
|
||||
country_code=server.country_code,
|
||||
is_available=server.is_available,
|
||||
is_trial_eligible=server.is_trial_eligible,
|
||||
price_kopeks=server.price_kopeks,
|
||||
price_rubles=server.price_kopeks / 100,
|
||||
max_users=server.max_users,
|
||||
current_users=server.current_users or 0,
|
||||
sort_order=server.sort_order,
|
||||
is_full=server.is_full,
|
||||
availability_status=server.availability_status,
|
||||
created_at=server.created_at,
|
||||
)
|
||||
)
|
||||
|
||||
return ServerListResponse(servers=items, total=total)
|
||||
|
||||
|
||||
@router.get('/{server_id}', response_model=ServerDetailResponse)
|
||||
async def get_server(
|
||||
server_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get detailed server info."""
|
||||
server = await get_server_squad_by_id(db, server_id)
|
||||
if not server:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Server not found',
|
||||
)
|
||||
|
||||
promo_groups = await _get_server_promo_groups(db, server)
|
||||
tariffs_using = await _get_tariffs_using_server(db, server.squad_uuid)
|
||||
active_subs = await count_active_users_for_squad(db, server.squad_uuid)
|
||||
|
||||
return ServerDetailResponse(
|
||||
id=server.id,
|
||||
squad_uuid=server.squad_uuid,
|
||||
display_name=server.display_name,
|
||||
original_name=server.original_name,
|
||||
country_code=server.country_code,
|
||||
description=server.description,
|
||||
is_available=server.is_available,
|
||||
is_trial_eligible=server.is_trial_eligible,
|
||||
price_kopeks=server.price_kopeks,
|
||||
price_rubles=server.price_kopeks / 100,
|
||||
max_users=server.max_users,
|
||||
current_users=server.current_users or 0,
|
||||
sort_order=server.sort_order,
|
||||
is_full=server.is_full,
|
||||
availability_status=server.availability_status,
|
||||
promo_groups=promo_groups,
|
||||
active_subscriptions=active_subs,
|
||||
tariffs_using=tariffs_using,
|
||||
created_at=server.created_at,
|
||||
updated_at=server.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.put('/{server_id}', response_model=ServerDetailResponse)
|
||||
async def update_existing_server(
|
||||
server_id: int,
|
||||
request: ServerUpdateRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update an existing server."""
|
||||
server = await get_server_squad_by_id(db, server_id)
|
||||
if not server:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Server not found',
|
||||
)
|
||||
|
||||
# Build updates dict
|
||||
updates = {}
|
||||
if request.display_name is not None:
|
||||
updates['display_name'] = request.display_name
|
||||
if request.description is not None:
|
||||
updates['description'] = request.description
|
||||
if request.country_code is not None:
|
||||
updates['country_code'] = request.country_code
|
||||
if request.is_available is not None:
|
||||
updates['is_available'] = request.is_available
|
||||
if request.is_trial_eligible is not None:
|
||||
updates['is_trial_eligible'] = request.is_trial_eligible
|
||||
if request.price_kopeks is not None:
|
||||
updates['price_kopeks'] = request.price_kopeks
|
||||
if request.max_users is not None:
|
||||
updates['max_users'] = request.max_users if request.max_users > 0 else None
|
||||
if request.sort_order is not None:
|
||||
updates['sort_order'] = request.sort_order
|
||||
|
||||
if updates:
|
||||
await update_server_squad(db, server_id, **updates)
|
||||
|
||||
# Update promo groups separately
|
||||
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}')
|
||||
|
||||
return await get_server(server_id, admin, db)
|
||||
|
||||
|
||||
@router.post('/{server_id}/toggle', response_model=ServerToggleResponse)
|
||||
async def toggle_server(
|
||||
server_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Toggle server availability."""
|
||||
server = await get_server_squad_by_id(db, server_id)
|
||||
if not server:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Server not found',
|
||||
)
|
||||
|
||||
new_status = not server.is_available
|
||||
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}')
|
||||
|
||||
return ServerToggleResponse(
|
||||
id=server_id,
|
||||
is_available=new_status,
|
||||
message=f'Server {status_text}',
|
||||
)
|
||||
|
||||
|
||||
@router.post('/{server_id}/trial', response_model=ServerTrialToggleResponse)
|
||||
async def toggle_server_trial(
|
||||
server_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Toggle server trial eligibility."""
|
||||
server = await get_server_squad_by_id(db, server_id)
|
||||
if not server:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Server not found',
|
||||
)
|
||||
|
||||
new_status = not server.is_trial_eligible
|
||||
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}')
|
||||
|
||||
return ServerTrialToggleResponse(
|
||||
id=server_id,
|
||||
is_trial_eligible=new_status,
|
||||
message=f'Server {status_text}',
|
||||
)
|
||||
|
||||
|
||||
@router.get('/{server_id}/stats', response_model=ServerStatsResponse)
|
||||
async def get_server_stats(
|
||||
server_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get server statistics."""
|
||||
server = await get_server_squad_by_id(db, server_id)
|
||||
if not server:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Server not found',
|
||||
)
|
||||
|
||||
active_subs = await count_active_users_for_squad(db, server.squad_uuid)
|
||||
|
||||
# Count trial subscriptions on this server
|
||||
# Use LIKE query for JSON array since .contains() is DB-specific
|
||||
trial_result = await db.execute(
|
||||
select(func.count(Subscription.id)).where(
|
||||
Subscription.is_trial == True,
|
||||
Subscription.status == 'active',
|
||||
func.cast(Subscription.connected_squads, String).like(f'%"{server.squad_uuid}"%'),
|
||||
)
|
||||
)
|
||||
trial_count = trial_result.scalar() or 0
|
||||
|
||||
usage_percent = None
|
||||
if server.max_users and server.max_users > 0:
|
||||
usage_percent = round((server.current_users or 0) / server.max_users * 100, 1)
|
||||
|
||||
return ServerStatsResponse(
|
||||
id=server_id,
|
||||
display_name=server.display_name,
|
||||
squad_uuid=server.squad_uuid,
|
||||
current_users=server.current_users or 0,
|
||||
max_users=server.max_users,
|
||||
active_subscriptions=active_subs,
|
||||
trial_subscriptions=trial_count,
|
||||
usage_percent=usage_percent,
|
||||
)
|
||||
|
||||
|
||||
@router.post('/sync', response_model=ServerSyncResponse)
|
||||
async def sync_servers(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Sync servers with RemnaWave."""
|
||||
try:
|
||||
subscription_service = SubscriptionService()
|
||||
if not subscription_service.is_configured:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='RemnaWave is not configured',
|
||||
)
|
||||
|
||||
# Get squads from RemnaWave
|
||||
squads = await subscription_service.get_remnawave_squads()
|
||||
if squads is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to fetch squads from RemnaWave',
|
||||
)
|
||||
|
||||
# Sync with database
|
||||
created, updated, removed = await sync_with_remnawave(db, squads)
|
||||
|
||||
logger.info(f'Admin {admin.id} synced servers: +{created} ~{updated} -{removed}')
|
||||
|
||||
return ServerSyncResponse(
|
||||
created=created,
|
||||
updated=updated,
|
||||
removed=removed,
|
||||
message=f'Synced: {created} created, {updated} updated, {removed} removed',
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to sync servers: {e}')
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f'Sync failed: {e!s}',
|
||||
)
|
||||
@@ -0,0 +1,274 @@
|
||||
"""Admin settings routes for cabinet - system configuration management."""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import User
|
||||
from app.services.system_settings_service import (
|
||||
ReadOnlySettingError,
|
||||
bot_configuration_service,
|
||||
)
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/settings', tags=['Admin Settings'])
|
||||
|
||||
|
||||
# ============ Schemas ============
|
||||
|
||||
|
||||
class SettingCategoryRef(BaseModel):
|
||||
"""Reference to category."""
|
||||
|
||||
key: str
|
||||
label: str
|
||||
|
||||
|
||||
class SettingCategorySummary(BaseModel):
|
||||
"""Category summary."""
|
||||
|
||||
key: str
|
||||
label: str
|
||||
description: str = ''
|
||||
items: int
|
||||
|
||||
|
||||
class SettingChoice(BaseModel):
|
||||
"""Choice option for setting."""
|
||||
|
||||
value: Any
|
||||
label: str
|
||||
description: str | None = None
|
||||
|
||||
|
||||
class SettingHint(BaseModel):
|
||||
"""Setting hints and guidance."""
|
||||
|
||||
description: str = ''
|
||||
format: str = ''
|
||||
example: str = ''
|
||||
warning: str = ''
|
||||
|
||||
|
||||
class SettingDefinition(BaseModel):
|
||||
"""Full setting definition with current state."""
|
||||
|
||||
key: str
|
||||
name: str
|
||||
category: SettingCategoryRef
|
||||
type: str
|
||||
is_optional: bool
|
||||
current: Any = Field(default=None)
|
||||
original: Any = Field(default=None)
|
||||
has_override: bool
|
||||
read_only: bool = Field(default=False)
|
||||
choices: list[SettingChoice] = Field(default_factory=list)
|
||||
hint: SettingHint | None = None
|
||||
|
||||
|
||||
class SettingUpdateRequest(BaseModel):
|
||||
"""Request to update setting value."""
|
||||
|
||||
value: Any
|
||||
|
||||
|
||||
# ============ Helper Functions ============
|
||||
|
||||
|
||||
def _coerce_value(key: str, value: Any) -> Any:
|
||||
"""Convert and validate value for a setting."""
|
||||
definition = bot_configuration_service.get_definition(key)
|
||||
|
||||
if value is None:
|
||||
if definition.is_optional:
|
||||
return None
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Value is required')
|
||||
|
||||
python_type = definition.python_type
|
||||
|
||||
try:
|
||||
if python_type is bool:
|
||||
if isinstance(value, bool):
|
||||
normalized = value
|
||||
elif isinstance(value, str):
|
||||
lowered = value.strip().lower()
|
||||
if lowered in {'true', '1', 'yes', 'on', 'да'}:
|
||||
normalized = True
|
||||
elif lowered in {'false', '0', 'no', 'off', 'нет'}:
|
||||
normalized = False
|
||||
else:
|
||||
raise ValueError('invalid bool')
|
||||
else:
|
||||
raise ValueError('invalid bool')
|
||||
|
||||
elif python_type is int:
|
||||
normalized = int(value)
|
||||
elif python_type is float:
|
||||
normalized = float(value)
|
||||
else:
|
||||
normalized = str(value)
|
||||
except ValueError:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Invalid value type') from None
|
||||
|
||||
choices = bot_configuration_service.get_choice_options(key)
|
||||
if choices:
|
||||
allowed_values = {option.value for option in choices}
|
||||
if normalized not in allowed_values:
|
||||
readable = ', '.join(bot_configuration_service.format_value(opt.value) for opt in choices)
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Value must be one of: {readable}',
|
||||
)
|
||||
|
||||
return normalized
|
||||
|
||||
|
||||
def _serialize_definition(definition, include_choices: bool = True) -> SettingDefinition:
|
||||
"""Serialize setting definition to response model."""
|
||||
current = bot_configuration_service.get_current_value(definition.key)
|
||||
original = bot_configuration_service.get_original_value(definition.key)
|
||||
has_override = bot_configuration_service.has_override(definition.key)
|
||||
|
||||
choices: list[SettingChoice] = []
|
||||
if include_choices:
|
||||
choices = [
|
||||
SettingChoice(
|
||||
value=option.value,
|
||||
label=option.label,
|
||||
description=option.description,
|
||||
)
|
||||
for option in bot_configuration_service.get_choice_options(definition.key)
|
||||
]
|
||||
|
||||
# Get setting hints
|
||||
guidance = bot_configuration_service.get_setting_guidance(definition.key)
|
||||
hint = SettingHint(
|
||||
description=guidance.get('description', ''),
|
||||
format=guidance.get('format', ''),
|
||||
example=guidance.get('example', ''),
|
||||
warning=guidance.get('warning', ''),
|
||||
)
|
||||
|
||||
return SettingDefinition(
|
||||
key=definition.key,
|
||||
name=definition.display_name,
|
||||
category=SettingCategoryRef(
|
||||
key=definition.category_key,
|
||||
label=definition.category_label,
|
||||
),
|
||||
type=definition.type_label,
|
||||
is_optional=definition.is_optional,
|
||||
current=current,
|
||||
original=original,
|
||||
has_override=has_override,
|
||||
read_only=bot_configuration_service.is_read_only(definition.key),
|
||||
choices=choices,
|
||||
hint=hint,
|
||||
)
|
||||
|
||||
|
||||
# ============ Routes ============
|
||||
|
||||
|
||||
@router.get('/categories', response_model=list[SettingCategorySummary])
|
||||
async def list_categories(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""Get list of setting categories."""
|
||||
categories = bot_configuration_service.get_categories()
|
||||
return [
|
||||
SettingCategorySummary(
|
||||
key=key,
|
||||
label=label,
|
||||
description=bot_configuration_service.get_category_description(key),
|
||||
items=count,
|
||||
)
|
||||
for key, label, count in categories
|
||||
]
|
||||
|
||||
|
||||
@router.get('', response_model=list[SettingDefinition])
|
||||
async def list_settings(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
category: str | None = Query(default=None, alias='category_key'),
|
||||
):
|
||||
"""Get list of all settings or settings for a specific category."""
|
||||
items: list[SettingDefinition] = []
|
||||
|
||||
if category:
|
||||
definitions = bot_configuration_service.get_settings_for_category(category)
|
||||
items.extend(_serialize_definition(defn) for defn in definitions)
|
||||
return items
|
||||
|
||||
for category_key, _, _ in bot_configuration_service.get_categories():
|
||||
definitions = bot_configuration_service.get_settings_for_category(category_key)
|
||||
items.extend(_serialize_definition(defn) for defn in definitions)
|
||||
|
||||
return items
|
||||
|
||||
|
||||
@router.get('/{key}', response_model=SettingDefinition)
|
||||
async def get_setting(
|
||||
key: str,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""Get a specific setting by key."""
|
||||
try:
|
||||
definition = bot_configuration_service.get_definition(key)
|
||||
except KeyError as error:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Setting not found') from error
|
||||
|
||||
return _serialize_definition(definition)
|
||||
|
||||
|
||||
@router.put('/{key}', response_model=SettingDefinition)
|
||||
async def update_setting(
|
||||
key: str,
|
||||
payload: SettingUpdateRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update a setting value."""
|
||||
try:
|
||||
definition = bot_configuration_service.get_definition(key)
|
||||
except KeyError as error:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Setting not found') from error
|
||||
|
||||
value = _coerce_value(key, payload.value)
|
||||
try:
|
||||
await bot_configuration_service.set_value(db, key, value)
|
||||
except ReadOnlySettingError as error:
|
||||
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}')
|
||||
return _serialize_definition(definition)
|
||||
|
||||
|
||||
@router.delete('/{key}', response_model=SettingDefinition)
|
||||
async def reset_setting(
|
||||
key: str,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Reset a setting to its default value."""
|
||||
try:
|
||||
definition = bot_configuration_service.get_definition(key)
|
||||
except KeyError as error:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Setting not found') from error
|
||||
|
||||
try:
|
||||
await bot_configuration_service.reset_value(db, key)
|
||||
except ReadOnlySettingError as error:
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, str(error)) from error
|
||||
await db.commit()
|
||||
|
||||
logger.info(f'Admin {admin.telegram_id} reset setting {key}')
|
||||
return _serialize_definition(definition)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,551 @@
|
||||
"""Admin routes for managing tariffs in cabinet."""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.crud.server_squad import get_all_server_squads
|
||||
from app.database.crud.tariff import (
|
||||
create_tariff,
|
||||
delete_tariff,
|
||||
get_all_tariffs,
|
||||
get_tariff_by_id,
|
||||
get_tariff_subscriptions_count,
|
||||
load_period_prices_from_db,
|
||||
reorder_tariffs,
|
||||
set_tariff_promo_groups,
|
||||
update_tariff,
|
||||
)
|
||||
from app.database.models import PromoGroup, Subscription, Tariff, Transaction, TransactionType, User
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
from ..schemas.tariffs import (
|
||||
PeriodPrice,
|
||||
PromoGroupInfo,
|
||||
ServerInfo,
|
||||
ServerTrafficLimit,
|
||||
TariffCreateRequest,
|
||||
TariffDetailResponse,
|
||||
TariffListItem,
|
||||
TariffListResponse,
|
||||
TariffSortOrderRequest,
|
||||
TariffStatsResponse,
|
||||
TariffToggleResponse,
|
||||
TariffTrialResponse,
|
||||
TariffUpdateRequest,
|
||||
)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/tariffs', tags=['Cabinet Admin Tariffs'])
|
||||
|
||||
|
||||
async def _get_tariff_servers(
|
||||
db: AsyncSession, allowed_squads: list[str], server_traffic_limits: dict = None
|
||||
) -> list[ServerInfo]:
|
||||
"""Get server info for tariff."""
|
||||
servers, _ = await get_all_server_squads(db, available_only=False)
|
||||
limits = server_traffic_limits or {}
|
||||
result = []
|
||||
for server in servers:
|
||||
# Получаем индивидуальный лимит трафика для сервера
|
||||
server_limit = None
|
||||
if server.squad_uuid in limits:
|
||||
limit_data = limits[server.squad_uuid]
|
||||
if isinstance(limit_data, dict) and 'traffic_limit_gb' in limit_data:
|
||||
server_limit = limit_data['traffic_limit_gb']
|
||||
elif isinstance(limit_data, int):
|
||||
server_limit = limit_data
|
||||
|
||||
result.append(
|
||||
ServerInfo(
|
||||
id=server.id,
|
||||
squad_uuid=server.squad_uuid,
|
||||
display_name=server.display_name,
|
||||
country_code=server.country_code,
|
||||
is_selected=server.squad_uuid in allowed_squads,
|
||||
traffic_limit_gb=server_limit,
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
async def _get_tariff_promo_groups(db: AsyncSession, tariff: Tariff) -> list[PromoGroupInfo]:
|
||||
"""Get promo group info for tariff."""
|
||||
result = await db.execute(select(PromoGroup).order_by(PromoGroup.name))
|
||||
all_groups = result.scalars().all()
|
||||
|
||||
selected_ids = {pg.id for pg in tariff.allowed_promo_groups} if tariff.allowed_promo_groups else set()
|
||||
|
||||
return [
|
||||
PromoGroupInfo(
|
||||
id=pg.id,
|
||||
name=pg.name,
|
||||
is_selected=pg.id in selected_ids,
|
||||
)
|
||||
for pg in all_groups
|
||||
]
|
||||
|
||||
|
||||
def _period_prices_to_list(period_prices: dict) -> list[PeriodPrice]:
|
||||
"""Convert period_prices dict to list."""
|
||||
if not period_prices:
|
||||
return []
|
||||
return [
|
||||
PeriodPrice(days=int(days), price_kopeks=price)
|
||||
for days, price in sorted(period_prices.items(), key=lambda x: int(x[0]))
|
||||
]
|
||||
|
||||
|
||||
def _period_prices_to_dict(period_prices: list[PeriodPrice]) -> dict:
|
||||
"""Convert period_prices list to dict."""
|
||||
return {str(pp.days): pp.price_kopeks for pp in period_prices}
|
||||
|
||||
|
||||
@router.get('', response_model=TariffListResponse)
|
||||
async def list_tariffs(
|
||||
include_inactive: bool = True,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get list of all tariffs."""
|
||||
tariffs = await get_all_tariffs(db, include_inactive=include_inactive)
|
||||
|
||||
items = []
|
||||
for tariff in tariffs:
|
||||
subs_count = await get_tariff_subscriptions_count(db, tariff.id)
|
||||
items.append(
|
||||
TariffListItem(
|
||||
id=tariff.id,
|
||||
name=tariff.name,
|
||||
description=tariff.description,
|
||||
is_active=tariff.is_active,
|
||||
is_trial_available=tariff.is_trial_available,
|
||||
is_daily=tariff.is_daily,
|
||||
daily_price_kopeks=tariff.daily_price_kopeks,
|
||||
allow_traffic_topup=tariff.allow_traffic_topup,
|
||||
traffic_limit_gb=tariff.traffic_limit_gb,
|
||||
device_limit=tariff.device_limit,
|
||||
tier_level=tariff.tier_level,
|
||||
display_order=tariff.display_order,
|
||||
servers_count=len(tariff.allowed_squads or []),
|
||||
subscriptions_count=subs_count,
|
||||
created_at=tariff.created_at,
|
||||
)
|
||||
)
|
||||
|
||||
return TariffListResponse(tariffs=items, total=len(items))
|
||||
|
||||
|
||||
@router.get('/available-servers', response_model=list[ServerInfo])
|
||||
async def get_available_servers(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get list of all servers for tariff selection."""
|
||||
servers, _ = await get_all_server_squads(db, available_only=False)
|
||||
return [
|
||||
ServerInfo(
|
||||
id=server.id,
|
||||
squad_uuid=server.squad_uuid,
|
||||
display_name=server.display_name,
|
||||
country_code=server.country_code,
|
||||
is_selected=False,
|
||||
)
|
||||
for server in 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(f'Admin {admin.id} updated tariff order: {request.tariff_ids}')
|
||||
|
||||
return {'message': 'Tariff order updated successfully'}
|
||||
|
||||
|
||||
@router.get('/{tariff_id}', response_model=TariffDetailResponse)
|
||||
async def get_tariff(
|
||||
tariff_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get detailed tariff info."""
|
||||
tariff = await get_tariff_by_id(db, tariff_id)
|
||||
if not tariff:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Tariff not found',
|
||||
)
|
||||
|
||||
allowed_squads = tariff.allowed_squads or []
|
||||
server_traffic_limits = tariff.server_traffic_limits or {}
|
||||
servers = await _get_tariff_servers(db, allowed_squads, server_traffic_limits)
|
||||
promo_groups = await _get_tariff_promo_groups(db, tariff)
|
||||
subs_count = await get_tariff_subscriptions_count(db, tariff.id)
|
||||
|
||||
# Преобразуем server_traffic_limits в формат для схемы
|
||||
server_limits_response = {}
|
||||
for uuid, limit_data in server_traffic_limits.items():
|
||||
if isinstance(limit_data, dict):
|
||||
server_limits_response[uuid] = ServerTrafficLimit(**limit_data)
|
||||
elif isinstance(limit_data, int):
|
||||
server_limits_response[uuid] = ServerTrafficLimit(traffic_limit_gb=limit_data)
|
||||
|
||||
return TariffDetailResponse(
|
||||
id=tariff.id,
|
||||
name=tariff.name,
|
||||
description=tariff.description,
|
||||
is_active=tariff.is_active,
|
||||
is_trial_available=tariff.is_trial_available,
|
||||
allow_traffic_topup=tariff.allow_traffic_topup,
|
||||
traffic_topup_enabled=tariff.traffic_topup_enabled,
|
||||
traffic_topup_packages=tariff.traffic_topup_packages or {},
|
||||
max_topup_traffic_gb=tariff.max_topup_traffic_gb,
|
||||
traffic_limit_gb=tariff.traffic_limit_gb,
|
||||
device_limit=tariff.device_limit,
|
||||
device_price_kopeks=tariff.device_price_kopeks,
|
||||
max_device_limit=tariff.max_device_limit,
|
||||
tier_level=tariff.tier_level,
|
||||
display_order=tariff.display_order,
|
||||
period_prices=_period_prices_to_list(tariff.period_prices),
|
||||
allowed_squads=allowed_squads,
|
||||
server_traffic_limits=server_limits_response,
|
||||
servers=servers,
|
||||
promo_groups=promo_groups,
|
||||
subscriptions_count=subs_count,
|
||||
# Произвольное количество дней
|
||||
custom_days_enabled=tariff.custom_days_enabled,
|
||||
price_per_day_kopeks=tariff.price_per_day_kopeks,
|
||||
min_days=tariff.min_days,
|
||||
max_days=tariff.max_days,
|
||||
# Произвольный трафик при покупке
|
||||
custom_traffic_enabled=tariff.custom_traffic_enabled,
|
||||
traffic_price_per_gb_kopeks=tariff.traffic_price_per_gb_kopeks,
|
||||
min_traffic_gb=tariff.min_traffic_gb,
|
||||
max_traffic_gb=tariff.max_traffic_gb,
|
||||
# Дневной тариф
|
||||
is_daily=tariff.is_daily,
|
||||
daily_price_kopeks=tariff.daily_price_kopeks,
|
||||
# Режим сброса трафика
|
||||
traffic_reset_mode=tariff.traffic_reset_mode,
|
||||
created_at=tariff.created_at,
|
||||
updated_at=tariff.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.post('', response_model=TariffDetailResponse)
|
||||
async def create_new_tariff(
|
||||
request: TariffCreateRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Create a new tariff."""
|
||||
period_prices_dict = _period_prices_to_dict(request.period_prices)
|
||||
|
||||
# Преобразуем ServerTrafficLimit в dict для хранения
|
||||
server_limits_dict = (
|
||||
{uuid: limit.model_dump() for uuid, limit in request.server_traffic_limits.items()}
|
||||
if request.server_traffic_limits
|
||||
else {}
|
||||
)
|
||||
|
||||
tariff = await create_tariff(
|
||||
db=db,
|
||||
name=request.name,
|
||||
description=request.description,
|
||||
is_active=request.is_active,
|
||||
allow_traffic_topup=request.allow_traffic_topup,
|
||||
traffic_topup_enabled=request.traffic_topup_enabled,
|
||||
traffic_topup_packages=request.traffic_topup_packages,
|
||||
max_topup_traffic_gb=request.max_topup_traffic_gb,
|
||||
traffic_limit_gb=request.traffic_limit_gb,
|
||||
device_limit=request.device_limit,
|
||||
device_price_kopeks=request.device_price_kopeks,
|
||||
max_device_limit=request.max_device_limit,
|
||||
tier_level=request.tier_level,
|
||||
period_prices=period_prices_dict,
|
||||
allowed_squads=request.allowed_squads,
|
||||
server_traffic_limits=server_limits_dict,
|
||||
promo_group_ids=request.promo_group_ids if request.promo_group_ids else None,
|
||||
# Произвольное количество дней
|
||||
custom_days_enabled=request.custom_days_enabled,
|
||||
price_per_day_kopeks=request.price_per_day_kopeks,
|
||||
min_days=request.min_days,
|
||||
max_days=request.max_days,
|
||||
# Произвольный трафик при покупке
|
||||
custom_traffic_enabled=request.custom_traffic_enabled,
|
||||
traffic_price_per_gb_kopeks=request.traffic_price_per_gb_kopeks,
|
||||
min_traffic_gb=request.min_traffic_gb,
|
||||
max_traffic_gb=request.max_traffic_gb,
|
||||
# Дневной тариф
|
||||
is_daily=request.is_daily,
|
||||
daily_price_kopeks=request.daily_price_kopeks,
|
||||
# Режим сброса трафика
|
||||
traffic_reset_mode=request.traffic_reset_mode,
|
||||
)
|
||||
|
||||
logger.info(f'Admin {admin.id} created tariff {tariff.id}: {tariff.name}')
|
||||
|
||||
# Перезагружаем периоды из БД для синхронизации с ботом
|
||||
await load_period_prices_from_db(db)
|
||||
|
||||
# Return full detail
|
||||
return await get_tariff(tariff.id, admin, db)
|
||||
|
||||
|
||||
@router.put('/{tariff_id}', response_model=TariffDetailResponse)
|
||||
async def update_existing_tariff(
|
||||
tariff_id: int,
|
||||
request: TariffUpdateRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update an existing tariff."""
|
||||
tariff = await get_tariff_by_id(db, tariff_id)
|
||||
if not tariff:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Tariff not found',
|
||||
)
|
||||
|
||||
# Build updates dict
|
||||
updates = {}
|
||||
if request.name is not None:
|
||||
updates['name'] = request.name
|
||||
if request.description is not None:
|
||||
updates['description'] = request.description
|
||||
if request.is_active is not None:
|
||||
updates['is_active'] = request.is_active
|
||||
if request.allow_traffic_topup is not None:
|
||||
updates['allow_traffic_topup'] = request.allow_traffic_topup
|
||||
if request.traffic_topup_enabled is not None:
|
||||
updates['traffic_topup_enabled'] = request.traffic_topup_enabled
|
||||
if request.traffic_topup_packages is not None:
|
||||
updates['traffic_topup_packages'] = request.traffic_topup_packages
|
||||
if request.max_topup_traffic_gb is not None:
|
||||
updates['max_topup_traffic_gb'] = request.max_topup_traffic_gb
|
||||
if request.traffic_limit_gb is not None:
|
||||
updates['traffic_limit_gb'] = request.traffic_limit_gb
|
||||
if request.device_limit is not None:
|
||||
updates['device_limit'] = request.device_limit
|
||||
if request.device_price_kopeks is not None:
|
||||
updates['device_price_kopeks'] = request.device_price_kopeks
|
||||
if request.max_device_limit is not None:
|
||||
updates['max_device_limit'] = request.max_device_limit
|
||||
if request.tier_level is not None:
|
||||
updates['tier_level'] = request.tier_level
|
||||
if request.display_order is not None:
|
||||
updates['display_order'] = request.display_order
|
||||
if request.period_prices is not None:
|
||||
updates['period_prices'] = _period_prices_to_dict(request.period_prices)
|
||||
if request.allowed_squads is not None:
|
||||
updates['allowed_squads'] = request.allowed_squads
|
||||
if request.server_traffic_limits is not None:
|
||||
# Преобразуем ServerTrafficLimit в dict для хранения
|
||||
updates['server_traffic_limits'] = {
|
||||
uuid: limit.model_dump() for uuid, limit in request.server_traffic_limits.items()
|
||||
}
|
||||
# Произвольное количество дней
|
||||
if request.custom_days_enabled is not None:
|
||||
updates['custom_days_enabled'] = request.custom_days_enabled
|
||||
if request.price_per_day_kopeks is not None:
|
||||
updates['price_per_day_kopeks'] = request.price_per_day_kopeks
|
||||
if request.min_days is not None:
|
||||
updates['min_days'] = request.min_days
|
||||
if request.max_days is not None:
|
||||
updates['max_days'] = request.max_days
|
||||
# Произвольный трафик при покупке
|
||||
if request.custom_traffic_enabled is not None:
|
||||
updates['custom_traffic_enabled'] = request.custom_traffic_enabled
|
||||
if request.traffic_price_per_gb_kopeks is not None:
|
||||
updates['traffic_price_per_gb_kopeks'] = request.traffic_price_per_gb_kopeks
|
||||
if request.min_traffic_gb is not None:
|
||||
updates['min_traffic_gb'] = request.min_traffic_gb
|
||||
if request.max_traffic_gb is not None:
|
||||
updates['max_traffic_gb'] = request.max_traffic_gb
|
||||
# Дневной тариф
|
||||
if request.is_daily is not None:
|
||||
updates['is_daily'] = request.is_daily
|
||||
if request.daily_price_kopeks is not None:
|
||||
updates['daily_price_kopeks'] = request.daily_price_kopeks
|
||||
# Режим сброса трафика (None допускается как значение для сброса к глобальной настройке)
|
||||
if 'traffic_reset_mode' in request.model_fields_set:
|
||||
updates['traffic_reset_mode'] = request.traffic_reset_mode
|
||||
|
||||
if updates:
|
||||
await update_tariff(db, tariff, **updates)
|
||||
|
||||
# Update promo groups separately
|
||||
if request.promo_group_ids is not None:
|
||||
await set_tariff_promo_groups(db, tariff, request.promo_group_ids)
|
||||
|
||||
logger.info(f'Admin {admin.id} updated tariff {tariff_id}')
|
||||
|
||||
# Перезагружаем периоды из БД для синхронизации с ботом
|
||||
await load_period_prices_from_db(db)
|
||||
|
||||
return await get_tariff(tariff_id, admin, db)
|
||||
|
||||
|
||||
@router.delete('/{tariff_id}')
|
||||
async def delete_existing_tariff(
|
||||
tariff_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Delete a tariff."""
|
||||
tariff = await get_tariff_by_id(db, tariff_id)
|
||||
if not tariff:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Tariff not found',
|
||||
)
|
||||
|
||||
subs_count = await get_tariff_subscriptions_count(db, tariff_id)
|
||||
await delete_tariff(db, tariff)
|
||||
logger.info(f'Admin {admin.id} deleted tariff {tariff_id}: {tariff.name} (affected subscriptions: {subs_count})')
|
||||
|
||||
# Перезагружаем периоды из БД для синхронизации с ботом
|
||||
await load_period_prices_from_db(db)
|
||||
|
||||
return {'message': 'Tariff deleted successfully', 'affected_subscriptions': subs_count}
|
||||
|
||||
|
||||
@router.post('/{tariff_id}/toggle', response_model=TariffToggleResponse)
|
||||
async def toggle_tariff(
|
||||
tariff_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Toggle tariff active status."""
|
||||
tariff = await get_tariff_by_id(db, tariff_id)
|
||||
if not tariff:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Tariff not found',
|
||||
)
|
||||
|
||||
new_status = not tariff.is_active
|
||||
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}')
|
||||
|
||||
# Перезагружаем периоды из БД для синхронизации с ботом
|
||||
await load_period_prices_from_db(db)
|
||||
|
||||
return TariffToggleResponse(
|
||||
id=tariff_id,
|
||||
is_active=new_status,
|
||||
message=f'Tariff {status_text}',
|
||||
)
|
||||
|
||||
|
||||
@router.post('/{tariff_id}/trial', response_model=TariffTrialResponse)
|
||||
async def toggle_trial_tariff(
|
||||
tariff_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Toggle tariff trial availability.
|
||||
|
||||
When enabling trial on a tariff, removes trial flag from all other tariffs
|
||||
(only one tariff can be the trial tariff at a time).
|
||||
"""
|
||||
tariff = await get_tariff_by_id(db, tariff_id)
|
||||
if not tariff:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Tariff not found',
|
||||
)
|
||||
|
||||
new_status = not tariff.is_trial_available
|
||||
|
||||
if new_status:
|
||||
# При включении триала - снимаем флаг со ВСЕХ тарифов, затем ставим на текущий
|
||||
# Это гарантирует, что триальным будет только один тариф
|
||||
await db.execute(Tariff.__table__.update().values(is_trial_available=False))
|
||||
await db.commit()
|
||||
# Обновляем объект тарифа после массового обновления
|
||||
await db.refresh(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}')
|
||||
|
||||
return TariffTrialResponse(
|
||||
id=tariff_id,
|
||||
is_trial_available=new_status,
|
||||
message=f'Tariff {status_text}',
|
||||
)
|
||||
|
||||
|
||||
@router.get('/{tariff_id}/stats', response_model=TariffStatsResponse)
|
||||
async def get_tariff_stats(
|
||||
tariff_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get tariff statistics."""
|
||||
tariff = await get_tariff_by_id(db, tariff_id)
|
||||
if not tariff:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Tariff not found',
|
||||
)
|
||||
|
||||
# Count subscriptions
|
||||
total_result = await db.execute(select(func.count(Subscription.id)).where(Subscription.tariff_id == tariff_id))
|
||||
total_count = total_result.scalar() or 0
|
||||
|
||||
# Count active subscriptions
|
||||
active_result = await db.execute(
|
||||
select(func.count(Subscription.id)).where(
|
||||
Subscription.tariff_id == tariff_id,
|
||||
Subscription.status == 'active',
|
||||
)
|
||||
)
|
||||
active_count = active_result.scalar() or 0
|
||||
|
||||
# Count trial subscriptions
|
||||
trial_result = await db.execute(
|
||||
select(func.count(Subscription.id)).where(
|
||||
Subscription.tariff_id == tariff_id,
|
||||
Subscription.is_trial == True,
|
||||
)
|
||||
)
|
||||
trial_count = trial_result.scalar() or 0
|
||||
|
||||
# Calculate revenue from subscription payments for users on this tariff
|
||||
revenue_result = await db.execute(
|
||||
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0))
|
||||
.join(Subscription, Transaction.user_id == Subscription.user_id)
|
||||
.where(
|
||||
Subscription.tariff_id == tariff_id,
|
||||
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
|
||||
Transaction.is_completed == True,
|
||||
)
|
||||
)
|
||||
revenue_kopeks = revenue_result.scalar() or 0
|
||||
|
||||
return TariffStatsResponse(
|
||||
id=tariff_id,
|
||||
name=tariff.name,
|
||||
subscriptions_count=total_count,
|
||||
active_subscriptions=active_count,
|
||||
trial_subscriptions=trial_count,
|
||||
revenue_kopeks=revenue_kopeks,
|
||||
revenue_rubles=revenue_kopeks / 100,
|
||||
)
|
||||
@@ -0,0 +1,607 @@
|
||||
"""Admin tickets routes for cabinet."""
|
||||
|
||||
import logging
|
||||
import math
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import desc, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.cabinet.routes.websocket import notify_user_ticket_reply
|
||||
from app.config import settings
|
||||
from app.database.crud.ticket import TicketCRUD
|
||||
from app.database.crud.ticket_notification import TicketNotificationCRUD
|
||||
from app.database.models import Ticket, TicketMessage, User
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
from ..schemas.tickets import TicketMessageResponse
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/tickets', tags=['Cabinet Admin Tickets'])
|
||||
|
||||
|
||||
# Admin-specific schemas
|
||||
class AdminTicketUserInfo(BaseModel):
|
||||
"""User info for admin view."""
|
||||
|
||||
id: int
|
||||
telegram_id: int | None = None # Can be None for email-only users
|
||||
email: str | None = None
|
||||
username: str | None = None
|
||||
first_name: str | None = None
|
||||
last_name: str | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class AdminTicketResponse(BaseModel):
|
||||
"""Ticket data for admin."""
|
||||
|
||||
id: int
|
||||
title: str
|
||||
status: str
|
||||
priority: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
closed_at: datetime | None = None
|
||||
messages_count: int = 0
|
||||
user: AdminTicketUserInfo | None = None
|
||||
last_message: TicketMessageResponse | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class AdminTicketDetailResponse(BaseModel):
|
||||
"""Ticket with all messages for admin."""
|
||||
|
||||
id: int
|
||||
title: str
|
||||
status: str
|
||||
priority: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
closed_at: datetime | None = None
|
||||
is_reply_blocked: bool = False
|
||||
user: AdminTicketUserInfo | None = None
|
||||
messages: list[TicketMessageResponse] = []
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class AdminTicketListResponse(BaseModel):
|
||||
"""Paginated ticket list for admin."""
|
||||
|
||||
items: list[AdminTicketResponse]
|
||||
total: int
|
||||
page: int
|
||||
per_page: int
|
||||
pages: int
|
||||
|
||||
|
||||
class AdminReplyRequest(BaseModel):
|
||||
"""Admin reply to ticket."""
|
||||
|
||||
message: str = Field(..., min_length=1, max_length=4000, description='Reply message')
|
||||
|
||||
|
||||
class AdminStatusUpdateRequest(BaseModel):
|
||||
"""Update ticket status."""
|
||||
|
||||
status: str = Field(..., description='New status: open, answered, pending, closed')
|
||||
|
||||
|
||||
class AdminPriorityUpdateRequest(BaseModel):
|
||||
"""Update ticket priority."""
|
||||
|
||||
priority: str = Field(..., description='New priority: low, normal, high, urgent')
|
||||
|
||||
|
||||
class AdminStatsResponse(BaseModel):
|
||||
"""Ticket statistics for admin."""
|
||||
|
||||
total: int
|
||||
open: int
|
||||
pending: int
|
||||
answered: int
|
||||
closed: int
|
||||
|
||||
|
||||
class TicketSettingsResponse(BaseModel):
|
||||
"""Ticket system settings."""
|
||||
|
||||
sla_enabled: bool
|
||||
sla_minutes: int
|
||||
sla_check_interval_seconds: int
|
||||
sla_reminder_cooldown_minutes: int
|
||||
support_system_mode: str # tickets, contact, both
|
||||
# Cabinet notifications settings
|
||||
cabinet_user_notifications_enabled: bool = True
|
||||
cabinet_admin_notifications_enabled: bool = True
|
||||
|
||||
|
||||
class TicketSettingsUpdateRequest(BaseModel):
|
||||
"""Update ticket settings."""
|
||||
|
||||
sla_enabled: bool | None = None
|
||||
sla_minutes: int | None = Field(None, ge=1, le=1440, description='SLA time in minutes (1-1440)')
|
||||
sla_check_interval_seconds: int | None = Field(None, ge=30, le=600, description='Check interval (30-600 seconds)')
|
||||
sla_reminder_cooldown_minutes: int | None = Field(
|
||||
None, ge=1, le=120, description='Reminder cooldown (1-120 minutes)'
|
||||
)
|
||||
support_system_mode: str | None = Field(None, description='Support mode: tickets, contact, both')
|
||||
# Cabinet notifications settings
|
||||
cabinet_user_notifications_enabled: bool | None = Field(None, description='Enable user notifications in cabinet')
|
||||
cabinet_admin_notifications_enabled: bool | None = Field(None, description='Enable admin notifications in cabinet')
|
||||
|
||||
|
||||
def _message_to_response(message: TicketMessage) -> TicketMessageResponse:
|
||||
"""Convert TicketMessage to response."""
|
||||
return TicketMessageResponse(
|
||||
id=message.id,
|
||||
message_text=message.message_text or '',
|
||||
is_from_admin=message.is_from_admin,
|
||||
has_media=bool(message.media_file_id),
|
||||
media_type=message.media_type,
|
||||
media_file_id=message.media_file_id,
|
||||
media_caption=message.media_caption,
|
||||
created_at=message.created_at,
|
||||
)
|
||||
|
||||
|
||||
def _user_to_info(user: User) -> AdminTicketUserInfo:
|
||||
"""Convert User to admin info."""
|
||||
return AdminTicketUserInfo(
|
||||
id=user.id,
|
||||
telegram_id=user.telegram_id,
|
||||
email=user.email,
|
||||
username=user.username,
|
||||
first_name=user.first_name,
|
||||
last_name=user.last_name,
|
||||
)
|
||||
|
||||
|
||||
def _ticket_to_admin_response(ticket: Ticket, include_messages: bool = False) -> AdminTicketResponse:
|
||||
"""Convert Ticket to admin response."""
|
||||
last_message = None
|
||||
messages_count = len(ticket.messages) if ticket.messages else 0
|
||||
|
||||
if ticket.messages:
|
||||
last_msg = max(ticket.messages, key=lambda m: m.created_at)
|
||||
last_message = _message_to_response(last_msg)
|
||||
|
||||
user_info = None
|
||||
if hasattr(ticket, 'user') and ticket.user:
|
||||
user_info = _user_to_info(ticket.user)
|
||||
|
||||
return AdminTicketResponse(
|
||||
id=ticket.id,
|
||||
title=ticket.title or f'Ticket #{ticket.id}',
|
||||
status=ticket.status,
|
||||
priority=ticket.priority or 'normal',
|
||||
created_at=ticket.created_at,
|
||||
updated_at=ticket.updated_at or ticket.created_at,
|
||||
closed_at=ticket.closed_at,
|
||||
messages_count=messages_count,
|
||||
user=user_info,
|
||||
last_message=last_message,
|
||||
)
|
||||
|
||||
|
||||
@router.get('/stats', response_model=AdminStatsResponse)
|
||||
async def get_ticket_stats(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get ticket statistics."""
|
||||
# Total count
|
||||
total_result = await db.execute(select(func.count()).select_from(Ticket))
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
# Count by status
|
||||
statuses = {}
|
||||
for status_name in ['open', 'pending', 'answered', 'closed']:
|
||||
result = await db.execute(select(func.count()).select_from(Ticket).where(Ticket.status == status_name))
|
||||
statuses[status_name] = result.scalar() or 0
|
||||
|
||||
return AdminStatsResponse(
|
||||
total=total,
|
||||
open=statuses.get('open', 0),
|
||||
pending=statuses.get('pending', 0),
|
||||
answered=statuses.get('answered', 0),
|
||||
closed=statuses.get('closed', 0),
|
||||
)
|
||||
|
||||
|
||||
@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(),
|
||||
)
|
||||
|
||||
|
||||
@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),
|
||||
):
|
||||
"""Get all tickets for admin."""
|
||||
# Base query with user relationship
|
||||
query = select(Ticket).options(selectinload(Ticket.messages), selectinload(Ticket.user))
|
||||
|
||||
# Build count query
|
||||
count_query = select(func.count()).select_from(Ticket)
|
||||
|
||||
# Apply filters
|
||||
if status_filter:
|
||||
query = query.where(Ticket.status == status_filter)
|
||||
count_query = count_query.where(Ticket.status == status_filter)
|
||||
|
||||
if priority_filter:
|
||||
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
|
||||
|
||||
# Paginate - order by updated_at desc (newest first)
|
||||
offset = (page - 1) * per_page
|
||||
query = query.order_by(desc(Ticket.updated_at)).offset(offset).limit(per_page)
|
||||
|
||||
result = await db.execute(query)
|
||||
tickets = result.scalars().all()
|
||||
|
||||
items = [_ticket_to_admin_response(t) for t in tickets]
|
||||
pages = math.ceil(total / per_page) if total > 0 else 1
|
||||
|
||||
return AdminTicketListResponse(
|
||||
items=items,
|
||||
total=total,
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
pages=pages,
|
||||
)
|
||||
|
||||
|
||||
@router.get('/{ticket_id}', response_model=AdminTicketDetailResponse)
|
||||
async def get_ticket_detail(
|
||||
ticket_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get ticket with all messages for admin."""
|
||||
query = (
|
||||
select(Ticket).where(Ticket.id == ticket_id).options(selectinload(Ticket.messages), selectinload(Ticket.user))
|
||||
)
|
||||
|
||||
result = await db.execute(query)
|
||||
ticket = result.scalar_one_or_none()
|
||||
|
||||
if not ticket:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Ticket not found',
|
||||
)
|
||||
|
||||
messages = sorted(ticket.messages or [], key=lambda m: m.created_at)
|
||||
messages_response = [_message_to_response(m) for m in messages]
|
||||
|
||||
user_info = None
|
||||
if ticket.user:
|
||||
user_info = _user_to_info(ticket.user)
|
||||
|
||||
return AdminTicketDetailResponse(
|
||||
id=ticket.id,
|
||||
title=ticket.title or f'Ticket #{ticket.id}',
|
||||
status=ticket.status,
|
||||
priority=ticket.priority or 'normal',
|
||||
created_at=ticket.created_at,
|
||||
updated_at=ticket.updated_at or ticket.created_at,
|
||||
closed_at=ticket.closed_at,
|
||||
is_reply_blocked=ticket.is_reply_blocked if hasattr(ticket, 'is_reply_blocked') else False,
|
||||
user=user_info,
|
||||
messages=messages_response,
|
||||
)
|
||||
|
||||
|
||||
@router.post('/{ticket_id}/reply', response_model=TicketMessageResponse)
|
||||
async def reply_to_ticket(
|
||||
ticket_id: int,
|
||||
request: AdminReplyRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Reply to a ticket as admin."""
|
||||
# Get ticket
|
||||
ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=False, load_user=True)
|
||||
|
||||
if not ticket:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Ticket not found',
|
||||
)
|
||||
|
||||
# Create admin message
|
||||
message = TicketMessage(
|
||||
ticket_id=ticket.id,
|
||||
user_id=ticket.user_id,
|
||||
message_text=request.message,
|
||||
is_from_admin=True,
|
||||
created_at=datetime.utcnow(),
|
||||
)
|
||||
db.add(message)
|
||||
|
||||
# Update ticket status to answered
|
||||
ticket.status = 'answered'
|
||||
ticket.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(message)
|
||||
|
||||
# Try to notify user via Telegram
|
||||
try:
|
||||
from aiogram import Bot
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
|
||||
bot = Bot(
|
||||
token=settings.BOT_TOKEN,
|
||||
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
|
||||
)
|
||||
try:
|
||||
from app.handlers.admin.tickets import notify_user_about_ticket_reply
|
||||
|
||||
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}')
|
||||
finally:
|
||||
await bot.session.close()
|
||||
except Exception as e:
|
||||
logger.warning(f'Failed to send Telegram notification: {e}')
|
||||
|
||||
# Уведомить пользователя в кабинете
|
||||
try:
|
||||
notification = await TicketNotificationCRUD.create_user_notification_for_admin_reply(
|
||||
db, ticket, request.message
|
||||
)
|
||||
if notification:
|
||||
# Отправить 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}')
|
||||
|
||||
return _message_to_response(message)
|
||||
|
||||
|
||||
@router.post('/{ticket_id}/status', response_model=AdminTicketDetailResponse)
|
||||
async def update_ticket_status(
|
||||
ticket_id: int,
|
||||
request: AdminStatusUpdateRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update ticket status."""
|
||||
allowed_statuses = {'open', 'pending', 'answered', 'closed'}
|
||||
if request.status not in allowed_statuses:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Invalid status. Allowed: {", ".join(allowed_statuses)}',
|
||||
)
|
||||
|
||||
query = (
|
||||
select(Ticket).where(Ticket.id == ticket_id).options(selectinload(Ticket.messages), selectinload(Ticket.user))
|
||||
)
|
||||
|
||||
result = await db.execute(query)
|
||||
ticket = result.scalar_one_or_none()
|
||||
|
||||
if not ticket:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Ticket not found',
|
||||
)
|
||||
|
||||
ticket.status = request.status
|
||||
ticket.updated_at = datetime.utcnow()
|
||||
if request.status == 'closed':
|
||||
ticket.closed_at = datetime.utcnow()
|
||||
else:
|
||||
ticket.closed_at = None
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(ticket)
|
||||
|
||||
messages = sorted(ticket.messages or [], key=lambda m: m.created_at)
|
||||
messages_response = [_message_to_response(m) for m in messages]
|
||||
|
||||
user_info = None
|
||||
if ticket.user:
|
||||
user_info = _user_to_info(ticket.user)
|
||||
|
||||
return AdminTicketDetailResponse(
|
||||
id=ticket.id,
|
||||
title=ticket.title or f'Ticket #{ticket.id}',
|
||||
status=ticket.status,
|
||||
priority=ticket.priority or 'normal',
|
||||
created_at=ticket.created_at,
|
||||
updated_at=ticket.updated_at or ticket.created_at,
|
||||
closed_at=ticket.closed_at,
|
||||
is_reply_blocked=ticket.is_reply_blocked if hasattr(ticket, 'is_reply_blocked') else False,
|
||||
user=user_info,
|
||||
messages=messages_response,
|
||||
)
|
||||
|
||||
|
||||
@router.post('/{ticket_id}/priority', response_model=AdminTicketDetailResponse)
|
||||
async def update_ticket_priority(
|
||||
ticket_id: int,
|
||||
request: AdminPriorityUpdateRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update ticket priority."""
|
||||
allowed_priorities = {'low', 'normal', 'high', 'urgent'}
|
||||
if request.priority not in allowed_priorities:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Invalid priority. Allowed: {", ".join(allowed_priorities)}',
|
||||
)
|
||||
|
||||
query = (
|
||||
select(Ticket).where(Ticket.id == ticket_id).options(selectinload(Ticket.messages), selectinload(Ticket.user))
|
||||
)
|
||||
|
||||
result = await db.execute(query)
|
||||
ticket = result.scalar_one_or_none()
|
||||
|
||||
if not ticket:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Ticket not found',
|
||||
)
|
||||
|
||||
ticket.priority = request.priority
|
||||
ticket.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(ticket)
|
||||
|
||||
messages = sorted(ticket.messages or [], key=lambda m: m.created_at)
|
||||
messages_response = [_message_to_response(m) for m in messages]
|
||||
|
||||
user_info = None
|
||||
if ticket.user:
|
||||
user_info = _user_to_info(ticket.user)
|
||||
|
||||
return AdminTicketDetailResponse(
|
||||
id=ticket.id,
|
||||
title=ticket.title or f'Ticket #{ticket.id}',
|
||||
status=ticket.status,
|
||||
priority=ticket.priority or 'normal',
|
||||
created_at=ticket.created_at,
|
||||
updated_at=ticket.updated_at or ticket.created_at,
|
||||
closed_at=ticket.closed_at,
|
||||
is_reply_blocked=ticket.is_reply_blocked if hasattr(ticket, 'is_reply_blocked') else False,
|
||||
user=user_info,
|
||||
messages=messages_response,
|
||||
)
|
||||
@@ -0,0 +1,694 @@
|
||||
"""Admin routes for traffic usage statistics."""
|
||||
|
||||
import asyncio
|
||||
import csv
|
||||
import io
|
||||
import logging
|
||||
import time
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
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 = logging.getLogger(__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 %s', 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(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 %s', 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."""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import aiohttp
|
||||
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 = logging.getLogger(__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() - _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()
|
||||
logger.info('Fetched %d cabinet releases from GitHub', len(releases))
|
||||
return releases
|
||||
logger.warning('GitHub API returned status %d for cabinet releases', 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: %s', 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)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,387 @@
|
||||
"""
|
||||
API роуты колеса удачи для администраторов.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import math
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.cabinet.dependencies import get_cabinet_db, get_current_admin_user
|
||||
from app.cabinet.schemas.wheel import (
|
||||
AdminSpinItem,
|
||||
AdminSpinsResponse,
|
||||
AdminWheelConfigResponse,
|
||||
CreatePrizeRequest,
|
||||
ReorderPrizesRequest,
|
||||
UpdatePrizeRequest,
|
||||
UpdateWheelConfigRequest,
|
||||
WheelPrizeAdminResponse,
|
||||
WheelStatisticsResponse,
|
||||
)
|
||||
from app.database.crud.wheel import (
|
||||
create_wheel_prize,
|
||||
delete_wheel_prize,
|
||||
get_all_spins,
|
||||
get_or_create_wheel_config,
|
||||
get_wheel_prizes,
|
||||
reorder_wheel_prizes,
|
||||
update_wheel_config,
|
||||
update_wheel_prize,
|
||||
)
|
||||
from app.database.models import User
|
||||
from app.services.wheel_service import wheel_service
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/wheel', tags=['Admin Fortune Wheel'])
|
||||
|
||||
|
||||
@router.get('/config', response_model=AdminWheelConfigResponse)
|
||||
async def get_admin_wheel_config(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Получить полную конфигурацию колеса."""
|
||||
config = await get_or_create_wheel_config(db)
|
||||
prizes = await get_wheel_prizes(db, config.id, active_only=False)
|
||||
|
||||
prizes_response = [
|
||||
WheelPrizeAdminResponse(
|
||||
id=p.id,
|
||||
config_id=p.config_id,
|
||||
prize_type=p.prize_type,
|
||||
prize_value=p.prize_value,
|
||||
display_name=p.display_name,
|
||||
emoji=p.emoji,
|
||||
color=p.color,
|
||||
prize_value_kopeks=p.prize_value_kopeks,
|
||||
sort_order=p.sort_order,
|
||||
manual_probability=p.manual_probability,
|
||||
is_active=p.is_active,
|
||||
promo_balance_bonus_kopeks=p.promo_balance_bonus_kopeks or 0,
|
||||
promo_subscription_days=p.promo_subscription_days or 0,
|
||||
promo_traffic_gb=p.promo_traffic_gb or 0,
|
||||
created_at=p.created_at,
|
||||
updated_at=p.updated_at,
|
||||
)
|
||||
for p in prizes
|
||||
]
|
||||
|
||||
return AdminWheelConfigResponse(
|
||||
id=config.id,
|
||||
is_enabled=config.is_enabled,
|
||||
name=config.name,
|
||||
spin_cost_stars=config.spin_cost_stars,
|
||||
spin_cost_days=config.spin_cost_days,
|
||||
spin_cost_stars_enabled=config.spin_cost_stars_enabled,
|
||||
spin_cost_days_enabled=config.spin_cost_days_enabled,
|
||||
rtp_percent=config.rtp_percent,
|
||||
daily_spin_limit=config.daily_spin_limit,
|
||||
min_subscription_days_for_day_payment=config.min_subscription_days_for_day_payment,
|
||||
promo_prefix=config.promo_prefix,
|
||||
promo_validity_days=config.promo_validity_days,
|
||||
prizes=prizes_response,
|
||||
created_at=config.created_at,
|
||||
updated_at=config.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.put('/config', response_model=AdminWheelConfigResponse)
|
||||
async def update_admin_wheel_config(
|
||||
request: UpdateWheelConfigRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Обновить конфигурацию колеса."""
|
||||
update_data = request.model_dump(exclude_unset=True)
|
||||
|
||||
if not update_data:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='No fields to update',
|
||||
)
|
||||
|
||||
config = await update_wheel_config(db, **update_data)
|
||||
|
||||
logger.info(f'🎡 Admin {admin.telegram_id} updated wheel config: {update_data}')
|
||||
|
||||
# Возвращаем полную конфигурацию
|
||||
prizes = await get_wheel_prizes(db, config.id, active_only=False)
|
||||
|
||||
prizes_response = [
|
||||
WheelPrizeAdminResponse(
|
||||
id=p.id,
|
||||
config_id=p.config_id,
|
||||
prize_type=p.prize_type,
|
||||
prize_value=p.prize_value,
|
||||
display_name=p.display_name,
|
||||
emoji=p.emoji,
|
||||
color=p.color,
|
||||
prize_value_kopeks=p.prize_value_kopeks,
|
||||
sort_order=p.sort_order,
|
||||
manual_probability=p.manual_probability,
|
||||
is_active=p.is_active,
|
||||
promo_balance_bonus_kopeks=p.promo_balance_bonus_kopeks or 0,
|
||||
promo_subscription_days=p.promo_subscription_days or 0,
|
||||
promo_traffic_gb=p.promo_traffic_gb or 0,
|
||||
created_at=p.created_at,
|
||||
updated_at=p.updated_at,
|
||||
)
|
||||
for p in prizes
|
||||
]
|
||||
|
||||
return AdminWheelConfigResponse(
|
||||
id=config.id,
|
||||
is_enabled=config.is_enabled,
|
||||
name=config.name,
|
||||
spin_cost_stars=config.spin_cost_stars,
|
||||
spin_cost_days=config.spin_cost_days,
|
||||
spin_cost_stars_enabled=config.spin_cost_stars_enabled,
|
||||
spin_cost_days_enabled=config.spin_cost_days_enabled,
|
||||
rtp_percent=config.rtp_percent,
|
||||
daily_spin_limit=config.daily_spin_limit,
|
||||
min_subscription_days_for_day_payment=config.min_subscription_days_for_day_payment,
|
||||
promo_prefix=config.promo_prefix,
|
||||
promo_validity_days=config.promo_validity_days,
|
||||
prizes=prizes_response,
|
||||
created_at=config.created_at,
|
||||
updated_at=config.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get('/prizes', response_model=list[WheelPrizeAdminResponse])
|
||||
async def get_prizes(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Получить список призов."""
|
||||
config = await get_or_create_wheel_config(db)
|
||||
prizes = await get_wheel_prizes(db, config.id, active_only=False)
|
||||
|
||||
return [
|
||||
WheelPrizeAdminResponse(
|
||||
id=p.id,
|
||||
config_id=p.config_id,
|
||||
prize_type=p.prize_type,
|
||||
prize_value=p.prize_value,
|
||||
display_name=p.display_name,
|
||||
emoji=p.emoji,
|
||||
color=p.color,
|
||||
prize_value_kopeks=p.prize_value_kopeks,
|
||||
sort_order=p.sort_order,
|
||||
manual_probability=p.manual_probability,
|
||||
is_active=p.is_active,
|
||||
promo_balance_bonus_kopeks=p.promo_balance_bonus_kopeks or 0,
|
||||
promo_subscription_days=p.promo_subscription_days or 0,
|
||||
promo_traffic_gb=p.promo_traffic_gb or 0,
|
||||
created_at=p.created_at,
|
||||
updated_at=p.updated_at,
|
||||
)
|
||||
for p in prizes
|
||||
]
|
||||
|
||||
|
||||
@router.post('/prizes', response_model=WheelPrizeAdminResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_prize(
|
||||
request: CreatePrizeRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Создать новый приз."""
|
||||
config = await get_or_create_wheel_config(db)
|
||||
|
||||
prize = await create_wheel_prize(
|
||||
db=db,
|
||||
config_id=config.id,
|
||||
prize_type=request.prize_type.value,
|
||||
prize_value=request.prize_value,
|
||||
display_name=request.display_name,
|
||||
prize_value_kopeks=request.prize_value_kopeks,
|
||||
emoji=request.emoji,
|
||||
color=request.color,
|
||||
sort_order=request.sort_order,
|
||||
manual_probability=request.manual_probability,
|
||||
is_active=request.is_active,
|
||||
promo_balance_bonus_kopeks=request.promo_balance_bonus_kopeks,
|
||||
promo_subscription_days=request.promo_subscription_days,
|
||||
promo_traffic_gb=request.promo_traffic_gb,
|
||||
)
|
||||
|
||||
logger.info(f'🎁 Admin {admin.telegram_id} created prize: {prize.display_name}')
|
||||
|
||||
return WheelPrizeAdminResponse(
|
||||
id=prize.id,
|
||||
config_id=prize.config_id,
|
||||
prize_type=prize.prize_type,
|
||||
prize_value=prize.prize_value,
|
||||
display_name=prize.display_name,
|
||||
emoji=prize.emoji,
|
||||
color=prize.color,
|
||||
prize_value_kopeks=prize.prize_value_kopeks,
|
||||
sort_order=prize.sort_order,
|
||||
manual_probability=prize.manual_probability,
|
||||
is_active=prize.is_active,
|
||||
promo_balance_bonus_kopeks=prize.promo_balance_bonus_kopeks or 0,
|
||||
promo_subscription_days=prize.promo_subscription_days or 0,
|
||||
promo_traffic_gb=prize.promo_traffic_gb or 0,
|
||||
created_at=prize.created_at,
|
||||
updated_at=prize.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.put('/prizes/{prize_id}', response_model=WheelPrizeAdminResponse)
|
||||
async def update_prize(
|
||||
prize_id: int,
|
||||
request: UpdatePrizeRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Обновить приз."""
|
||||
update_data = request.model_dump(exclude_unset=True)
|
||||
|
||||
# Конвертируем enum в строку если есть
|
||||
if update_data.get('prize_type'):
|
||||
update_data['prize_type'] = update_data['prize_type'].value
|
||||
|
||||
if not update_data:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='No fields to update',
|
||||
)
|
||||
|
||||
prize = await update_wheel_prize(db, prize_id, **update_data)
|
||||
|
||||
if not prize:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Prize not found',
|
||||
)
|
||||
|
||||
logger.info(f'🎁 Admin {admin.telegram_id} updated prize {prize_id}: {update_data}')
|
||||
|
||||
return WheelPrizeAdminResponse(
|
||||
id=prize.id,
|
||||
config_id=prize.config_id,
|
||||
prize_type=prize.prize_type,
|
||||
prize_value=prize.prize_value,
|
||||
display_name=prize.display_name,
|
||||
emoji=prize.emoji,
|
||||
color=prize.color,
|
||||
prize_value_kopeks=prize.prize_value_kopeks,
|
||||
sort_order=prize.sort_order,
|
||||
manual_probability=prize.manual_probability,
|
||||
is_active=prize.is_active,
|
||||
promo_balance_bonus_kopeks=prize.promo_balance_bonus_kopeks or 0,
|
||||
promo_subscription_days=prize.promo_subscription_days or 0,
|
||||
promo_traffic_gb=prize.promo_traffic_gb or 0,
|
||||
created_at=prize.created_at,
|
||||
updated_at=prize.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.delete('/prizes/{prize_id}', status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_prize_endpoint(
|
||||
prize_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Удалить приз."""
|
||||
success = await delete_wheel_prize(db, prize_id)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Prize not found',
|
||||
)
|
||||
|
||||
logger.info(f'🗑️ Admin {admin.telegram_id} deleted prize {prize_id}')
|
||||
|
||||
|
||||
@router.post('/prizes/reorder', status_code=status.HTTP_200_OK)
|
||||
async def reorder_prizes(
|
||||
request: ReorderPrizesRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Переупорядочить призы."""
|
||||
await reorder_wheel_prizes(db, request.prize_ids)
|
||||
logger.info(f'🔄 Admin {admin.telegram_id} reordered prizes: {request.prize_ids}')
|
||||
return {'success': True}
|
||||
|
||||
|
||||
@router.get('/statistics', response_model=WheelStatisticsResponse)
|
||||
async def get_statistics(
|
||||
date_from: datetime | None = Query(None),
|
||||
date_to: datetime | None = Query(None),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Получить статистику колеса."""
|
||||
stats = await wheel_service.get_statistics(db, date_from, date_to)
|
||||
|
||||
return WheelStatisticsResponse(
|
||||
total_spins=stats['total_spins'],
|
||||
total_revenue_kopeks=stats['total_revenue_kopeks'],
|
||||
total_payout_kopeks=stats['total_payout_kopeks'],
|
||||
actual_rtp_percent=stats['actual_rtp_percent'],
|
||||
configured_rtp_percent=stats['configured_rtp_percent'],
|
||||
spins_by_payment_type=stats['spins_by_payment_type'],
|
||||
prizes_distribution=stats['prizes_distribution'],
|
||||
top_wins=stats['top_wins'],
|
||||
period_from=stats['period_from'],
|
||||
period_to=stats['period_to'],
|
||||
)
|
||||
|
||||
|
||||
@router.get('/spins', response_model=AdminSpinsResponse)
|
||||
async def get_all_spins_endpoint(
|
||||
user_id: int | None = Query(None),
|
||||
date_from: datetime | None = Query(None),
|
||||
date_to: datetime | None = Query(None),
|
||||
page: int = Query(1, ge=1),
|
||||
per_page: int = Query(50, ge=1, le=100),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Получить все спины с фильтрами."""
|
||||
offset = (page - 1) * per_page
|
||||
spins, total = await get_all_spins(
|
||||
db,
|
||||
user_id=user_id,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
limit=per_page,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
items = [
|
||||
AdminSpinItem(
|
||||
id=spin.id,
|
||||
user_id=spin.user_id,
|
||||
username=spin.user.username if spin.user else None,
|
||||
payment_type=spin.payment_type,
|
||||
payment_amount=spin.payment_amount,
|
||||
payment_value_kopeks=spin.payment_value_kopeks,
|
||||
prize_type=spin.prize_type,
|
||||
prize_value=spin.prize_value,
|
||||
prize_display_name=spin.prize_display_name,
|
||||
prize_value_kopeks=spin.prize_value_kopeks,
|
||||
is_applied=spin.is_applied,
|
||||
created_at=spin.created_at,
|
||||
)
|
||||
for spin in spins
|
||||
]
|
||||
|
||||
pages = math.ceil(total / per_page) if total > 0 else 1
|
||||
|
||||
return AdminSpinsResponse(
|
||||
items=items,
|
||||
total=total,
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
pages=pages,
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,769 @@
|
||||
"""Branding routes for cabinet - logo, project name, and theme colors management."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import SystemSetting, User
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/branding', tags=['Branding'])
|
||||
|
||||
# Directory for storing branding assets
|
||||
BRANDING_DIR = Path('data/branding')
|
||||
LOGO_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.webp', '.svg']
|
||||
|
||||
# Settings keys
|
||||
BRANDING_NAME_KEY = 'CABINET_BRANDING_NAME'
|
||||
BRANDING_LOGO_KEY = 'CABINET_BRANDING_LOGO' # Stores "custom" or "default"
|
||||
THEME_COLORS_KEY = 'CABINET_THEME_COLORS' # Stores JSON with theme colors
|
||||
ENABLED_THEMES_KEY = 'CABINET_ENABLED_THEMES' # Stores JSON with enabled themes {"dark": true, "light": false}
|
||||
ANIMATION_ENABLED_KEY = 'CABINET_ANIMATION_ENABLED' # Stores "true" or "false"
|
||||
FULLSCREEN_ENABLED_KEY = 'CABINET_FULLSCREEN_ENABLED' # Stores "true" or "false"
|
||||
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'}
|
||||
MAX_FILE_SIZE = 5 * 1024 * 1024 # 5MB for larger logos
|
||||
|
||||
|
||||
# ============ Schemas ============
|
||||
|
||||
|
||||
class BrandingResponse(BaseModel):
|
||||
"""Current branding settings."""
|
||||
|
||||
name: str
|
||||
logo_url: str | None = None
|
||||
logo_letter: str
|
||||
has_custom_logo: bool
|
||||
|
||||
|
||||
class BrandingNameUpdate(BaseModel):
|
||||
"""Request to update branding name."""
|
||||
|
||||
name: str
|
||||
|
||||
|
||||
class ThemeColorsResponse(BaseModel):
|
||||
"""Theme colors settings."""
|
||||
|
||||
accent: str = '#3b82f6'
|
||||
darkBackground: str = '#0a0f1a'
|
||||
darkSurface: str = '#0f172a'
|
||||
darkText: str = '#f1f5f9'
|
||||
darkTextSecondary: str = '#94a3b8'
|
||||
lightBackground: str = '#F7E7CE'
|
||||
lightSurface: str = '#FEF9F0'
|
||||
lightText: str = '#1F1A12'
|
||||
lightTextSecondary: str = '#7D6B48'
|
||||
success: str = '#22c55e'
|
||||
warning: str = '#f59e0b'
|
||||
error: str = '#ef4444'
|
||||
|
||||
|
||||
class ThemeColorsUpdate(BaseModel):
|
||||
"""Request to update theme colors (partial update allowed)."""
|
||||
|
||||
accent: str | None = None
|
||||
darkBackground: str | None = None
|
||||
darkSurface: str | None = None
|
||||
darkText: str | None = None
|
||||
darkTextSecondary: str | None = None
|
||||
lightBackground: str | None = None
|
||||
lightSurface: str | None = None
|
||||
lightText: str | None = None
|
||||
lightTextSecondary: str | None = None
|
||||
success: str | None = None
|
||||
warning: str | None = None
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class EnabledThemesResponse(BaseModel):
|
||||
"""Enabled themes settings."""
|
||||
|
||||
dark: bool = True
|
||||
light: bool = True
|
||||
|
||||
|
||||
class EnabledThemesUpdate(BaseModel):
|
||||
"""Request to update enabled themes."""
|
||||
|
||||
dark: bool | None = None
|
||||
light: bool | None = None
|
||||
|
||||
|
||||
class AnimationEnabledResponse(BaseModel):
|
||||
"""Animation enabled setting."""
|
||||
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
class AnimationEnabledUpdate(BaseModel):
|
||||
"""Request to update animation setting."""
|
||||
|
||||
enabled: bool
|
||||
|
||||
|
||||
class FullscreenEnabledResponse(BaseModel):
|
||||
"""Fullscreen enabled setting."""
|
||||
|
||||
enabled: bool = False
|
||||
|
||||
|
||||
class FullscreenEnabledUpdate(BaseModel):
|
||||
"""Request to update fullscreen setting."""
|
||||
|
||||
enabled: bool
|
||||
|
||||
|
||||
class EmailAuthEnabledResponse(BaseModel):
|
||||
"""Email auth enabled setting."""
|
||||
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
class EmailAuthEnabledUpdate(BaseModel):
|
||||
"""Request to update email auth setting."""
|
||||
|
||||
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."""
|
||||
|
||||
yandex_metrika_id: str = ''
|
||||
google_ads_id: str = ''
|
||||
google_ads_label: str = ''
|
||||
|
||||
|
||||
class AnalyticsCountersUpdate(BaseModel):
|
||||
"""Request to update analytics counters (partial update allowed)."""
|
||||
|
||||
yandex_metrika_id: str | None = None
|
||||
google_ads_id: str | None = None
|
||||
google_ads_label: str | None = None
|
||||
|
||||
|
||||
# Default theme colors
|
||||
DEFAULT_THEME_COLORS = {
|
||||
'accent': '#3b82f6',
|
||||
'darkBackground': '#0a0f1a',
|
||||
'darkSurface': '#0f172a',
|
||||
'darkText': '#f1f5f9',
|
||||
'darkTextSecondary': '#94a3b8',
|
||||
'lightBackground': '#F7E7CE',
|
||||
'lightSurface': '#FEF9F0',
|
||||
'lightText': '#1F1A12',
|
||||
'lightTextSecondary': '#7D6B48',
|
||||
'success': '#22c55e',
|
||||
'warning': '#f59e0b',
|
||||
'error': '#ef4444',
|
||||
}
|
||||
|
||||
|
||||
# ============ Helper Functions ============
|
||||
|
||||
|
||||
def ensure_branding_dir():
|
||||
"""Ensure branding directory exists."""
|
||||
BRANDING_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
async def get_setting_value(db: AsyncSession, key: str) -> str | None:
|
||||
"""Get a setting value from database."""
|
||||
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):
|
||||
"""Set a setting value in database."""
|
||||
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 get_logo_path() -> Path | None:
|
||||
"""Get the path to the custom logo file (any supported format)."""
|
||||
if not BRANDING_DIR.exists():
|
||||
return None
|
||||
|
||||
# Search for logo file with any supported extension
|
||||
for ext in LOGO_EXTENSIONS:
|
||||
logo_path = BRANDING_DIR / f'logo{ext}'
|
||||
if logo_path.exists():
|
||||
return logo_path
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def has_custom_logo() -> bool:
|
||||
"""Check if a custom logo exists."""
|
||||
return get_logo_path() is not None
|
||||
|
||||
|
||||
# ============ Routes ============
|
||||
|
||||
|
||||
@router.get('', response_model=BrandingResponse)
|
||||
async def get_branding(
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""
|
||||
Get current branding settings.
|
||||
This is a public endpoint - no authentication required.
|
||||
"""
|
||||
# Get name from database or use default from env/settings
|
||||
name = await get_setting_value(db, BRANDING_NAME_KEY)
|
||||
if name is None: # Only use fallback if not set at all (empty string is valid)
|
||||
name = getattr(settings, 'CABINET_BRANDING_NAME', None) or os.getenv('VITE_APP_NAME', 'Cabinet')
|
||||
|
||||
# Check for custom logo
|
||||
custom_logo = has_custom_logo()
|
||||
|
||||
# Get first letter for logo fallback (use "V" if name is empty)
|
||||
logo_letter = name[0].upper() if name else 'V'
|
||||
|
||||
return BrandingResponse(
|
||||
name=name,
|
||||
logo_url='/cabinet/branding/logo' if custom_logo else None,
|
||||
logo_letter=logo_letter,
|
||||
has_custom_logo=custom_logo,
|
||||
)
|
||||
|
||||
|
||||
@router.get('/logo')
|
||||
async def get_logo():
|
||||
"""
|
||||
Get the custom logo image.
|
||||
Returns 404 if no custom logo is set.
|
||||
"""
|
||||
logo_path = get_logo_path()
|
||||
|
||||
if logo_path is None or not logo_path.exists():
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='No custom logo set')
|
||||
|
||||
# Determine media type from file extension
|
||||
suffix = logo_path.suffix.lower()
|
||||
media_types = {
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.webp': 'image/webp',
|
||||
'.svg': 'image/svg+xml',
|
||||
}
|
||||
media_type = media_types.get(suffix, 'image/png')
|
||||
|
||||
return FileResponse(logo_path, media_type=media_type, headers={'Cache-Control': 'public, max-age=3600'})
|
||||
|
||||
|
||||
@router.put('/name', response_model=BrandingResponse)
|
||||
async def update_branding_name(
|
||||
payload: BrandingNameUpdate,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update the project name. Admin only. Empty name allowed (logo only mode)."""
|
||||
name = payload.name.strip() if payload.name else ''
|
||||
|
||||
if len(name) > 50:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Name too long (max 50 characters)')
|
||||
|
||||
await set_setting_value(db, BRANDING_NAME_KEY, name)
|
||||
|
||||
logger.info(f'Admin {admin.telegram_id} updated branding name to: {name}')
|
||||
|
||||
# Return updated branding
|
||||
custom_logo = has_custom_logo()
|
||||
logo_letter = name[0].upper() if name else 'C'
|
||||
|
||||
return BrandingResponse(
|
||||
name=name,
|
||||
logo_url='/cabinet/branding/logo' if custom_logo else None,
|
||||
logo_letter=logo_letter,
|
||||
has_custom_logo=custom_logo,
|
||||
)
|
||||
|
||||
|
||||
@router.post('/logo', response_model=BrandingResponse)
|
||||
async def upload_logo(
|
||||
file: UploadFile = File(...),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Upload a custom logo. Admin only."""
|
||||
# Validate content type
|
||||
if file.content_type not in ALLOWED_CONTENT_TYPES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail='Invalid file type. Allowed: PNG, JPEG, WebP, SVG'
|
||||
)
|
||||
|
||||
# Read file content
|
||||
content = await file.read()
|
||||
|
||||
# Validate file size
|
||||
if len(content) > MAX_FILE_SIZE:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'File too large. Maximum size: {MAX_FILE_SIZE // 1024 // 1024}MB',
|
||||
)
|
||||
|
||||
# Ensure directory exists
|
||||
ensure_branding_dir()
|
||||
|
||||
# Determine file extension from content type
|
||||
ext_map = {
|
||||
'image/png': '.png',
|
||||
'image/jpeg': '.jpg',
|
||||
'image/jpg': '.jpg',
|
||||
'image/webp': '.webp',
|
||||
'image/svg+xml': '.svg',
|
||||
}
|
||||
extension = ext_map.get(file.content_type, '.png')
|
||||
|
||||
# Remove old logo files with any extension
|
||||
for old_file in BRANDING_DIR.glob('logo.*'):
|
||||
old_file.unlink()
|
||||
|
||||
# Save new logo
|
||||
logo_path = BRANDING_DIR / f'logo{extension}'
|
||||
logo_path.write_bytes(content)
|
||||
|
||||
# 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}')
|
||||
|
||||
# Get current name for response
|
||||
name = await get_setting_value(db, BRANDING_NAME_KEY)
|
||||
if name is None: # Only use fallback if not set at all (empty string is valid)
|
||||
name = getattr(settings, 'CABINET_BRANDING_NAME', None) or os.getenv('VITE_APP_NAME', 'Cabinet')
|
||||
|
||||
logo_letter = name[0].upper() if name else 'C'
|
||||
|
||||
return BrandingResponse(
|
||||
name=name,
|
||||
logo_url='/cabinet/branding/logo',
|
||||
logo_letter=logo_letter,
|
||||
has_custom_logo=True,
|
||||
)
|
||||
|
||||
|
||||
@router.delete('/logo', response_model=BrandingResponse)
|
||||
async def delete_logo(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Delete custom logo and revert to letter. Admin only."""
|
||||
# Remove logo files
|
||||
for old_file in BRANDING_DIR.glob('logo.*'):
|
||||
old_file.unlink()
|
||||
|
||||
# Update setting
|
||||
await set_setting_value(db, BRANDING_LOGO_KEY, 'default')
|
||||
|
||||
logger.info(f'Admin {admin.telegram_id} deleted custom logo')
|
||||
|
||||
# Get current name for response
|
||||
name = await get_setting_value(db, BRANDING_NAME_KEY)
|
||||
if name is None: # Only use fallback if not set at all (empty string is valid)
|
||||
name = getattr(settings, 'CABINET_BRANDING_NAME', None) or os.getenv('VITE_APP_NAME', 'Cabinet')
|
||||
|
||||
logo_letter = name[0].upper() if name else 'C'
|
||||
|
||||
return BrandingResponse(
|
||||
name=name,
|
||||
logo_url=None,
|
||||
logo_letter=logo_letter,
|
||||
has_custom_logo=False,
|
||||
)
|
||||
|
||||
|
||||
# ============ Theme Colors Routes ============
|
||||
|
||||
|
||||
def validate_hex_color(color: str) -> bool:
|
||||
"""Validate hex color format."""
|
||||
if not color or not isinstance(color, str):
|
||||
return False
|
||||
if not color.startswith('#'):
|
||||
return False
|
||||
hex_part = color[1:]
|
||||
if len(hex_part) not in (3, 6):
|
||||
return False
|
||||
try:
|
||||
int(hex_part, 16)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
@router.get('/colors', response_model=ThemeColorsResponse)
|
||||
async def get_theme_colors(
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""
|
||||
Get current theme colors.
|
||||
This is a public endpoint - no authentication required.
|
||||
"""
|
||||
colors_json = await get_setting_value(db, THEME_COLORS_KEY)
|
||||
|
||||
if colors_json:
|
||||
try:
|
||||
colors = json.loads(colors_json)
|
||||
# Merge with defaults to ensure all fields exist
|
||||
merged = {**DEFAULT_THEME_COLORS, **colors}
|
||||
return ThemeColorsResponse(**merged)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
return ThemeColorsResponse(**DEFAULT_THEME_COLORS)
|
||||
|
||||
|
||||
@router.patch('/colors', response_model=ThemeColorsResponse)
|
||||
async def update_theme_colors(
|
||||
payload: ThemeColorsUpdate,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update theme colors. Admin only. Partial update supported."""
|
||||
# Get current colors
|
||||
colors_json = await get_setting_value(db, THEME_COLORS_KEY)
|
||||
current_colors = DEFAULT_THEME_COLORS.copy()
|
||||
|
||||
if colors_json:
|
||||
try:
|
||||
current_colors.update(json.loads(colors_json))
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
# Update with new values (only non-None fields)
|
||||
update_data = payload.model_dump(exclude_none=True)
|
||||
|
||||
# Validate hex colors
|
||||
for key, value in update_data.items():
|
||||
if not validate_hex_color(value):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f'Invalid hex color for {key}: {value}')
|
||||
|
||||
current_colors.update(update_data)
|
||||
|
||||
# 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())}')
|
||||
|
||||
return ThemeColorsResponse(**current_colors)
|
||||
|
||||
|
||||
@router.post('/colors/reset', response_model=ThemeColorsResponse)
|
||||
async def reset_theme_colors(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Reset theme colors to defaults. Admin only."""
|
||||
# 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')
|
||||
|
||||
return ThemeColorsResponse(**DEFAULT_THEME_COLORS)
|
||||
|
||||
|
||||
# ============ Enabled Themes Routes ============
|
||||
|
||||
DEFAULT_ENABLED_THEMES = {'dark': True, 'light': True}
|
||||
|
||||
|
||||
@router.get('/themes', response_model=EnabledThemesResponse)
|
||||
async def get_enabled_themes(
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""
|
||||
Get which themes are enabled.
|
||||
This is a public endpoint - no authentication required.
|
||||
"""
|
||||
themes_json = await get_setting_value(db, ENABLED_THEMES_KEY)
|
||||
|
||||
if themes_json:
|
||||
try:
|
||||
themes = json.loads(themes_json)
|
||||
return EnabledThemesResponse(**themes)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
return EnabledThemesResponse(**DEFAULT_ENABLED_THEMES)
|
||||
|
||||
|
||||
@router.patch('/themes', response_model=EnabledThemesResponse)
|
||||
async def update_enabled_themes(
|
||||
payload: EnabledThemesUpdate,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update which themes are enabled. Admin only. At least one theme must be enabled."""
|
||||
# Get current settings
|
||||
themes_json = await get_setting_value(db, ENABLED_THEMES_KEY)
|
||||
current_themes = DEFAULT_ENABLED_THEMES.copy()
|
||||
|
||||
if themes_json:
|
||||
try:
|
||||
current_themes.update(json.loads(themes_json))
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
# Update with new values
|
||||
update_data = payload.model_dump(exclude_none=True)
|
||||
current_themes.update(update_data)
|
||||
|
||||
# Ensure at least one theme is enabled
|
||||
if not current_themes.get('dark') and not current_themes.get('light'):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='At least one theme must be enabled')
|
||||
|
||||
# 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}')
|
||||
|
||||
return EnabledThemesResponse(**current_themes)
|
||||
|
||||
|
||||
# ============ Animation Routes ============
|
||||
|
||||
|
||||
@router.get('/animation', response_model=AnimationEnabledResponse)
|
||||
async def get_animation_enabled(
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""
|
||||
Get animation enabled setting.
|
||||
This is a public endpoint - no authentication required.
|
||||
"""
|
||||
animation_value = await get_setting_value(db, ANIMATION_ENABLED_KEY)
|
||||
|
||||
if animation_value is not None:
|
||||
enabled = animation_value.lower() == 'true'
|
||||
return AnimationEnabledResponse(enabled=enabled)
|
||||
|
||||
# Default: enabled
|
||||
return AnimationEnabledResponse(enabled=True)
|
||||
|
||||
|
||||
@router.patch('/animation', response_model=AnimationEnabledResponse)
|
||||
async def update_animation_enabled(
|
||||
payload: AnimationEnabledUpdate,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""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}')
|
||||
|
||||
return AnimationEnabledResponse(enabled=payload.enabled)
|
||||
|
||||
|
||||
# ============ Fullscreen Routes ============
|
||||
|
||||
|
||||
@router.get('/fullscreen', response_model=FullscreenEnabledResponse)
|
||||
async def get_fullscreen_enabled(
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""
|
||||
Get fullscreen enabled setting.
|
||||
This is a public endpoint - no authentication required.
|
||||
"""
|
||||
fullscreen_value = await get_setting_value(db, FULLSCREEN_ENABLED_KEY)
|
||||
|
||||
if fullscreen_value is not None:
|
||||
enabled = fullscreen_value.lower() == 'true'
|
||||
return FullscreenEnabledResponse(enabled=enabled)
|
||||
|
||||
# Default: disabled
|
||||
return FullscreenEnabledResponse(enabled=False)
|
||||
|
||||
|
||||
@router.patch('/fullscreen', response_model=FullscreenEnabledResponse)
|
||||
async def update_fullscreen_enabled(
|
||||
payload: FullscreenEnabledUpdate,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""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}')
|
||||
|
||||
return FullscreenEnabledResponse(enabled=payload.enabled)
|
||||
|
||||
|
||||
# ============ Email Auth Routes ============
|
||||
|
||||
|
||||
@router.get('/email-auth', response_model=EmailAuthEnabledResponse)
|
||||
async def get_email_auth_enabled(
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""
|
||||
Get email auth enabled setting.
|
||||
This is a public endpoint - no authentication required.
|
||||
Controls whether email registration/login is available.
|
||||
"""
|
||||
email_auth_value = await get_setting_value(db, EMAIL_AUTH_ENABLED_KEY)
|
||||
|
||||
if email_auth_value is not None:
|
||||
enabled = email_auth_value.lower() == 'true'
|
||||
return EmailAuthEnabledResponse(enabled=enabled)
|
||||
|
||||
# Default: check config setting
|
||||
return EmailAuthEnabledResponse(enabled=settings.is_cabinet_email_auth_enabled())
|
||||
|
||||
|
||||
@router.patch('/email-auth', response_model=EmailAuthEnabledResponse)
|
||||
async def update_email_auth_enabled(
|
||||
payload: EmailAuthEnabledUpdate,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""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}')
|
||||
|
||||
return EmailAuthEnabledResponse(enabled=payload.enabled)
|
||||
|
||||
|
||||
# ============ Analytics Counters Routes ============
|
||||
|
||||
|
||||
@router.get('/analytics', response_model=AnalyticsCountersResponse)
|
||||
async def get_analytics_counters(
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""
|
||||
Get analytics counter settings.
|
||||
This is a public endpoint - no authentication required.
|
||||
"""
|
||||
yandex_id = await get_setting_value(db, YANDEX_METRIKA_ID_KEY) or ''
|
||||
google_id = await get_setting_value(db, GOOGLE_ADS_ID_KEY) or ''
|
||||
google_label = await get_setting_value(db, GOOGLE_ADS_LABEL_KEY) or ''
|
||||
|
||||
return AnalyticsCountersResponse(
|
||||
yandex_metrika_id=yandex_id,
|
||||
google_ads_id=google_id,
|
||||
google_ads_label=google_label,
|
||||
)
|
||||
|
||||
|
||||
@router.patch('/analytics', response_model=AnalyticsCountersResponse)
|
||||
async def update_analytics_counters(
|
||||
payload: AnalyticsCountersUpdate,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update analytics counter settings. Admin only. Partial update supported."""
|
||||
if payload.yandex_metrika_id is not None:
|
||||
value = payload.yandex_metrika_id.strip()
|
||||
if value and not value.isdigit():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Yandex Metrika counter ID must be numeric',
|
||||
)
|
||||
await set_setting_value(db, YANDEX_METRIKA_ID_KEY, value)
|
||||
|
||||
if payload.google_ads_id is not None:
|
||||
value = payload.google_ads_id.strip()
|
||||
if value and not value.startswith('AW-'):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Google Ads conversion ID must start with AW-',
|
||||
)
|
||||
await set_setting_value(db, GOOGLE_ADS_ID_KEY, value)
|
||||
|
||||
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')
|
||||
|
||||
# Return current state
|
||||
yandex_id = await get_setting_value(db, YANDEX_METRIKA_ID_KEY) or ''
|
||||
google_id = await get_setting_value(db, GOOGLE_ADS_ID_KEY) or ''
|
||||
google_label = await get_setting_value(db, GOOGLE_ADS_LABEL_KEY) or ''
|
||||
|
||||
return AnalyticsCountersResponse(
|
||||
yandex_metrika_id=yandex_id,
|
||||
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(f'Admin {admin.telegram_id} set lite mode enabled: {payload.enabled}')
|
||||
|
||||
return LiteModeEnabledResponse(enabled=payload.enabled)
|
||||
@@ -0,0 +1,423 @@
|
||||
"""Contests routes for cabinet - user participation in games/contests."""
|
||||
|
||||
import logging
|
||||
import random
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.crud.contest import (
|
||||
create_attempt,
|
||||
get_active_rounds,
|
||||
get_attempt,
|
||||
increment_winner_count,
|
||||
)
|
||||
from app.database.crud.subscription import get_subscription_by_user_id
|
||||
from app.database.models import SubscriptionStatus, User
|
||||
from app.services.contest_rotation_service import (
|
||||
GAME_ANAGRAM,
|
||||
GAME_BLITZ,
|
||||
GAME_CIPHER,
|
||||
GAME_EMOJI,
|
||||
GAME_LOCKS,
|
||||
GAME_QUEST,
|
||||
GAME_SERVER,
|
||||
)
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/contests', tags=['Cabinet Contests'])
|
||||
|
||||
|
||||
# ============ Schemas ============
|
||||
|
||||
|
||||
class ContestInfo(BaseModel):
|
||||
"""Contest/game info."""
|
||||
|
||||
id: int
|
||||
slug: str
|
||||
name: str
|
||||
description: str | None = None
|
||||
prize_type: str
|
||||
prize_value: str
|
||||
is_available: bool
|
||||
already_played: bool = False
|
||||
|
||||
|
||||
class ContestGameData(BaseModel):
|
||||
"""Data for playing a contest game."""
|
||||
|
||||
round_id: int
|
||||
game_type: str
|
||||
game_data: dict[str, Any]
|
||||
instructions: str
|
||||
|
||||
|
||||
class ContestAnswerRequest(BaseModel):
|
||||
"""Request to submit contest answer."""
|
||||
|
||||
round_id: int
|
||||
answer: str
|
||||
|
||||
|
||||
class ContestResult(BaseModel):
|
||||
"""Result of contest attempt."""
|
||||
|
||||
is_winner: bool
|
||||
message: str
|
||||
prize_type: str | None = None
|
||||
prize_value: str | None = None
|
||||
|
||||
|
||||
# ============ Helpers ============
|
||||
|
||||
|
||||
def _user_allowed(subscription) -> bool:
|
||||
"""Check if user is allowed to participate in contests."""
|
||||
if not subscription:
|
||||
return False
|
||||
return subscription.status in {
|
||||
SubscriptionStatus.ACTIVE.value,
|
||||
SubscriptionStatus.TRIAL.value,
|
||||
}
|
||||
|
||||
|
||||
async def _award_prize(db: AsyncSession, user_id: int, prize_type: str, prize_value: str) -> str:
|
||||
"""Award prize to winner."""
|
||||
if prize_type == 'days':
|
||||
try:
|
||||
days = int(prize_value)
|
||||
except ValueError:
|
||||
return 'Error: invalid prize value'
|
||||
|
||||
subscription = await get_subscription_by_user_id(db, user_id)
|
||||
if not subscription:
|
||||
return 'Error: subscription not found'
|
||||
|
||||
subscription.end_date = subscription.end_date + timedelta(days=days)
|
||||
subscription.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
logger.info(f'🎁 Extended subscription for user {user_id} by {days} days (contest prize)')
|
||||
return f'Subscription extended by {days} days'
|
||||
|
||||
if prize_type == 'balance':
|
||||
from app.database.crud.user import get_user_by_id
|
||||
|
||||
try:
|
||||
amount = float(prize_value)
|
||||
except ValueError:
|
||||
return 'Error: invalid prize value'
|
||||
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
return 'Error: user not found'
|
||||
|
||||
user.balance += amount
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
logger.info(f'🎁 Added {amount} to balance for user {user_id} (contest prize)')
|
||||
return f'Balance increased by {amount}'
|
||||
|
||||
logger.warning(f'Unknown prize type: {prize_type}')
|
||||
return f"Prize type '{prize_type}' not supported"
|
||||
|
||||
|
||||
# ============ Routes ============
|
||||
|
||||
|
||||
class ContestsCountResponse(BaseModel):
|
||||
"""Count of available contests."""
|
||||
|
||||
count: int
|
||||
|
||||
|
||||
@router.get('/count', response_model=ContestsCountResponse)
|
||||
async def get_contests_count(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get count of contests available for the user."""
|
||||
subscription = await get_subscription_by_user_id(db, user.id)
|
||||
|
||||
if not _user_allowed(subscription):
|
||||
return ContestsCountResponse(count=0)
|
||||
|
||||
active_rounds = await get_active_rounds(db)
|
||||
|
||||
# Count unique available contests (not yet played)
|
||||
count = 0
|
||||
seen_templates = set()
|
||||
for rnd in active_rounds:
|
||||
if not rnd.template or not rnd.template.is_enabled:
|
||||
continue
|
||||
tpl_slug = rnd.template.slug if rnd.template else ''
|
||||
if tpl_slug in seen_templates:
|
||||
continue
|
||||
seen_templates.add(tpl_slug)
|
||||
|
||||
# Check if user already played this round
|
||||
attempt = await get_attempt(db, rnd.id, user.id)
|
||||
if not attempt:
|
||||
count += 1
|
||||
|
||||
return ContestsCountResponse(count=count)
|
||||
|
||||
|
||||
@router.get('', response_model=list[ContestInfo])
|
||||
async def get_contests(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get list of available contests/games."""
|
||||
subscription = await get_subscription_by_user_id(db, user.id)
|
||||
|
||||
if not _user_allowed(subscription):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Contests are only available for users with active or trial subscriptions',
|
||||
)
|
||||
|
||||
active_rounds = await get_active_rounds(db)
|
||||
|
||||
# Group by template to avoid duplicates
|
||||
unique_templates = {}
|
||||
for rnd in active_rounds:
|
||||
if not rnd.template or not rnd.template.is_enabled:
|
||||
continue
|
||||
tpl_slug = rnd.template.slug if rnd.template else ''
|
||||
if tpl_slug not in unique_templates:
|
||||
unique_templates[tpl_slug] = rnd
|
||||
|
||||
contests = []
|
||||
for tpl_slug, rnd in unique_templates.items():
|
||||
# Check if user already played this round
|
||||
attempt = await get_attempt(db, rnd.id, user.id)
|
||||
|
||||
contests.append(
|
||||
ContestInfo(
|
||||
id=rnd.id,
|
||||
slug=tpl_slug,
|
||||
name=rnd.template.name if rnd.template else tpl_slug,
|
||||
description=rnd.template.description if rnd.template else None,
|
||||
prize_type=rnd.template.prize_type if rnd.template else 'days',
|
||||
prize_value=rnd.template.prize_value if rnd.template else '1',
|
||||
is_available=True,
|
||||
already_played=attempt is not None,
|
||||
)
|
||||
)
|
||||
|
||||
return contests
|
||||
|
||||
|
||||
@router.get('/{round_id}', response_model=ContestGameData)
|
||||
async def get_contest_game(
|
||||
round_id: int,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get game data for a specific contest round."""
|
||||
subscription = await get_subscription_by_user_id(db, user.id)
|
||||
|
||||
if not _user_allowed(subscription):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Contests are only available for users with active or trial subscriptions',
|
||||
)
|
||||
|
||||
active_rounds = await get_active_rounds(db)
|
||||
round_obj = next((r for r in active_rounds if r.id == round_id), None)
|
||||
|
||||
if not round_obj:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Contest round not found or already finished',
|
||||
)
|
||||
|
||||
if not round_obj.template or not round_obj.template.is_enabled:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='This contest is disabled',
|
||||
)
|
||||
|
||||
# Check if already played
|
||||
attempt = await get_attempt(db, round_id, user.id)
|
||||
if attempt:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='You have already played this round',
|
||||
)
|
||||
|
||||
tpl = round_obj.template
|
||||
game_type = tpl.slug
|
||||
game_data = {}
|
||||
instructions = ''
|
||||
|
||||
if game_type == GAME_QUEST:
|
||||
rows = round_obj.payload.get('rows', 3)
|
||||
cols = round_obj.payload.get('cols', 3)
|
||||
secret = random.randint(0, rows * cols - 1)
|
||||
game_data = {
|
||||
'rows': rows,
|
||||
'cols': cols,
|
||||
'secret': secret,
|
||||
'grid_size': rows * cols,
|
||||
}
|
||||
instructions = 'Select one of the nodes in the grid. Find the hidden server!'
|
||||
|
||||
elif game_type == GAME_LOCKS:
|
||||
total = round_obj.payload.get('total', 20)
|
||||
secret = random.randint(0, total - 1)
|
||||
game_data = {
|
||||
'total': total,
|
||||
'secret': secret,
|
||||
}
|
||||
instructions = 'Find the unlocked button among the locks!'
|
||||
|
||||
elif game_type == GAME_SERVER:
|
||||
flags = round_obj.payload.get('flags') or []
|
||||
shuffled_flags = flags.copy()
|
||||
random.shuffle(shuffled_flags)
|
||||
game_data = {
|
||||
'flags': shuffled_flags,
|
||||
}
|
||||
instructions = 'Choose a server by clicking on a flag!'
|
||||
|
||||
elif game_type == GAME_CIPHER:
|
||||
question = round_obj.payload.get('question', '')
|
||||
game_data = {
|
||||
'question': question,
|
||||
'input_type': 'text',
|
||||
}
|
||||
instructions = 'Decrypt the cipher and enter the answer!'
|
||||
|
||||
elif game_type == GAME_EMOJI:
|
||||
question = round_obj.payload.get('question', '🤔')
|
||||
emoji_list = question.split()
|
||||
random.shuffle(emoji_list)
|
||||
game_data = {
|
||||
'question': ' '.join(emoji_list),
|
||||
'input_type': 'text',
|
||||
}
|
||||
instructions = 'Guess the service by emojis!'
|
||||
|
||||
elif game_type == GAME_ANAGRAM:
|
||||
letters = round_obj.payload.get('letters', '')
|
||||
game_data = {
|
||||
'letters': letters,
|
||||
'input_type': 'text',
|
||||
}
|
||||
instructions = 'Make a word from the given letters!'
|
||||
|
||||
elif game_type == GAME_BLITZ:
|
||||
game_data = {
|
||||
'button_text': "I'm here!",
|
||||
}
|
||||
instructions = 'Click the button as fast as you can!'
|
||||
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Unknown contest type',
|
||||
)
|
||||
|
||||
return ContestGameData(
|
||||
round_id=round_id,
|
||||
game_type=game_type,
|
||||
game_data=game_data,
|
||||
instructions=instructions,
|
||||
)
|
||||
|
||||
|
||||
@router.post('/{round_id}/answer', response_model=ContestResult)
|
||||
async def submit_contest_answer(
|
||||
round_id: int,
|
||||
request: ContestAnswerRequest,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Submit answer for a contest round."""
|
||||
subscription = await get_subscription_by_user_id(db, user.id)
|
||||
|
||||
if not _user_allowed(subscription):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Contests are only available for users with active or trial subscriptions',
|
||||
)
|
||||
|
||||
active_rounds = await get_active_rounds(db)
|
||||
round_obj = next((r for r in active_rounds if r.id == round_id), None)
|
||||
|
||||
if not round_obj:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Contest round not found or already finished',
|
||||
)
|
||||
|
||||
# Check if already played
|
||||
attempt = await get_attempt(db, round_id, user.id)
|
||||
if attempt:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='You have already played this round',
|
||||
)
|
||||
|
||||
tpl = round_obj.template
|
||||
answer = request.answer
|
||||
is_winner = False
|
||||
|
||||
# Determine if winner based on game type
|
||||
if tpl.slug == GAME_SERVER:
|
||||
flags = round_obj.payload.get('flags') or []
|
||||
secret_idx = round_obj.payload.get('secret_idx')
|
||||
correct_flag = flags[secret_idx] if secret_idx is not None and secret_idx < len(flags) else ''
|
||||
is_winner = answer == correct_flag
|
||||
|
||||
elif tpl.slug in {GAME_QUEST, GAME_LOCKS}:
|
||||
try:
|
||||
parts = answer.split('_')
|
||||
if len(parts) >= 2:
|
||||
idx = int(parts[0])
|
||||
secret = int(parts[1])
|
||||
is_winner = idx == secret
|
||||
except (ValueError, IndexError):
|
||||
is_winner = False
|
||||
|
||||
elif tpl.slug == GAME_BLITZ:
|
||||
is_winner = answer.lower() == 'blitz'
|
||||
|
||||
elif tpl.slug in {GAME_CIPHER, GAME_EMOJI, GAME_ANAGRAM}:
|
||||
correct = (round_obj.payload.get('answer') or '').upper()
|
||||
is_winner = correct and answer.upper() == correct
|
||||
|
||||
# Record attempt
|
||||
await create_attempt(db, round_id=round_obj.id, user_id=user.id, answer=str(answer), is_winner=is_winner)
|
||||
|
||||
if is_winner:
|
||||
await increment_winner_count(db, round_obj)
|
||||
prize_text = await _award_prize(db, user.id, tpl.prize_type, tpl.prize_value)
|
||||
return ContestResult(
|
||||
is_winner=True,
|
||||
message=f'🎉 Congratulations! You won! {prize_text}',
|
||||
prize_type=tpl.prize_type,
|
||||
prize_value=tpl.prize_value,
|
||||
)
|
||||
lose_messages = {
|
||||
GAME_QUEST: ['Empty node', 'Wrong server', 'Try another'],
|
||||
GAME_LOCKS: ['Locked', 'No access', 'Try again'],
|
||||
GAME_SERVER: ['Server overloaded', 'No response', 'Try tomorrow'],
|
||||
}
|
||||
messages = lose_messages.get(tpl.slug, ['Incorrect', 'Try again next round'])
|
||||
return ContestResult(
|
||||
is_winner=False,
|
||||
message=random.choice(messages),
|
||||
)
|
||||
@@ -0,0 +1,310 @@
|
||||
"""Info pages routes for cabinet - FAQ, rules, privacy policy, etc."""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.rules import get_current_rules_content, get_rules_by_language
|
||||
from app.database.models import User
|
||||
from app.services.faq_service import FaqService
|
||||
from app.services.privacy_policy_service import PrivacyPolicyService
|
||||
from app.services.public_offer_service import PublicOfferService
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
|
||||
|
||||
logger = logging.getLogger(__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 ============
|
||||
|
||||
|
||||
class FaqPageResponse(BaseModel):
|
||||
"""FAQ page."""
|
||||
|
||||
id: int
|
||||
title: str
|
||||
content: str
|
||||
order: int
|
||||
|
||||
|
||||
class RulesResponse(BaseModel):
|
||||
"""Service rules."""
|
||||
|
||||
content: str
|
||||
updated_at: str | None = None
|
||||
|
||||
|
||||
class PrivacyPolicyResponse(BaseModel):
|
||||
"""Privacy policy."""
|
||||
|
||||
content: str
|
||||
updated_at: str | None = None
|
||||
|
||||
|
||||
class PublicOfferResponse(BaseModel):
|
||||
"""Public offer."""
|
||||
|
||||
content: str
|
||||
updated_at: str | None = None
|
||||
|
||||
|
||||
class ServiceInfoResponse(BaseModel):
|
||||
"""General service info."""
|
||||
|
||||
name: str
|
||||
description: str | None = None
|
||||
support_email: str | None = None
|
||||
support_telegram: str | None = None
|
||||
website: str | None = None
|
||||
|
||||
|
||||
class SupportConfigResponse(BaseModel):
|
||||
"""Support/tickets configuration for miniapp."""
|
||||
|
||||
tickets_enabled: bool
|
||||
support_type: str # "tickets", "profile", "url"
|
||||
support_url: str | None = None
|
||||
support_username: str | None = None
|
||||
|
||||
|
||||
# ============ Routes ============
|
||||
|
||||
|
||||
@router.get('/faq', response_model=list[FaqPageResponse])
|
||||
async def get_faq_pages(
|
||||
language: str = Query('ru', min_length=2, max_length=10),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get list of FAQ pages."""
|
||||
requested_lang = FaqService.normalize_language(language)
|
||||
pages = await FaqService.get_pages(
|
||||
db,
|
||||
requested_lang,
|
||||
include_inactive=False, # Only active pages for cabinet
|
||||
fallback=True,
|
||||
)
|
||||
|
||||
return [
|
||||
FaqPageResponse(
|
||||
id=page.id,
|
||||
title=page.title,
|
||||
content=page.content or '',
|
||||
order=page.display_order or 0,
|
||||
)
|
||||
for page in pages
|
||||
]
|
||||
|
||||
|
||||
@router.get('/faq/{page_id}', response_model=FaqPageResponse)
|
||||
async def get_faq_page(
|
||||
page_id: int,
|
||||
language: str = Query('ru', min_length=2, max_length=10),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get a specific FAQ page by ID."""
|
||||
requested_lang = FaqService.normalize_language(language)
|
||||
page = await FaqService.get_page(
|
||||
db,
|
||||
page_id,
|
||||
requested_lang,
|
||||
include_inactive=False,
|
||||
fallback=True,
|
||||
)
|
||||
|
||||
if not page:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='FAQ page not found',
|
||||
)
|
||||
|
||||
return FaqPageResponse(
|
||||
id=page.id,
|
||||
title=page.title,
|
||||
content=page.content or '',
|
||||
order=page.display_order or 0,
|
||||
)
|
||||
|
||||
|
||||
@router.get('/rules', response_model=RulesResponse)
|
||||
async def get_rules(
|
||||
language: str = Query('ru', min_length=2, max_length=10),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get service rules - uses same function as bot."""
|
||||
requested_lang = language.split('-')[0].lower()
|
||||
|
||||
# Use the same function as bot to ensure consistent content
|
||||
content = await get_current_rules_content(db, requested_lang)
|
||||
|
||||
# Try to get updated_at from DB record
|
||||
rules = await get_rules_by_language(db, requested_lang)
|
||||
updated_at = None
|
||||
if rules and rules.updated_at:
|
||||
updated_at = rules.updated_at.isoformat()
|
||||
|
||||
return RulesResponse(content=content, updated_at=updated_at)
|
||||
|
||||
|
||||
@router.get('/privacy-policy', response_model=PrivacyPolicyResponse)
|
||||
async def get_privacy_policy(
|
||||
language: str = Query('ru', min_length=2, max_length=10),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get privacy policy."""
|
||||
requested_lang = PrivacyPolicyService.normalize_language(language)
|
||||
policy = await PrivacyPolicyService.get_policy(db, requested_lang, fallback=True)
|
||||
|
||||
if policy and policy.content:
|
||||
updated_at = policy.updated_at.isoformat() if policy.updated_at else None
|
||||
return PrivacyPolicyResponse(content=policy.content, updated_at=updated_at)
|
||||
|
||||
# Return default policy if none found
|
||||
return PrivacyPolicyResponse(
|
||||
content="""# Политика конфиденциальности
|
||||
|
||||
Мы уважаем вашу конфиденциальность и защищаем ваши персональные данные.
|
||||
""",
|
||||
updated_at=None,
|
||||
)
|
||||
|
||||
|
||||
@router.get('/public-offer', response_model=PublicOfferResponse)
|
||||
async def get_public_offer(
|
||||
language: str = Query('ru', min_length=2, max_length=10),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get public offer."""
|
||||
requested_lang = PublicOfferService.normalize_language(language)
|
||||
offer = await PublicOfferService.get_offer(db, requested_lang, fallback=True)
|
||||
|
||||
if offer and offer.content:
|
||||
updated_at = offer.updated_at.isoformat() if offer.updated_at else None
|
||||
return PublicOfferResponse(content=offer.content, updated_at=updated_at)
|
||||
|
||||
# Return default offer if none found
|
||||
return PublicOfferResponse(
|
||||
content="""# Публичная оферта
|
||||
|
||||
Условия использования сервиса.
|
||||
""",
|
||||
updated_at=None,
|
||||
)
|
||||
|
||||
|
||||
@router.get('/service', response_model=ServiceInfoResponse)
|
||||
async def get_service_info():
|
||||
"""Get general service information."""
|
||||
return ServiceInfoResponse(
|
||||
name=getattr(settings, 'SERVICE_NAME', None) or getattr(settings, 'BOT_NAME', 'VPN Service'),
|
||||
description=getattr(settings, 'SERVICE_DESCRIPTION', None),
|
||||
support_email=getattr(settings, 'SUPPORT_EMAIL', None),
|
||||
support_telegram=getattr(settings, 'SUPPORT_USERNAME', None) or getattr(settings, 'SUPPORT_TELEGRAM', None),
|
||||
website=getattr(settings, 'WEBSITE_URL', None),
|
||||
)
|
||||
|
||||
|
||||
@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': code,
|
||||
'name': _LANGUAGE_META.get(code, (code.upper(), '🌐'))[0],
|
||||
'flag': _LANGUAGE_META.get(code, (code.upper(), '🌐'))[1],
|
||||
}
|
||||
for code in codes
|
||||
],
|
||||
'default': default_language,
|
||||
}
|
||||
|
||||
|
||||
@router.get('/user/language')
|
||||
async def get_user_language(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
):
|
||||
"""Get current user's language."""
|
||||
return {'language': user.language or 'ru'}
|
||||
|
||||
|
||||
@router.patch('/user/language')
|
||||
async def update_user_language(
|
||||
request: dict[str, str],
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update user's language preference."""
|
||||
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(available_languages)}',
|
||||
)
|
||||
|
||||
user.language = requested_language
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
return {'language': user.language}
|
||||
|
||||
|
||||
@router.get('/support-config', response_model=SupportConfigResponse)
|
||||
async def get_support_config():
|
||||
"""Get support/tickets configuration for cabinet."""
|
||||
# Use SUPPORT_SYSTEM_MODE setting (configurable from admin panel)
|
||||
support_mode = settings.get_support_system_mode() # returns: tickets, contact, or both
|
||||
|
||||
# Map support mode to support type for frontend
|
||||
# - "tickets" mode -> tickets only, no contact
|
||||
# - "contact" mode -> contact only (profile), no tickets
|
||||
# - "both" mode -> tickets enabled, contact available as fallback
|
||||
if support_mode == 'tickets':
|
||||
tickets_enabled = True
|
||||
support_type = 'tickets'
|
||||
elif support_mode == 'contact':
|
||||
tickets_enabled = False
|
||||
support_type = 'profile'
|
||||
else: # both
|
||||
tickets_enabled = True
|
||||
support_type = 'tickets'
|
||||
|
||||
return SupportConfigResponse(
|
||||
tickets_enabled=tickets_enabled,
|
||||
support_type=support_type,
|
||||
support_url=None, # Cabinet doesn't use custom URLs
|
||||
support_username=settings.SUPPORT_USERNAME, # Always return for fallback
|
||||
)
|
||||
@@ -0,0 +1,196 @@
|
||||
"""Media upload/download routes for cabinet tickets."""
|
||||
|
||||
import logging
|
||||
import mimetypes
|
||||
|
||||
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, File, Form, HTTPException, Request, Response, UploadFile, status
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
|
||||
from ..dependencies import get_current_cabinet_user
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/media', tags=['Cabinet Media'])
|
||||
|
||||
ALLOWED_MEDIA_TYPES = {'photo', 'video', 'document'}
|
||||
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB
|
||||
|
||||
|
||||
class MediaUploadResponse(BaseModel):
|
||||
"""Response after successful media upload."""
|
||||
|
||||
media_type: str
|
||||
file_id: str
|
||||
file_unique_id: str | None = None
|
||||
media_url: str
|
||||
|
||||
|
||||
def _resolve_target_chat_id() -> int:
|
||||
"""Get chat ID for uploading files (notification channel or first admin)."""
|
||||
chat_id = settings.get_admin_notifications_chat_id()
|
||||
if chat_id is not None:
|
||||
return chat_id
|
||||
|
||||
admin_ids = settings.get_admin_ids()
|
||||
if admin_ids:
|
||||
return admin_ids[0]
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='No chat configured for file uploads',
|
||||
)
|
||||
|
||||
|
||||
def _build_media_url(request: Request, file_id: str) -> str:
|
||||
"""Build URL for downloading media."""
|
||||
return str(request.url_for('cabinet_download_media', file_id=file_id))
|
||||
|
||||
|
||||
@router.post('/upload', response_model=MediaUploadResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def upload_media(
|
||||
request: Request,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
file: UploadFile = File(...),
|
||||
media_type: str = Form('photo', description='File type: photo, video, or document'),
|
||||
):
|
||||
"""
|
||||
Upload media file for use in ticket messages.
|
||||
Returns file_id that can be used when creating ticket or adding message.
|
||||
"""
|
||||
media_type_normalized = (media_type or '').strip().lower()
|
||||
if media_type_normalized not in ALLOWED_MEDIA_TYPES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Unsupported media type. Allowed: {", ".join(ALLOWED_MEDIA_TYPES)}',
|
||||
)
|
||||
|
||||
# Read and validate file
|
||||
file_bytes = await file.read()
|
||||
if not file_bytes:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='File is empty',
|
||||
)
|
||||
|
||||
if len(file_bytes) > MAX_FILE_SIZE:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'File too large. Maximum size: {MAX_FILE_SIZE // 1024 // 1024}MB',
|
||||
)
|
||||
|
||||
# Validate content type for photos
|
||||
if media_type_normalized == 'photo':
|
||||
allowed_image_types = {'image/jpeg', 'image/png', 'image/gif', 'image/webp'}
|
||||
if file.content_type and file.content_type not in allowed_image_types:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Invalid image type. Allowed: JPEG, PNG, GIF, WebP',
|
||||
)
|
||||
|
||||
target_chat_id = _resolve_target_chat_id()
|
||||
upload = BufferedInputFile(file_bytes, filename=file.filename or 'upload')
|
||||
|
||||
bot = Bot(
|
||||
token=settings.BOT_TOKEN,
|
||||
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
|
||||
)
|
||||
|
||||
try:
|
||||
if media_type_normalized == 'photo':
|
||||
message = await bot.send_photo(
|
||||
chat_id=target_chat_id,
|
||||
photo=upload,
|
||||
)
|
||||
media = message.photo[-1]
|
||||
elif media_type_normalized == 'video':
|
||||
message = await bot.send_video(
|
||||
chat_id=target_chat_id,
|
||||
video=upload,
|
||||
)
|
||||
media = message.video
|
||||
else:
|
||||
message = await bot.send_document(
|
||||
chat_id=target_chat_id,
|
||||
document=upload,
|
||||
)
|
||||
media = message.document
|
||||
|
||||
media_url = _build_media_url(request, media.file_id)
|
||||
|
||||
logger.info(f'User {user.telegram_id} uploaded {media_type_normalized}: {media.file_id}')
|
||||
|
||||
return MediaUploadResponse(
|
||||
media_type=media_type_normalized,
|
||||
file_id=media.file_id,
|
||||
file_unique_id=getattr(media, 'file_unique_id', None),
|
||||
media_url=media_url,
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as error:
|
||||
logger.error(f'Failed to upload media for user {user.telegram_id}: {error}')
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to upload media',
|
||||
) from error
|
||||
finally:
|
||||
await bot.session.close()
|
||||
|
||||
|
||||
@router.get('/{file_id}', name='cabinet_download_media')
|
||||
async def download_media(
|
||||
file_id: str,
|
||||
) -> Response:
|
||||
"""
|
||||
Download media file by file_id.
|
||||
Used to display images/documents in ticket messages.
|
||||
"""
|
||||
bot = Bot(
|
||||
token=settings.BOT_TOKEN,
|
||||
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
|
||||
)
|
||||
|
||||
try:
|
||||
file = await bot.get_file(file_id)
|
||||
if not file.file_path:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Media file not found',
|
||||
)
|
||||
|
||||
buffer = await bot.download_file(file.file_path)
|
||||
|
||||
if hasattr(buffer, 'seek'):
|
||||
buffer.seek(0)
|
||||
|
||||
content = buffer.read() if hasattr(buffer, 'read') else bytes(buffer)
|
||||
filename = file.file_path.split('/')[-1]
|
||||
|
||||
media_type = mimetypes.guess_type(filename)[0] or 'application/octet-stream'
|
||||
|
||||
return Response(
|
||||
content=content,
|
||||
media_type=media_type,
|
||||
headers={
|
||||
'Content-Disposition': f'inline; filename={filename}',
|
||||
'Cache-Control': 'public, max-age=86400', # Cache for 24 hours
|
||||
},
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as error:
|
||||
logger.error(f'Failed to download media {file_id}: {error}')
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to download media',
|
||||
) from error
|
||||
finally:
|
||||
await bot.session.close()
|
||||
@@ -0,0 +1,151 @@
|
||||
"""Notification settings routes for cabinet."""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import User
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/notifications', tags=['Cabinet Notifications'])
|
||||
|
||||
|
||||
# ============ Schemas ============
|
||||
|
||||
|
||||
class NotificationSettingsResponse(BaseModel):
|
||||
"""User notification settings."""
|
||||
|
||||
subscription_expiry_enabled: bool = True
|
||||
subscription_expiry_days: int = 3
|
||||
traffic_warning_enabled: bool = True
|
||||
traffic_warning_percent: int = 80
|
||||
balance_low_enabled: bool = True
|
||||
balance_low_threshold: int = 100 # kopeks
|
||||
news_enabled: bool = True
|
||||
promo_offers_enabled: bool = True
|
||||
|
||||
|
||||
class NotificationSettingsUpdate(BaseModel):
|
||||
"""Update notification settings."""
|
||||
|
||||
subscription_expiry_enabled: bool | None = None
|
||||
subscription_expiry_days: int | None = Field(None, ge=1, le=30)
|
||||
traffic_warning_enabled: bool | None = None
|
||||
traffic_warning_percent: int | None = Field(None, ge=50, le=99)
|
||||
balance_low_enabled: bool | None = None
|
||||
balance_low_threshold: int | None = Field(None, ge=0)
|
||||
news_enabled: bool | None = None
|
||||
promo_offers_enabled: bool | None = None
|
||||
|
||||
|
||||
# ============ Helpers ============
|
||||
|
||||
|
||||
def _get_notification_settings(user: User) -> dict[str, Any]:
|
||||
"""Get notification settings from user object."""
|
||||
# Try to get from user's settings field or use defaults
|
||||
settings_data = getattr(user, 'notification_settings', None) or {}
|
||||
|
||||
return {
|
||||
'subscription_expiry_enabled': settings_data.get('subscription_expiry_enabled', True),
|
||||
'subscription_expiry_days': settings_data.get('subscription_expiry_days', 3),
|
||||
'traffic_warning_enabled': settings_data.get('traffic_warning_enabled', True),
|
||||
'traffic_warning_percent': settings_data.get('traffic_warning_percent', 80),
|
||||
'balance_low_enabled': settings_data.get('balance_low_enabled', True),
|
||||
'balance_low_threshold': settings_data.get('balance_low_threshold', 100),
|
||||
'news_enabled': settings_data.get('news_enabled', True),
|
||||
'promo_offers_enabled': settings_data.get('promo_offers_enabled', True),
|
||||
}
|
||||
|
||||
|
||||
def _update_notification_settings(user: User, updates: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Update notification settings on user object."""
|
||||
current_settings = _get_notification_settings(user)
|
||||
|
||||
for key, value in updates.items():
|
||||
if value is not None:
|
||||
current_settings[key] = value
|
||||
|
||||
return current_settings
|
||||
|
||||
|
||||
# ============ Routes ============
|
||||
|
||||
|
||||
@router.get('', response_model=NotificationSettingsResponse)
|
||||
async def get_notification_settings(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
):
|
||||
"""Get user's notification settings."""
|
||||
settings = _get_notification_settings(user)
|
||||
return NotificationSettingsResponse(**settings)
|
||||
|
||||
|
||||
@router.patch('', response_model=NotificationSettingsResponse)
|
||||
async def update_notification_settings(
|
||||
request: NotificationSettingsUpdate,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update user's notification settings."""
|
||||
updates = request.model_dump(exclude_unset=True)
|
||||
|
||||
if not updates:
|
||||
# No updates provided, return current settings
|
||||
settings = _get_notification_settings(user)
|
||||
return NotificationSettingsResponse(**settings)
|
||||
|
||||
# Update settings
|
||||
new_settings = _update_notification_settings(user, updates)
|
||||
|
||||
# Store in user object
|
||||
if not hasattr(user, 'notification_settings') or user.notification_settings is None:
|
||||
user.notification_settings = {}
|
||||
|
||||
user.notification_settings = new_settings
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
return NotificationSettingsResponse(**new_settings)
|
||||
|
||||
|
||||
@router.post('/test')
|
||||
async def send_test_notification(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
):
|
||||
"""Send a test notification to the user."""
|
||||
# This would typically trigger a notification via Telegram bot
|
||||
# For now, just return success
|
||||
return {
|
||||
'success': True,
|
||||
'message': 'Test notification request received. You will receive a test message shortly.',
|
||||
}
|
||||
|
||||
|
||||
@router.get('/history')
|
||||
async def get_notification_history(
|
||||
limit: int = 20,
|
||||
offset: int = 0,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get user's notification history."""
|
||||
# For now, return empty list - notification history can be implemented later
|
||||
# when there's a notification log table
|
||||
return {
|
||||
'notifications': [],
|
||||
'total': 0,
|
||||
'limit': limit,
|
||||
'offset': offset,
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
"""OAuth 2.0 authentication routes for cabinet."""
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
|
||||
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, _store_refresh_token
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/auth/oauth', tags=['Cabinet OAuth'])
|
||||
|
||||
|
||||
async def _finalize_oauth_login(db: AsyncSession, user: User, provider: str) -> AuthResponse:
|
||||
"""Update last login, create tokens, store refresh token."""
|
||||
user.cabinet_last_login = datetime.now(UTC).replace(tzinfo=None)
|
||||
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}')
|
||||
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')
|
||||
|
||||
|
||||
# --- 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 %s: %s', provider, 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 %s: %s', provider, 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 %s for existing user %s', provider, user.id)
|
||||
return await _finalize_oauth_login(db, user, provider)
|
||||
|
||||
# 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 %s linked to existing email user %s', provider, user.id)
|
||||
return await _finalize_oauth_login(db, user, provider)
|
||||
|
||||
# 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 %s with id=%s', provider, user.id)
|
||||
return await _finalize_oauth_login(db, user, provider)
|
||||
@@ -0,0 +1,365 @@
|
||||
"""Polls routes for cabinet - user participation in polls/surveys."""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.poll import (
|
||||
get_poll_response_by_id,
|
||||
record_poll_answer,
|
||||
)
|
||||
from app.database.models import Poll, PollQuestion, PollResponse, User
|
||||
from app.services.poll_service import get_next_question, get_question_option, reward_user_for_poll
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/polls', tags=['Cabinet Polls'])
|
||||
|
||||
|
||||
# ============ Schemas ============
|
||||
|
||||
|
||||
class PollOptionResponse(BaseModel):
|
||||
"""Poll option."""
|
||||
|
||||
id: int
|
||||
text: str
|
||||
order: int
|
||||
|
||||
|
||||
class PollQuestionResponse(BaseModel):
|
||||
"""Poll question with options."""
|
||||
|
||||
id: int
|
||||
text: str
|
||||
order: int
|
||||
options: list[PollOptionResponse]
|
||||
|
||||
|
||||
class PollInfo(BaseModel):
|
||||
"""Poll info for user."""
|
||||
|
||||
id: int
|
||||
response_id: int
|
||||
title: str
|
||||
description: str | None = None
|
||||
total_questions: int
|
||||
answered_questions: int
|
||||
is_completed: bool
|
||||
reward_amount: int | None = None
|
||||
|
||||
|
||||
class PollStartResponse(BaseModel):
|
||||
"""Response when starting a poll."""
|
||||
|
||||
response_id: int
|
||||
current_question_index: int
|
||||
total_questions: int
|
||||
question: PollQuestionResponse
|
||||
|
||||
|
||||
class AnswerRequest(BaseModel):
|
||||
"""Request to answer a poll question."""
|
||||
|
||||
option_id: int
|
||||
|
||||
|
||||
class AnswerResponse(BaseModel):
|
||||
"""Response after answering."""
|
||||
|
||||
success: bool
|
||||
is_completed: bool
|
||||
next_question: PollQuestionResponse | None = None
|
||||
current_question_index: int | None = None
|
||||
total_questions: int
|
||||
reward_granted: int | None = None
|
||||
message: str | None = None
|
||||
|
||||
|
||||
# ============ Helpers ============
|
||||
|
||||
|
||||
def _question_to_response(question: PollQuestion) -> PollQuestionResponse:
|
||||
"""Convert question model to response."""
|
||||
options = [
|
||||
PollOptionResponse(
|
||||
id=opt.id,
|
||||
text=opt.text,
|
||||
order=opt.order,
|
||||
)
|
||||
for opt in sorted(question.options, key=lambda o: o.order)
|
||||
]
|
||||
return PollQuestionResponse(
|
||||
id=question.id,
|
||||
text=question.text,
|
||||
order=question.order,
|
||||
options=options,
|
||||
)
|
||||
|
||||
|
||||
# ============ Routes ============
|
||||
|
||||
|
||||
class PollsCountResponse(BaseModel):
|
||||
"""Count of available polls."""
|
||||
|
||||
count: int
|
||||
|
||||
|
||||
@router.get('/count', response_model=PollsCountResponse)
|
||||
async def get_polls_count(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get count of polls available for the user."""
|
||||
result = await db.execute(
|
||||
select(PollResponse)
|
||||
.where(PollResponse.user_id == user.id)
|
||||
.where(PollResponse.completed_at.is_(None)) # Only incomplete polls
|
||||
)
|
||||
responses = result.scalars().all()
|
||||
return PollsCountResponse(count=len(responses))
|
||||
|
||||
|
||||
@router.get('', response_model=list[PollInfo])
|
||||
async def get_available_polls(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get list of polls available for the user."""
|
||||
# Get user's poll responses with eager loading of relationships
|
||||
result = await db.execute(
|
||||
select(PollResponse)
|
||||
.where(PollResponse.user_id == user.id)
|
||||
.options(
|
||||
selectinload(PollResponse.poll).selectinload(Poll.questions),
|
||||
selectinload(PollResponse.answers),
|
||||
)
|
||||
.order_by(PollResponse.created_at.desc())
|
||||
)
|
||||
responses = result.scalars().all()
|
||||
|
||||
polls = []
|
||||
for response in responses:
|
||||
if not response.poll:
|
||||
continue
|
||||
|
||||
answered_count = len(response.answers) if response.answers else 0
|
||||
total_questions = len(response.poll.questions) if response.poll.questions else 0
|
||||
|
||||
# Convert kopeks to rubles for display
|
||||
reward_amount = None
|
||||
if response.poll.reward_amount_kopeks:
|
||||
reward_amount = response.poll.reward_amount_kopeks // 100
|
||||
|
||||
polls.append(
|
||||
PollInfo(
|
||||
id=response.poll.id,
|
||||
response_id=response.id,
|
||||
title=response.poll.title,
|
||||
description=response.poll.description,
|
||||
total_questions=total_questions,
|
||||
answered_questions=answered_count,
|
||||
is_completed=response.completed_at is not None,
|
||||
reward_amount=reward_amount,
|
||||
)
|
||||
)
|
||||
|
||||
return polls
|
||||
|
||||
|
||||
@router.get('/{response_id}', response_model=PollInfo)
|
||||
async def get_poll_details(
|
||||
response_id: int,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get details of a specific poll response."""
|
||||
response = await get_poll_response_by_id(db, response_id)
|
||||
|
||||
if not response or response.user_id != user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Poll not found',
|
||||
)
|
||||
|
||||
if not response.poll:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Poll data not available',
|
||||
)
|
||||
|
||||
answered_count = len(response.answers) if response.answers else 0
|
||||
total_questions = len(response.poll.questions) if response.poll.questions else 0
|
||||
|
||||
# Convert kopeks to rubles for display
|
||||
reward_amount = None
|
||||
if response.poll.reward_amount_kopeks:
|
||||
reward_amount = response.poll.reward_amount_kopeks // 100
|
||||
|
||||
return PollInfo(
|
||||
id=response.poll.id,
|
||||
response_id=response.id,
|
||||
title=response.poll.title,
|
||||
description=response.poll.description,
|
||||
total_questions=total_questions,
|
||||
answered_questions=answered_count,
|
||||
is_completed=response.completed_at is not None,
|
||||
reward_amount=reward_amount,
|
||||
)
|
||||
|
||||
|
||||
@router.post('/{response_id}/start', response_model=PollStartResponse)
|
||||
async def start_poll(
|
||||
response_id: int,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Start or continue a poll."""
|
||||
response = await get_poll_response_by_id(db, response_id)
|
||||
|
||||
if not response or response.user_id != user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Poll not found',
|
||||
)
|
||||
|
||||
if response.completed_at:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='This poll has already been completed',
|
||||
)
|
||||
|
||||
if not response.poll or not response.poll.questions:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Poll is not available',
|
||||
)
|
||||
|
||||
# Mark as started if not already
|
||||
if not response.started_at:
|
||||
response.started_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
|
||||
# Get next unanswered question
|
||||
index, question = await get_next_question(response)
|
||||
|
||||
if not question:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='No questions available',
|
||||
)
|
||||
|
||||
return PollStartResponse(
|
||||
response_id=response.id,
|
||||
current_question_index=index,
|
||||
total_questions=len(response.poll.questions),
|
||||
question=_question_to_response(question),
|
||||
)
|
||||
|
||||
|
||||
@router.post('/{response_id}/questions/{question_id}/answer', response_model=AnswerResponse)
|
||||
async def answer_question(
|
||||
response_id: int,
|
||||
question_id: int,
|
||||
request: AnswerRequest,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Submit answer for a poll question."""
|
||||
response = await get_poll_response_by_id(db, response_id)
|
||||
|
||||
if not response or response.user_id != user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Poll not found',
|
||||
)
|
||||
|
||||
if response.completed_at:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='This poll has already been completed',
|
||||
)
|
||||
|
||||
if not response.poll:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Poll is not available',
|
||||
)
|
||||
|
||||
# Find the question
|
||||
question = next((q for q in response.poll.questions if q.id == question_id), None)
|
||||
if not question:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Question not found',
|
||||
)
|
||||
|
||||
# Validate option
|
||||
option = await get_question_option(question, request.option_id)
|
||||
if not option:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Invalid answer option',
|
||||
)
|
||||
|
||||
# Record the answer
|
||||
await record_poll_answer(
|
||||
db,
|
||||
response_id=response.id,
|
||||
question_id=question.id,
|
||||
option_id=option.id,
|
||||
)
|
||||
|
||||
# Refresh to get updated answers
|
||||
try:
|
||||
await db.refresh(response, attribute_names=['answers'])
|
||||
except Exception:
|
||||
response = await get_poll_response_by_id(db, response_id)
|
||||
if not response:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to process answer',
|
||||
)
|
||||
|
||||
# Get next question
|
||||
index, next_question = await get_next_question(response)
|
||||
total_questions = len(response.poll.questions)
|
||||
|
||||
if next_question:
|
||||
# More questions to answer
|
||||
return AnswerResponse(
|
||||
success=True,
|
||||
is_completed=False,
|
||||
next_question=_question_to_response(next_question),
|
||||
current_question_index=index,
|
||||
total_questions=total_questions,
|
||||
)
|
||||
|
||||
# Poll completed
|
||||
response.completed_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
|
||||
# Award reward if any
|
||||
reward_amount = await reward_user_for_poll(db, response)
|
||||
|
||||
message = 'Thank you for completing the poll!'
|
||||
if reward_amount:
|
||||
message += f' Reward of {settings.format_price(reward_amount)} has been added to your balance.'
|
||||
|
||||
return AnswerResponse(
|
||||
success=True,
|
||||
is_completed=True,
|
||||
total_questions=total_questions,
|
||||
reward_granted=reward_amount,
|
||||
message=message,
|
||||
)
|
||||
@@ -0,0 +1,415 @@
|
||||
"""Promo offers routes for cabinet - personal discounts and offers."""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import and_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.crud.discount_offer import (
|
||||
get_offer_by_id,
|
||||
mark_offer_claimed,
|
||||
)
|
||||
from app.database.crud.promo_group import get_auto_assign_promo_groups
|
||||
from app.database.crud.promo_offer_template import get_promo_offer_template_by_id
|
||||
from app.database.crud.transaction import get_user_total_spent_kopeks
|
||||
from app.database.models import DiscountOffer, User
|
||||
from app.services.promo_offer_service import promo_offer_service
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/promo', tags=['Cabinet Promo'])
|
||||
|
||||
|
||||
# ============ Schemas ============
|
||||
|
||||
|
||||
class PromoOfferInfo(BaseModel):
|
||||
"""Promo offer info."""
|
||||
|
||||
id: int
|
||||
notification_type: str
|
||||
discount_percent: int | None = None
|
||||
effect_type: str
|
||||
expires_at: datetime
|
||||
is_active: bool
|
||||
is_claimed: bool
|
||||
claimed_at: datetime | None = None
|
||||
extra_data: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class ActiveDiscountInfo(BaseModel):
|
||||
"""User's active discount info."""
|
||||
|
||||
discount_percent: int
|
||||
source: str | None = None
|
||||
expires_at: datetime | None = None
|
||||
is_active: bool
|
||||
|
||||
|
||||
class ClaimOfferRequest(BaseModel):
|
||||
"""Request to claim an offer."""
|
||||
|
||||
offer_id: int
|
||||
|
||||
|
||||
class ClaimOfferResponse(BaseModel):
|
||||
"""Response after claiming offer."""
|
||||
|
||||
success: bool
|
||||
message: str
|
||||
discount_percent: int | None = None
|
||||
expires_at: datetime | None = None
|
||||
|
||||
|
||||
class PromoGroupDiscounts(BaseModel):
|
||||
"""User's promo group discounts."""
|
||||
|
||||
group_name: str | None = None
|
||||
server_discount_percent: int = 0
|
||||
traffic_discount_percent: int = 0
|
||||
device_discount_percent: int = 0
|
||||
period_discounts: dict[str, int] = {}
|
||||
|
||||
|
||||
class LoyaltyTierInfo(BaseModel):
|
||||
"""Info about a single loyalty tier (promo group)."""
|
||||
|
||||
id: int
|
||||
name: str
|
||||
threshold_rubles: float
|
||||
server_discount_percent: int = 0
|
||||
traffic_discount_percent: int = 0
|
||||
device_discount_percent: int = 0
|
||||
period_discounts: dict[str, int] = {}
|
||||
is_current: bool = False
|
||||
is_achieved: bool = False
|
||||
|
||||
|
||||
class LoyaltyTiersResponse(BaseModel):
|
||||
"""Response with all loyalty tiers and user progress."""
|
||||
|
||||
tiers: list[LoyaltyTierInfo]
|
||||
current_spent_rubles: float
|
||||
current_tier_name: str | None = None
|
||||
next_tier_name: str | None = None
|
||||
next_tier_threshold_rubles: float | None = None
|
||||
progress_percent: float = 0
|
||||
|
||||
|
||||
# ============ Routes ============
|
||||
|
||||
|
||||
@router.get('/offers', response_model=list[PromoOfferInfo])
|
||||
async def get_promo_offers(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get list of available promo offers for the user."""
|
||||
now = datetime.utcnow()
|
||||
|
||||
result = await db.execute(
|
||||
select(DiscountOffer)
|
||||
.where(
|
||||
and_(
|
||||
DiscountOffer.user_id == user.id,
|
||||
DiscountOffer.expires_at > now,
|
||||
)
|
||||
)
|
||||
.order_by(DiscountOffer.created_at.desc())
|
||||
)
|
||||
offers = result.scalars().all()
|
||||
|
||||
return [
|
||||
PromoOfferInfo(
|
||||
id=offer.id,
|
||||
notification_type=offer.notification_type or '',
|
||||
discount_percent=offer.discount_percent,
|
||||
effect_type=offer.effect_type or 'percent_discount',
|
||||
expires_at=offer.expires_at,
|
||||
is_active=offer.is_active and offer.claimed_at is None,
|
||||
is_claimed=offer.claimed_at is not None,
|
||||
claimed_at=offer.claimed_at,
|
||||
extra_data=offer.extra_data,
|
||||
)
|
||||
for offer in offers
|
||||
]
|
||||
|
||||
|
||||
@router.get('/active-discount', response_model=ActiveDiscountInfo)
|
||||
async def get_active_discount(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
):
|
||||
"""Get user's currently active discount."""
|
||||
discount_percent = user.promo_offer_discount_percent or 0
|
||||
expires_at = user.promo_offer_discount_expires_at
|
||||
source = user.promo_offer_discount_source
|
||||
|
||||
now = datetime.utcnow()
|
||||
is_active = discount_percent > 0 and (expires_at is None or expires_at > now)
|
||||
|
||||
return ActiveDiscountInfo(
|
||||
discount_percent=discount_percent if is_active else 0,
|
||||
source=source,
|
||||
expires_at=expires_at,
|
||||
is_active=is_active,
|
||||
)
|
||||
|
||||
|
||||
@router.get('/group-discounts', response_model=PromoGroupDiscounts)
|
||||
async def get_promo_group_discounts(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get user's promo group discounts."""
|
||||
await db.refresh(user, ['promo_group', 'user_promo_groups'])
|
||||
|
||||
promo_group = user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else None
|
||||
|
||||
if not promo_group:
|
||||
return PromoGroupDiscounts()
|
||||
|
||||
# Get period discounts
|
||||
period_discounts = {}
|
||||
raw_period_discounts = getattr(promo_group, 'period_discounts', None)
|
||||
if isinstance(raw_period_discounts, dict):
|
||||
for key, value in raw_period_discounts.items():
|
||||
try:
|
||||
period_discounts[str(key)] = int(value)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
|
||||
return PromoGroupDiscounts(
|
||||
group_name=promo_group.name,
|
||||
server_discount_percent=promo_group.server_discount_percent or 0,
|
||||
traffic_discount_percent=promo_group.traffic_discount_percent or 0,
|
||||
device_discount_percent=promo_group.device_discount_percent or 0,
|
||||
period_discounts=period_discounts,
|
||||
)
|
||||
|
||||
|
||||
@router.get('/loyalty-tiers', response_model=LoyaltyTiersResponse)
|
||||
async def get_loyalty_tiers(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get all loyalty tiers (promo groups with auto-assign thresholds) and user's progress."""
|
||||
# Get user's total spent
|
||||
total_spent_kopeks = await get_user_total_spent_kopeks(db, user.id)
|
||||
total_spent_rubles = total_spent_kopeks / 100
|
||||
|
||||
# Get user's current promo group
|
||||
await db.refresh(user, ['promo_group', 'user_promo_groups'])
|
||||
current_promo_group = user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else None
|
||||
current_tier_name = current_promo_group.name if current_promo_group else None
|
||||
|
||||
# Get all auto-assign promo groups (sorted by threshold ascending)
|
||||
auto_groups = await get_auto_assign_promo_groups(db)
|
||||
|
||||
tiers: list[LoyaltyTierInfo] = []
|
||||
next_tier_name: str | None = None
|
||||
next_tier_threshold: float | None = None
|
||||
|
||||
for group in auto_groups:
|
||||
threshold_kopeks = group.auto_assign_total_spent_kopeks or 0
|
||||
threshold_rubles = threshold_kopeks / 100
|
||||
is_achieved = total_spent_kopeks >= threshold_kopeks
|
||||
is_current = current_promo_group and current_promo_group.id == group.id
|
||||
|
||||
# Get period discounts
|
||||
period_discounts = {}
|
||||
raw_period_discounts = getattr(group, 'period_discounts', None)
|
||||
if isinstance(raw_period_discounts, dict):
|
||||
for key, value in raw_period_discounts.items():
|
||||
try:
|
||||
period_discounts[str(key)] = int(value)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
|
||||
tiers.append(
|
||||
LoyaltyTierInfo(
|
||||
id=group.id,
|
||||
name=group.name,
|
||||
threshold_rubles=threshold_rubles,
|
||||
server_discount_percent=group.server_discount_percent or 0,
|
||||
traffic_discount_percent=group.traffic_discount_percent or 0,
|
||||
device_discount_percent=group.device_discount_percent or 0,
|
||||
period_discounts=period_discounts,
|
||||
is_current=is_current,
|
||||
is_achieved=is_achieved,
|
||||
)
|
||||
)
|
||||
|
||||
# Find next tier (first not achieved)
|
||||
if not is_achieved and next_tier_name is None:
|
||||
next_tier_name = group.name
|
||||
next_tier_threshold = threshold_rubles
|
||||
|
||||
# Calculate progress to next tier
|
||||
progress_percent = 0.0
|
||||
if next_tier_threshold and next_tier_threshold > 0:
|
||||
progress_percent = min(100.0, (total_spent_rubles / next_tier_threshold) * 100)
|
||||
elif tiers and all(t.is_achieved for t in tiers):
|
||||
# All tiers achieved
|
||||
progress_percent = 100.0
|
||||
|
||||
return LoyaltyTiersResponse(
|
||||
tiers=tiers,
|
||||
current_spent_rubles=total_spent_rubles,
|
||||
current_tier_name=current_tier_name,
|
||||
next_tier_name=next_tier_name,
|
||||
next_tier_threshold_rubles=next_tier_threshold,
|
||||
progress_percent=progress_percent,
|
||||
)
|
||||
|
||||
|
||||
@router.post('/claim', response_model=ClaimOfferResponse)
|
||||
async def claim_promo_offer(
|
||||
request: ClaimOfferRequest,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Claim a promo offer."""
|
||||
offer = await get_offer_by_id(db, request.offer_id)
|
||||
|
||||
if not offer or offer.user_id != user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Offer not found',
|
||||
)
|
||||
|
||||
now = datetime.utcnow()
|
||||
|
||||
if offer.claimed_at is not None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='This offer has already been claimed',
|
||||
)
|
||||
|
||||
if not offer.is_active or offer.expires_at <= now:
|
||||
offer.is_active = False
|
||||
await db.commit()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='This offer has expired',
|
||||
)
|
||||
|
||||
effect_type = (offer.effect_type or 'percent_discount').lower()
|
||||
|
||||
# Handle test access offers
|
||||
if effect_type == 'test_access':
|
||||
await db.refresh(user, ['subscription'])
|
||||
success, newly_added, expires_at, error_code = await promo_offer_service.grant_test_access(
|
||||
db,
|
||||
user,
|
||||
offer,
|
||||
)
|
||||
|
||||
if not success:
|
||||
error_messages = {
|
||||
'subscription_missing': 'Active subscription required for this offer',
|
||||
'squads_missing': 'Could not determine servers for test access',
|
||||
'already_connected': 'These servers are already connected',
|
||||
'remnawave_sync_failed': 'Failed to connect servers. Please try again later',
|
||||
}
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=error_messages.get(error_code, 'Failed to activate offer'),
|
||||
)
|
||||
|
||||
await mark_offer_claimed(
|
||||
db,
|
||||
offer,
|
||||
details={
|
||||
'context': 'test_access_claim',
|
||||
'new_squads': newly_added,
|
||||
'expires_at': expires_at.isoformat() if expires_at else None,
|
||||
},
|
||||
)
|
||||
|
||||
return ClaimOfferResponse(
|
||||
success=True,
|
||||
message=f'Test access activated until {expires_at.strftime("%Y-%m-%d %H:%M") if expires_at else "unlimited"}',
|
||||
expires_at=expires_at,
|
||||
)
|
||||
|
||||
# Handle discount offers
|
||||
discount_percent = int(offer.discount_percent or 0)
|
||||
if discount_percent <= 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Invalid offer',
|
||||
)
|
||||
|
||||
user.promo_offer_discount_percent = discount_percent
|
||||
user.promo_offer_discount_source = offer.notification_type
|
||||
user.updated_at = now
|
||||
|
||||
# Calculate expiration
|
||||
extra_data = offer.extra_data or {}
|
||||
raw_duration = extra_data.get('active_discount_hours')
|
||||
template_id = extra_data.get('template_id')
|
||||
|
||||
if raw_duration in (None, '') and template_id:
|
||||
try:
|
||||
template = await get_promo_offer_template_by_id(db, int(template_id))
|
||||
except (ValueError, TypeError):
|
||||
template = None
|
||||
if template and template.active_discount_hours:
|
||||
raw_duration = template.active_discount_hours
|
||||
|
||||
try:
|
||||
duration_hours = int(raw_duration) if raw_duration is not None else None
|
||||
except (TypeError, ValueError):
|
||||
duration_hours = None
|
||||
|
||||
if duration_hours and duration_hours > 0:
|
||||
discount_expires_at = now + timedelta(hours=duration_hours)
|
||||
else:
|
||||
discount_expires_at = None
|
||||
|
||||
user.promo_offer_discount_expires_at = discount_expires_at
|
||||
|
||||
await mark_offer_claimed(
|
||||
db,
|
||||
offer,
|
||||
details={
|
||||
'context': 'discount_claim',
|
||||
'discount_percent': discount_percent,
|
||||
'discount_expires_at': discount_expires_at.isoformat() if discount_expires_at else None,
|
||||
},
|
||||
)
|
||||
await db.refresh(user)
|
||||
|
||||
expires_text = ''
|
||||
if discount_expires_at:
|
||||
expires_text = f' Valid until {discount_expires_at.strftime("%Y-%m-%d %H:%M")}'
|
||||
|
||||
return ClaimOfferResponse(
|
||||
success=True,
|
||||
message=f'Discount of {discount_percent}% activated!{expires_text}',
|
||||
discount_percent=discount_percent,
|
||||
expires_at=discount_expires_at,
|
||||
)
|
||||
|
||||
|
||||
@router.delete('/active-discount')
|
||||
async def clear_active_discount(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Clear user's 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()
|
||||
|
||||
await db.commit()
|
||||
|
||||
return {'message': 'Active discount cleared'}
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Promo code routes for cabinet."""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import User
|
||||
from app.services.promocode_service import PromoCodeService
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/promocode', tags=['Cabinet Promocode'])
|
||||
|
||||
|
||||
class PromocodeActivateRequest(BaseModel):
|
||||
"""Request to activate a promo code."""
|
||||
|
||||
code: str = Field(..., min_length=1, max_length=50, description='Promo code to activate')
|
||||
|
||||
|
||||
class PromocodeActivateResponse(BaseModel):
|
||||
"""Response after activating a promo code."""
|
||||
|
||||
success: bool
|
||||
message: str
|
||||
balance_before: float = 0
|
||||
balance_after: float = 0
|
||||
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,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Activate a promo code for the current user."""
|
||||
promocode_service = PromoCodeService()
|
||||
|
||||
result = await promocode_service.activate_promocode(db=db, user_id=user.id, code=request.code.strip())
|
||||
|
||||
if result['success']:
|
||||
balance_before_rubles = result.get('balance_before_kopeks', 0) / 100
|
||||
balance_after_rubles = result.get('balance_after_kopeks', 0) / 100
|
||||
|
||||
return PromocodeActivateResponse(
|
||||
success=True,
|
||||
message='Promo code activated successfully',
|
||||
balance_before=balance_before_rubles,
|
||||
balance_after=balance_after_rubles,
|
||||
bonus_description=result.get('description'),
|
||||
)
|
||||
|
||||
# Map error codes to messages
|
||||
error_messages = {
|
||||
'not_found': 'Promo code not found',
|
||||
'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',
|
||||
}
|
||||
|
||||
error_code = result.get('error', 'server_error')
|
||||
error_message = error_messages.get(error_code, 'Failed to activate promo code')
|
||||
|
||||
raise HTTPException(
|
||||
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,
|
||||
)
|
||||
@@ -0,0 +1,197 @@
|
||||
"""Referral program routes for cabinet."""
|
||||
|
||||
import logging
|
||||
import math
|
||||
|
||||
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 ..dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
from ..schemas.referral import (
|
||||
ReferralEarningResponse,
|
||||
ReferralEarningsListResponse,
|
||||
ReferralInfoResponse,
|
||||
ReferralItemResponse,
|
||||
ReferralListResponse,
|
||||
ReferralTermsResponse,
|
||||
)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/referral', tags=['Cabinet Referral'])
|
||||
|
||||
|
||||
@router.get('', response_model=ReferralInfoResponse)
|
||||
async def get_referral_info(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get referral program info for current user."""
|
||||
# Get total referrals count
|
||||
total_query = select(func.count()).select_from(User).where(User.referred_by_id == user.id)
|
||||
total_result = await db.execute(total_query)
|
||||
total_referrals = total_result.scalar() or 0
|
||||
|
||||
# Get active referrals (with subscription)
|
||||
active_query = (
|
||||
select(func.count())
|
||||
.select_from(User)
|
||||
.where(User.referred_by_id == user.id)
|
||||
.where(User.has_had_paid_subscription == True)
|
||||
)
|
||||
active_result = await db.execute(active_query)
|
||||
active_referrals = active_result.scalar() or 0
|
||||
|
||||
# Get total earnings
|
||||
earnings_query = select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0)).where(
|
||||
ReferralEarning.user_id == user.id
|
||||
)
|
||||
earnings_result = await db.execute(earnings_query)
|
||||
total_earnings = earnings_result.scalar() or 0
|
||||
|
||||
# Get user's commission percent
|
||||
commission_percent = user.referral_commission_percent
|
||||
if commission_percent is None:
|
||||
commission_percent = settings.REFERRAL_COMMISSION_PERCENT
|
||||
|
||||
# Build referral link
|
||||
bot_username = settings.get_bot_username() or 'bot'
|
||||
referral_link = f'https://t.me/{bot_username}?start={user.referral_code}'
|
||||
|
||||
return ReferralInfoResponse(
|
||||
referral_code=user.referral_code or '',
|
||||
referral_link=referral_link,
|
||||
total_referrals=total_referrals,
|
||||
active_referrals=active_referrals,
|
||||
total_earnings_kopeks=total_earnings,
|
||||
total_earnings_rubles=total_earnings / 100,
|
||||
commission_percent=commission_percent,
|
||||
)
|
||||
|
||||
|
||||
@router.get('/list', response_model=ReferralListResponse)
|
||||
async def get_referral_list(
|
||||
page: int = Query(1, ge=1, description='Page number'),
|
||||
per_page: int = Query(20, ge=1, le=100, description='Items per page'),
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get list of invited users."""
|
||||
# Base query with eager loading of subscription relationship
|
||||
query = select(User).options(selectinload(User.subscription)).where(User.referred_by_id == user.id)
|
||||
|
||||
# Get total count
|
||||
count_query = select(func.count()).select_from(User).where(User.referred_by_id == user.id)
|
||||
total_result = await db.execute(count_query)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
# Paginate
|
||||
offset = (page - 1) * per_page
|
||||
query = query.order_by(desc(User.created_at)).offset(offset).limit(per_page)
|
||||
|
||||
result = await db.execute(query)
|
||||
referrals = result.scalars().all()
|
||||
|
||||
items = [
|
||||
ReferralItemResponse(
|
||||
id=r.id,
|
||||
username=r.username,
|
||||
first_name=r.first_name,
|
||||
created_at=r.created_at,
|
||||
has_subscription=r.subscription is not None,
|
||||
has_paid=r.has_had_paid_subscription,
|
||||
)
|
||||
for r in referrals
|
||||
]
|
||||
|
||||
pages = math.ceil(total / per_page) if total > 0 else 1
|
||||
|
||||
return ReferralListResponse(
|
||||
items=items,
|
||||
total=total,
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
pages=pages,
|
||||
)
|
||||
|
||||
|
||||
@router.get('/earnings', response_model=ReferralEarningsListResponse)
|
||||
async def get_referral_earnings(
|
||||
page: int = Query(1, ge=1, description='Page number'),
|
||||
per_page: int = Query(20, ge=1, le=100, description='Items per page'),
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get referral earnings history."""
|
||||
# Base query
|
||||
query = select(ReferralEarning).where(ReferralEarning.user_id == user.id)
|
||||
|
||||
# Get total count and sum
|
||||
count_query = select(func.count()).select_from(ReferralEarning).where(ReferralEarning.user_id == user.id)
|
||||
total_result = await db.execute(count_query)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
sum_query = select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0)).where(
|
||||
ReferralEarning.user_id == user.id
|
||||
)
|
||||
sum_result = await db.execute(sum_query)
|
||||
total_amount = sum_result.scalar() or 0
|
||||
|
||||
# Paginate
|
||||
offset = (page - 1) * per_page
|
||||
query = query.order_by(desc(ReferralEarning.created_at)).offset(offset).limit(per_page)
|
||||
|
||||
result = await db.execute(query)
|
||||
earnings = result.scalars().all()
|
||||
|
||||
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()
|
||||
|
||||
items.append(
|
||||
ReferralEarningResponse(
|
||||
id=e.id,
|
||||
amount_kopeks=e.amount_kopeks,
|
||||
amount_rubles=e.amount_kopeks / 100,
|
||||
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,
|
||||
created_at=e.created_at,
|
||||
)
|
||||
)
|
||||
|
||||
pages = math.ceil(total / per_page) if total > 0 else 1
|
||||
|
||||
return ReferralEarningsListResponse(
|
||||
items=items,
|
||||
total=total,
|
||||
total_amount_kopeks=total_amount,
|
||||
total_amount_rubles=total_amount / 100,
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
pages=pages,
|
||||
)
|
||||
|
||||
|
||||
@router.get('/terms', response_model=ReferralTermsResponse)
|
||||
async def get_referral_terms():
|
||||
"""Get referral program terms."""
|
||||
return ReferralTermsResponse(
|
||||
is_enabled=settings.is_referral_program_enabled(),
|
||||
commission_percent=settings.REFERRAL_COMMISSION_PERCENT,
|
||||
minimum_topup_kopeks=settings.REFERRAL_MINIMUM_TOPUP_KOPEKS,
|
||||
minimum_topup_rubles=settings.REFERRAL_MINIMUM_TOPUP_KOPEKS / 100,
|
||||
first_topup_bonus_kopeks=settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS,
|
||||
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,
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,204 @@
|
||||
"""Ticket notifications routes for cabinet."""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.crud.ticket_notification import TicketNotificationCRUD
|
||||
from app.database.models import User
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user, get_current_cabinet_user
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/tickets/notifications', tags=['Cabinet Ticket Notifications'])
|
||||
admin_router = APIRouter(prefix='/admin/tickets/notifications', tags=['Cabinet Admin Ticket Notifications'])
|
||||
|
||||
|
||||
# Schemas
|
||||
class TicketNotificationResponse(BaseModel):
|
||||
"""Single ticket notification."""
|
||||
|
||||
id: int
|
||||
ticket_id: int
|
||||
notification_type: str
|
||||
message: str | None = None
|
||||
is_read: bool
|
||||
created_at: datetime
|
||||
read_at: datetime | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class TicketNotificationListResponse(BaseModel):
|
||||
"""List of ticket notifications."""
|
||||
|
||||
items: list[TicketNotificationResponse]
|
||||
unread_count: int
|
||||
|
||||
|
||||
class UnreadCountResponse(BaseModel):
|
||||
"""Unread notifications count."""
|
||||
|
||||
unread_count: int
|
||||
|
||||
|
||||
# User endpoints
|
||||
@router.get('', response_model=TicketNotificationListResponse)
|
||||
async def get_user_notifications(
|
||||
unread_only: bool = Query(False, description='Only return unread notifications'),
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
offset: int = Query(0, ge=0),
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get ticket notifications for current user."""
|
||||
notifications = await TicketNotificationCRUD.get_user_notifications(
|
||||
db, user.id, unread_only=unread_only, limit=limit, offset=offset
|
||||
)
|
||||
unread_count = await TicketNotificationCRUD.count_unread_user(db, user.id)
|
||||
|
||||
return TicketNotificationListResponse(
|
||||
items=[TicketNotificationResponse.model_validate(n) for n in notifications],
|
||||
unread_count=unread_count,
|
||||
)
|
||||
|
||||
|
||||
@router.get('/unread-count', response_model=UnreadCountResponse)
|
||||
async def get_user_unread_count(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get unread notifications count for current user."""
|
||||
count = await TicketNotificationCRUD.count_unread_user(db, user.id)
|
||||
return UnreadCountResponse(unread_count=count)
|
||||
|
||||
|
||||
@router.post('/{notification_id}/read')
|
||||
async def mark_notification_as_read(
|
||||
notification_id: int,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Mark a notification as read."""
|
||||
# Security: Verify notification belongs to current user and is not an admin notification
|
||||
notification = await TicketNotificationCRUD.get_by_id(db, notification_id)
|
||||
if not notification:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Notification not found',
|
||||
)
|
||||
|
||||
# Check ownership: notification must belong to user and not be an admin notification
|
||||
if notification.user_id != user.id or notification.is_for_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="You don't have permission to mark this notification as read",
|
||||
)
|
||||
|
||||
await TicketNotificationCRUD.mark_as_read(db, notification_id)
|
||||
return {'success': True}
|
||||
|
||||
|
||||
@router.post('/read-all')
|
||||
async def mark_all_notifications_as_read(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Mark all notifications as read for current user."""
|
||||
count = await TicketNotificationCRUD.mark_all_as_read_user(db, user.id)
|
||||
return {'success': True, 'marked_count': count}
|
||||
|
||||
|
||||
@router.post('/ticket/{ticket_id}/read')
|
||||
async def mark_ticket_notifications_as_read(
|
||||
ticket_id: int,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Mark all notifications for a specific ticket as read."""
|
||||
count = await TicketNotificationCRUD.mark_ticket_notifications_as_read(db, ticket_id, user.id, is_admin=False)
|
||||
return {'success': True, 'marked_count': count}
|
||||
|
||||
|
||||
# Admin endpoints
|
||||
@admin_router.get('', response_model=TicketNotificationListResponse)
|
||||
async def get_admin_notifications(
|
||||
unread_only: bool = Query(False, description='Only return unread notifications'),
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
offset: int = Query(0, ge=0),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get ticket notifications for admins."""
|
||||
notifications = await TicketNotificationCRUD.get_admin_notifications(
|
||||
db, unread_only=unread_only, limit=limit, offset=offset
|
||||
)
|
||||
unread_count = await TicketNotificationCRUD.count_unread_admin(db)
|
||||
|
||||
return TicketNotificationListResponse(
|
||||
items=[TicketNotificationResponse.model_validate(n) for n in notifications],
|
||||
unread_count=unread_count,
|
||||
)
|
||||
|
||||
|
||||
@admin_router.get('/unread-count', response_model=UnreadCountResponse)
|
||||
async def get_admin_unread_count(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get unread notifications count for admins."""
|
||||
count = await TicketNotificationCRUD.count_unread_admin(db)
|
||||
return UnreadCountResponse(unread_count=count)
|
||||
|
||||
|
||||
@admin_router.post('/{notification_id}/read')
|
||||
async def mark_admin_notification_as_read(
|
||||
notification_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Mark an admin notification as read."""
|
||||
# Security: Verify notification exists and is an admin notification
|
||||
notification = await TicketNotificationCRUD.get_by_id(db, notification_id)
|
||||
if not notification:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Notification not found',
|
||||
)
|
||||
|
||||
# Check that this is actually an admin notification
|
||||
if not notification.is_for_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='This is not an admin notification',
|
||||
)
|
||||
|
||||
await TicketNotificationCRUD.mark_as_read(db, notification_id)
|
||||
return {'success': True}
|
||||
|
||||
|
||||
@admin_router.post('/read-all')
|
||||
async def mark_all_admin_notifications_as_read(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Mark all admin notifications as read."""
|
||||
count = await TicketNotificationCRUD.mark_all_as_read_admin(db)
|
||||
return {'success': True, 'marked_count': count}
|
||||
|
||||
|
||||
@admin_router.post('/ticket/{ticket_id}/read')
|
||||
async def mark_admin_ticket_notifications_as_read(
|
||||
ticket_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Mark all admin notifications for a specific ticket as read."""
|
||||
count = await TicketNotificationCRUD.mark_ticket_notifications_as_read(db, ticket_id, admin.id, is_admin=True)
|
||||
return {'success': True, 'marked_count': count}
|
||||
@@ -0,0 +1,300 @@
|
||||
"""Support tickets routes for cabinet."""
|
||||
|
||||
import logging
|
||||
import math
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import desc, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.cabinet.routes.websocket import notify_admins_new_ticket, notify_admins_ticket_reply
|
||||
from app.config import settings
|
||||
from app.database.crud.ticket_notification import TicketNotificationCRUD
|
||||
from app.database.models import Ticket, TicketMessage, User
|
||||
from app.handlers.tickets import notify_admins_about_new_ticket, notify_admins_about_ticket_reply
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
from ..schemas.tickets import (
|
||||
TicketCreateRequest,
|
||||
TicketDetailResponse,
|
||||
TicketListResponse,
|
||||
TicketMessageCreateRequest,
|
||||
TicketMessageResponse,
|
||||
TicketResponse,
|
||||
)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/tickets', tags=['Cabinet Tickets'])
|
||||
|
||||
|
||||
def _message_to_response(message: TicketMessage) -> TicketMessageResponse:
|
||||
"""Convert TicketMessage to response."""
|
||||
return TicketMessageResponse(
|
||||
id=message.id,
|
||||
message_text=message.message_text or '',
|
||||
is_from_admin=message.is_from_admin,
|
||||
has_media=bool(message.media_file_id),
|
||||
media_type=message.media_type,
|
||||
media_file_id=message.media_file_id,
|
||||
media_caption=message.media_caption,
|
||||
created_at=message.created_at,
|
||||
)
|
||||
|
||||
|
||||
def _ticket_to_response(ticket: Ticket, include_last_message: bool = True) -> TicketResponse:
|
||||
"""Convert Ticket to response."""
|
||||
last_message = None
|
||||
messages_count = len(ticket.messages) if ticket.messages else 0
|
||||
|
||||
if include_last_message and ticket.messages:
|
||||
last_msg = max(ticket.messages, key=lambda m: m.created_at)
|
||||
last_message = _message_to_response(last_msg)
|
||||
|
||||
return TicketResponse(
|
||||
id=ticket.id,
|
||||
title=ticket.title or f'Ticket #{ticket.id}',
|
||||
status=ticket.status,
|
||||
priority=ticket.priority or 'normal',
|
||||
created_at=ticket.created_at,
|
||||
updated_at=ticket.updated_at or ticket.created_at,
|
||||
closed_at=ticket.closed_at,
|
||||
messages_count=messages_count,
|
||||
last_message=last_message,
|
||||
)
|
||||
|
||||
|
||||
@router.get('', response_model=TicketListResponse)
|
||||
async def get_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'),
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get user's support tickets."""
|
||||
# Check if tickets are enabled
|
||||
if not settings.is_support_tickets_enabled():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Support tickets are disabled',
|
||||
)
|
||||
|
||||
# Base query
|
||||
query = select(Ticket).where(Ticket.user_id == user.id).options(selectinload(Ticket.messages))
|
||||
|
||||
# Filter by status
|
||||
if status_filter:
|
||||
query = query.where(Ticket.status == status_filter)
|
||||
|
||||
# Get total count
|
||||
count_query = select(func.count()).select_from(Ticket).where(Ticket.user_id == user.id)
|
||||
if status_filter:
|
||||
count_query = count_query.where(Ticket.status == status_filter)
|
||||
|
||||
total_result = await db.execute(count_query)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
# Paginate
|
||||
offset = (page - 1) * per_page
|
||||
query = query.order_by(desc(Ticket.updated_at)).offset(offset).limit(per_page)
|
||||
|
||||
result = await db.execute(query)
|
||||
tickets = result.scalars().all()
|
||||
|
||||
items = [_ticket_to_response(t) for t in tickets]
|
||||
pages = math.ceil(total / per_page) if total > 0 else 1
|
||||
|
||||
return TicketListResponse(
|
||||
items=items,
|
||||
total=total,
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
pages=pages,
|
||||
)
|
||||
|
||||
|
||||
@router.post('', response_model=TicketDetailResponse)
|
||||
async def create_ticket(
|
||||
request: TicketCreateRequest,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Create a new support ticket."""
|
||||
# Check if tickets are enabled
|
||||
if not settings.is_support_tickets_enabled():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Support tickets are disabled',
|
||||
)
|
||||
|
||||
# Create ticket
|
||||
ticket = Ticket(
|
||||
user_id=user.id,
|
||||
title=request.title,
|
||||
status='open',
|
||||
priority='normal',
|
||||
created_at=datetime.utcnow(),
|
||||
updated_at=datetime.utcnow(),
|
||||
)
|
||||
db.add(ticket)
|
||||
await db.flush()
|
||||
|
||||
# Create initial message with optional media
|
||||
message = TicketMessage(
|
||||
ticket_id=ticket.id,
|
||||
user_id=user.id,
|
||||
message_text=request.message,
|
||||
is_from_admin=False,
|
||||
media_type=request.media_type,
|
||||
media_file_id=request.media_file_id,
|
||||
media_caption=request.media_caption,
|
||||
created_at=datetime.utcnow(),
|
||||
)
|
||||
db.add(message)
|
||||
await db.commit()
|
||||
|
||||
# Refresh to get relationships
|
||||
await db.refresh(ticket, ['messages'])
|
||||
|
||||
# Уведомить админов о новом тикете (Telegram)
|
||||
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}')
|
||||
|
||||
# Уведомить админов в кабинете
|
||||
try:
|
||||
notification = await TicketNotificationCRUD.create_admin_notification_for_new_ticket(db, ticket)
|
||||
if notification:
|
||||
# Отправить 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}')
|
||||
|
||||
messages = [_message_to_response(m) for m in ticket.messages]
|
||||
|
||||
return TicketDetailResponse(
|
||||
id=ticket.id,
|
||||
title=ticket.title,
|
||||
status=ticket.status,
|
||||
priority=ticket.priority or 'normal',
|
||||
created_at=ticket.created_at,
|
||||
updated_at=ticket.updated_at,
|
||||
closed_at=ticket.closed_at,
|
||||
is_reply_blocked=ticket.is_reply_blocked if hasattr(ticket, 'is_reply_blocked') else False,
|
||||
messages=messages,
|
||||
)
|
||||
|
||||
|
||||
@router.get('/{ticket_id}', response_model=TicketDetailResponse)
|
||||
async def get_ticket(
|
||||
ticket_id: int,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get ticket with all messages."""
|
||||
query = (
|
||||
select(Ticket).where(Ticket.id == ticket_id, Ticket.user_id == user.id).options(selectinload(Ticket.messages))
|
||||
)
|
||||
|
||||
result = await db.execute(query)
|
||||
ticket = result.scalar_one_or_none()
|
||||
|
||||
if not ticket:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Ticket not found',
|
||||
)
|
||||
|
||||
messages = sorted(ticket.messages or [], key=lambda m: m.created_at)
|
||||
messages_response = [_message_to_response(m) for m in messages]
|
||||
|
||||
return TicketDetailResponse(
|
||||
id=ticket.id,
|
||||
title=ticket.title or f'Ticket #{ticket.id}',
|
||||
status=ticket.status,
|
||||
priority=ticket.priority or 'normal',
|
||||
created_at=ticket.created_at,
|
||||
updated_at=ticket.updated_at or ticket.created_at,
|
||||
closed_at=ticket.closed_at,
|
||||
is_reply_blocked=ticket.is_reply_blocked if hasattr(ticket, 'is_reply_blocked') else False,
|
||||
messages=messages_response,
|
||||
)
|
||||
|
||||
|
||||
@router.post('/{ticket_id}/messages', response_model=TicketMessageResponse)
|
||||
async def add_ticket_message(
|
||||
ticket_id: int,
|
||||
request: TicketMessageCreateRequest,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Add message to existing ticket."""
|
||||
# Get ticket
|
||||
query = select(Ticket).where(Ticket.id == ticket_id, Ticket.user_id == user.id)
|
||||
result = await db.execute(query)
|
||||
ticket = result.scalar_one_or_none()
|
||||
|
||||
if not ticket:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Ticket not found',
|
||||
)
|
||||
|
||||
# Check if ticket is closed
|
||||
if ticket.status == 'closed':
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Cannot add message to closed ticket',
|
||||
)
|
||||
|
||||
# Check if replies are blocked
|
||||
if hasattr(ticket, 'is_reply_blocked') and ticket.is_reply_blocked:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Replies to this ticket are blocked',
|
||||
)
|
||||
|
||||
# Create message with optional media
|
||||
message = TicketMessage(
|
||||
ticket_id=ticket.id,
|
||||
user_id=user.id,
|
||||
message_text=request.message,
|
||||
is_from_admin=False,
|
||||
media_type=request.media_type,
|
||||
media_file_id=request.media_file_id,
|
||||
media_caption=request.media_caption,
|
||||
created_at=datetime.utcnow(),
|
||||
)
|
||||
db.add(message)
|
||||
|
||||
# Update ticket status and timestamp
|
||||
if ticket.status == 'answered':
|
||||
ticket.status = 'pending'
|
||||
ticket.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(message)
|
||||
|
||||
# Уведомить админов об ответе пользователя (Telegram)
|
||||
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}')
|
||||
|
||||
# Уведомить админов в кабинете
|
||||
try:
|
||||
notification = await TicketNotificationCRUD.create_admin_notification_for_user_reply(
|
||||
db, ticket, request.message
|
||||
)
|
||||
if notification:
|
||||
# Отправить 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}')
|
||||
|
||||
return _message_to_response(message)
|
||||
@@ -0,0 +1,589 @@
|
||||
"""WebSocket endpoint for cabinet real-time notifications."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
|
||||
from app.cabinet.auth.jwt_handler import get_token_payload
|
||||
from app.config import settings
|
||||
from app.database.crud.user import get_user_by_id
|
||||
from app.database.database import AsyncSessionLocal
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class CabinetConnectionManager:
|
||||
"""Менеджер WebSocket подключений для кабинета."""
|
||||
|
||||
def __init__(self):
|
||||
# user_id -> set of websocket connections
|
||||
self._user_connections: dict[int, set[WebSocket]] = {}
|
||||
# admin user_ids -> set of websocket connections
|
||||
self._admin_connections: dict[int, set[WebSocket]] = {}
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def connect(self, websocket: WebSocket, user_id: int, is_admin: bool) -> None:
|
||||
"""Зарегистрировать подключение."""
|
||||
async with self._lock:
|
||||
if user_id not in self._user_connections:
|
||||
self._user_connections[user_id] = set()
|
||||
self._user_connections[user_id].add(websocket)
|
||||
|
||||
if is_admin:
|
||||
if user_id not in self._admin_connections:
|
||||
self._admin_connections[user_id] = set()
|
||||
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),
|
||||
)
|
||||
|
||||
async def disconnect(self, websocket: WebSocket, user_id: int) -> None:
|
||||
"""Отменить регистрацию подключения."""
|
||||
async with self._lock:
|
||||
if user_id in self._user_connections:
|
||||
self._user_connections[user_id].discard(websocket)
|
||||
if not self._user_connections[user_id]:
|
||||
del self._user_connections[user_id]
|
||||
|
||||
if user_id in self._admin_connections:
|
||||
self._admin_connections[user_id].discard(websocket)
|
||||
if not self._admin_connections[user_id]:
|
||||
del self._admin_connections[user_id]
|
||||
|
||||
logger.debug('Cabinet WS disconnected: user_id=%d', user_id)
|
||||
|
||||
async def send_to_user(self, user_id: int, message: dict) -> None:
|
||||
"""Отправить сообщение конкретному пользователю."""
|
||||
# Snapshot connections under the lock to avoid mutation during iteration
|
||||
async with self._lock:
|
||||
connections = list(self._user_connections.get(user_id, set()))
|
||||
|
||||
if not connections:
|
||||
return
|
||||
|
||||
disconnected = set()
|
||||
data = json.dumps(message, default=str, ensure_ascii=False)
|
||||
|
||||
for ws in connections:
|
||||
try:
|
||||
await ws.send_text(data)
|
||||
except Exception as e:
|
||||
logger.warning('Failed to send to user %d: %s', user_id, e)
|
||||
disconnected.add(ws)
|
||||
|
||||
# Cleanup disconnected
|
||||
if disconnected:
|
||||
async with self._lock:
|
||||
for ws in disconnected:
|
||||
self._user_connections.get(user_id, set()).discard(ws)
|
||||
|
||||
async def send_to_admins(self, message: dict) -> None:
|
||||
"""Отправить сообщение всем админам."""
|
||||
# Snapshot connections under the lock to avoid mutation during iteration
|
||||
async with self._lock:
|
||||
if not self._admin_connections:
|
||||
return
|
||||
# Create a snapshot: list of (user_id, list of websockets)
|
||||
admin_snapshot = [(user_id, list(connections)) for user_id, connections in self._admin_connections.items()]
|
||||
|
||||
data = json.dumps(message, default=str, ensure_ascii=False)
|
||||
disconnected_by_user: dict[int, set[WebSocket]] = {}
|
||||
|
||||
for user_id, connections in admin_snapshot:
|
||||
for ws in connections:
|
||||
try:
|
||||
await ws.send_text(data)
|
||||
except Exception as e:
|
||||
logger.warning('Failed to send to admin %d: %s', user_id, e)
|
||||
if user_id not in disconnected_by_user:
|
||||
disconnected_by_user[user_id] = set()
|
||||
disconnected_by_user[user_id].add(ws)
|
||||
|
||||
# Cleanup disconnected
|
||||
if disconnected_by_user:
|
||||
async with self._lock:
|
||||
for user_id, ws_set in disconnected_by_user.items():
|
||||
for ws in ws_set:
|
||||
self._admin_connections.get(user_id, set()).discard(ws)
|
||||
|
||||
|
||||
# Глобальный менеджер подключений
|
||||
cabinet_ws_manager = CabinetConnectionManager()
|
||||
|
||||
|
||||
async def verify_cabinet_ws_token(token: str) -> tuple[int | None, bool]:
|
||||
"""
|
||||
Проверить JWT токен для WebSocket.
|
||||
|
||||
Returns:
|
||||
tuple[user_id, is_admin] или (None, False) если токен невалидный
|
||||
"""
|
||||
if not token:
|
||||
return None, False
|
||||
|
||||
payload = get_token_payload(token, expected_type='access')
|
||||
if not payload:
|
||||
return None, False
|
||||
|
||||
try:
|
||||
user_id = int(payload.get('sub'))
|
||||
except (TypeError, ValueError):
|
||||
return None, False
|
||||
|
||||
try:
|
||||
async with AsyncSessionLocal() as db:
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user or user.status != 'active':
|
||||
return None, False
|
||||
|
||||
is_admin = settings.is_admin(
|
||||
telegram_id=user.telegram_id, email=user.email if user.email_verified else None
|
||||
)
|
||||
return user_id, is_admin
|
||||
except (TimeoutError, OSError, ConnectionRefusedError) as e:
|
||||
logger.error('Database connection error in WS token verification: %s', str(e)[:200])
|
||||
return None, False
|
||||
|
||||
|
||||
@router.websocket('/ws')
|
||||
async def cabinet_websocket_endpoint(websocket: WebSocket):
|
||||
"""WebSocket endpoint для real-time уведомлений кабинета."""
|
||||
client_host = websocket.client.host if websocket.client else 'unknown'
|
||||
|
||||
# Получаем токен из query params
|
||||
token = websocket.query_params.get('token')
|
||||
|
||||
if not token:
|
||||
logger.debug('Cabinet WS: No token from %s', client_host)
|
||||
# Принимаем и сразу закрываем с кодом ошибки
|
||||
await websocket.accept()
|
||||
await websocket.close(code=1008, reason='Unauthorized: No token')
|
||||
return
|
||||
|
||||
# Верифицируем токен
|
||||
user_id, is_admin = await verify_cabinet_ws_token(token)
|
||||
|
||||
if not user_id:
|
||||
logger.debug('Cabinet WS: Invalid token from %s', client_host)
|
||||
# Принимаем и сразу закрываем с кодом ошибки
|
||||
await websocket.accept()
|
||||
await websocket.close(code=1008, reason='Unauthorized: Invalid token')
|
||||
return
|
||||
|
||||
# Принимаем соединение
|
||||
try:
|
||||
await websocket.accept()
|
||||
logger.debug('Cabinet WS accepted: user_id=%d, is_admin=%s', user_id, is_admin)
|
||||
except Exception as e:
|
||||
logger.error('Cabinet WS: Failed to accept from %s: %s', client_host, e)
|
||||
return
|
||||
|
||||
# Регистрируем подключение
|
||||
await cabinet_ws_manager.connect(websocket, user_id, is_admin)
|
||||
|
||||
try:
|
||||
# Приветственное сообщение
|
||||
await websocket.send_json(
|
||||
{
|
||||
'type': 'connected',
|
||||
'user_id': user_id,
|
||||
'is_admin': is_admin,
|
||||
}
|
||||
)
|
||||
|
||||
# Обрабатываем входящие сообщения
|
||||
while True:
|
||||
try:
|
||||
data = await websocket.receive_text()
|
||||
message = json.loads(data)
|
||||
|
||||
# Ping/pong для keepalive
|
||||
if message.get('type') == 'ping':
|
||||
await websocket.send_json({'type': 'pong'})
|
||||
|
||||
except json.JSONDecodeError:
|
||||
logger.warning('Cabinet WS: Invalid JSON from user %d', user_id)
|
||||
except WebSocketDisconnect:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.exception('Cabinet WS error for user %d: %s', user_id, e)
|
||||
break
|
||||
|
||||
except WebSocketDisconnect:
|
||||
logger.debug('Cabinet WS disconnected: user_id=%d', user_id)
|
||||
except Exception as e:
|
||||
logger.exception('Cabinet WS error: %s', e)
|
||||
finally:
|
||||
await cabinet_ws_manager.disconnect(websocket, user_id)
|
||||
|
||||
|
||||
# Функции для отправки уведомлений (используются из других модулей)
|
||||
async def notify_user_ticket_reply(user_id: int, ticket_id: int, message: str) -> None:
|
||||
"""Уведомить пользователя об ответе в тикете."""
|
||||
await cabinet_ws_manager.send_to_user(
|
||||
user_id,
|
||||
{
|
||||
'type': 'ticket.admin_reply',
|
||||
'ticket_id': ticket_id,
|
||||
'message': message,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def notify_admins_new_ticket(ticket_id: int, title: str, user_id: int) -> None:
|
||||
"""Уведомить админов о новом тикете."""
|
||||
await cabinet_ws_manager.send_to_admins(
|
||||
{
|
||||
'type': 'ticket.new',
|
||||
'ticket_id': ticket_id,
|
||||
'title': title,
|
||||
'user_id': user_id,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def notify_admins_ticket_reply(ticket_id: int, message: str, user_id: int) -> None:
|
||||
"""Уведомить админов об ответе пользователя."""
|
||||
await cabinet_ws_manager.send_to_admins(
|
||||
{
|
||||
'type': 'ticket.user_reply',
|
||||
'ticket_id': ticket_id,
|
||||
'message': message,
|
||||
'user_id': user_id,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Уведомления о балансе
|
||||
# ============================================================================
|
||||
|
||||
|
||||
async def notify_user_balance_topup(
|
||||
user_id: int,
|
||||
amount_kopeks: int,
|
||||
new_balance_kopeks: int,
|
||||
description: str = '',
|
||||
) -> None:
|
||||
"""Уведомить пользователя о пополнении баланса."""
|
||||
await cabinet_ws_manager.send_to_user(
|
||||
user_id,
|
||||
{
|
||||
'type': 'balance.topup',
|
||||
'amount_kopeks': amount_kopeks,
|
||||
'amount_rubles': amount_kopeks / 100,
|
||||
'new_balance_kopeks': new_balance_kopeks,
|
||||
'new_balance_rubles': new_balance_kopeks / 100,
|
||||
'description': description,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def notify_user_balance_change(
|
||||
user_id: int,
|
||||
amount_kopeks: int,
|
||||
new_balance_kopeks: int,
|
||||
description: str = '',
|
||||
) -> None:
|
||||
"""Уведомить пользователя об изменении баланса."""
|
||||
await cabinet_ws_manager.send_to_user(
|
||||
user_id,
|
||||
{
|
||||
'type': 'balance.change',
|
||||
'amount_kopeks': amount_kopeks,
|
||||
'amount_rubles': amount_kopeks / 100,
|
||||
'new_balance_kopeks': new_balance_kopeks,
|
||||
'new_balance_rubles': new_balance_kopeks / 100,
|
||||
'description': description,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Уведомления о подписке
|
||||
# ============================================================================
|
||||
|
||||
|
||||
async def notify_user_subscription_activated(
|
||||
user_id: int,
|
||||
expires_at: str,
|
||||
tariff_name: str = '',
|
||||
) -> None:
|
||||
"""Уведомить пользователя об активации подписки."""
|
||||
await cabinet_ws_manager.send_to_user(
|
||||
user_id,
|
||||
{
|
||||
'type': 'subscription.activated',
|
||||
'expires_at': expires_at,
|
||||
'tariff_name': tariff_name,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def notify_user_subscription_expiring(
|
||||
user_id: int,
|
||||
days_left: int,
|
||||
expires_at: str,
|
||||
) -> None:
|
||||
"""Уведомить пользователя о скором истечении подписки."""
|
||||
await cabinet_ws_manager.send_to_user(
|
||||
user_id,
|
||||
{
|
||||
'type': 'subscription.expiring',
|
||||
'days_left': days_left,
|
||||
'expires_at': expires_at,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def notify_user_subscription_expired(user_id: int) -> None:
|
||||
"""Уведомить пользователя об истечении подписки."""
|
||||
await cabinet_ws_manager.send_to_user(
|
||||
user_id,
|
||||
{
|
||||
'type': 'subscription.expired',
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def notify_user_subscription_renewed(
|
||||
user_id: int,
|
||||
new_expires_at: str,
|
||||
amount_kopeks: int = 0,
|
||||
) -> None:
|
||||
"""Уведомить пользователя о продлении подписки."""
|
||||
await cabinet_ws_manager.send_to_user(
|
||||
user_id,
|
||||
{
|
||||
'type': 'subscription.renewed',
|
||||
'new_expires_at': new_expires_at,
|
||||
'amount_kopeks': amount_kopeks,
|
||||
'amount_rubles': amount_kopeks / 100,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
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,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Уведомления об автопродлении
|
||||
# ============================================================================
|
||||
|
||||
|
||||
async def notify_user_autopay_success(
|
||||
user_id: int,
|
||||
amount_kopeks: int,
|
||||
new_expires_at: str,
|
||||
) -> None:
|
||||
"""Уведомить пользователя об успешном автопродлении."""
|
||||
await cabinet_ws_manager.send_to_user(
|
||||
user_id,
|
||||
{
|
||||
'type': 'autopay.success',
|
||||
'amount_kopeks': amount_kopeks,
|
||||
'amount_rubles': amount_kopeks / 100,
|
||||
'new_expires_at': new_expires_at,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def notify_user_autopay_failed(
|
||||
user_id: int,
|
||||
reason: str = '',
|
||||
) -> None:
|
||||
"""Уведомить пользователя о неудачном автопродлении."""
|
||||
await cabinet_ws_manager.send_to_user(
|
||||
user_id,
|
||||
{
|
||||
'type': 'autopay.failed',
|
||||
'reason': reason,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def notify_user_autopay_insufficient_funds(
|
||||
user_id: int,
|
||||
required_kopeks: int,
|
||||
balance_kopeks: int,
|
||||
) -> None:
|
||||
"""Уведомить о недостатке средств для автопродления."""
|
||||
await cabinet_ws_manager.send_to_user(
|
||||
user_id,
|
||||
{
|
||||
'type': 'autopay.insufficient_funds',
|
||||
'required_kopeks': required_kopeks,
|
||||
'required_rubles': required_kopeks / 100,
|
||||
'balance_kopeks': balance_kopeks,
|
||||
'balance_rubles': balance_kopeks / 100,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Уведомления о бане/разбане
|
||||
# ============================================================================
|
||||
|
||||
|
||||
async def notify_user_ban(user_id: int, reason: str = '') -> None:
|
||||
"""Уведомить пользователя о блокировке."""
|
||||
await cabinet_ws_manager.send_to_user(
|
||||
user_id,
|
||||
{
|
||||
'type': 'account.banned',
|
||||
'reason': reason,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def notify_user_unban(user_id: int) -> None:
|
||||
"""Уведомить пользователя о разблокировке."""
|
||||
await cabinet_ws_manager.send_to_user(
|
||||
user_id,
|
||||
{
|
||||
'type': 'account.unbanned',
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def notify_user_warning(user_id: int, message: str) -> None:
|
||||
"""Уведомить пользователя о предупреждении."""
|
||||
await cabinet_ws_manager.send_to_user(
|
||||
user_id,
|
||||
{
|
||||
'type': 'account.warning',
|
||||
'message': message,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Уведомления о рефералах
|
||||
# ============================================================================
|
||||
|
||||
|
||||
async def notify_user_referral_bonus(
|
||||
user_id: int,
|
||||
bonus_kopeks: int,
|
||||
referral_name: str = '',
|
||||
) -> None:
|
||||
"""Уведомить пользователя о реферальном бонусе."""
|
||||
await cabinet_ws_manager.send_to_user(
|
||||
user_id,
|
||||
{
|
||||
'type': 'referral.bonus',
|
||||
'bonus_kopeks': bonus_kopeks,
|
||||
'bonus_rubles': bonus_kopeks / 100,
|
||||
'referral_name': referral_name,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def notify_user_referral_registered(
|
||||
user_id: int,
|
||||
referral_name: str = '',
|
||||
) -> None:
|
||||
"""Уведомить пользователя о регистрации нового реферала."""
|
||||
await cabinet_ws_manager.send_to_user(
|
||||
user_id,
|
||||
{
|
||||
'type': 'referral.registered',
|
||||
'referral_name': referral_name,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Прочие уведомления
|
||||
# ============================================================================
|
||||
|
||||
|
||||
async def notify_user_daily_debit(
|
||||
user_id: int,
|
||||
amount_kopeks: int,
|
||||
new_balance_kopeks: int,
|
||||
) -> None:
|
||||
"""Уведомить о ежедневном списании."""
|
||||
await cabinet_ws_manager.send_to_user(
|
||||
user_id,
|
||||
{
|
||||
'type': 'subscription.daily_debit',
|
||||
'amount_kopeks': amount_kopeks,
|
||||
'amount_rubles': amount_kopeks / 100,
|
||||
'new_balance_kopeks': new_balance_kopeks,
|
||||
'new_balance_rubles': new_balance_kopeks / 100,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def notify_user_traffic_reset(user_id: int) -> None:
|
||||
"""Уведомить о сбросе трафика."""
|
||||
await cabinet_ws_manager.send_to_user(
|
||||
user_id,
|
||||
{
|
||||
'type': 'subscription.traffic_reset',
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def notify_user_payment_received(
|
||||
user_id: int,
|
||||
amount_kopeks: int,
|
||||
payment_method: str = '',
|
||||
) -> None:
|
||||
"""Уведомить о полученном платеже."""
|
||||
await cabinet_ws_manager.send_to_user(
|
||||
user_id,
|
||||
{
|
||||
'type': 'payment.received',
|
||||
'amount_kopeks': amount_kopeks,
|
||||
'amount_rubles': amount_kopeks / 100,
|
||||
'payment_method': payment_method,
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,275 @@
|
||||
"""
|
||||
API роуты колеса удачи для пользователей.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import math
|
||||
import time
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.cabinet.dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
from app.cabinet.schemas.wheel import (
|
||||
SpinAvailabilityResponse,
|
||||
SpinHistoryItem,
|
||||
SpinHistoryResponse,
|
||||
SpinRequest,
|
||||
SpinResultResponse,
|
||||
WheelConfigResponse,
|
||||
WheelPrizeDisplay,
|
||||
)
|
||||
from app.config import settings
|
||||
from app.database.crud.wheel import (
|
||||
get_or_create_wheel_config,
|
||||
get_user_spin_history,
|
||||
get_user_spins_today,
|
||||
get_wheel_prizes,
|
||||
)
|
||||
from app.database.models import User
|
||||
from app.services.wheel_service import wheel_service
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/wheel', tags=['Fortune Wheel'])
|
||||
|
||||
|
||||
@router.get('/config', response_model=WheelConfigResponse)
|
||||
async def get_wheel_config(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Получить конфигурацию колеса удачи."""
|
||||
config = await get_or_create_wheel_config(db)
|
||||
prizes = await get_wheel_prizes(db, config.id, active_only=True)
|
||||
spins_today = await get_user_spins_today(db, user.id)
|
||||
|
||||
# Проверяем доступность
|
||||
availability = await wheel_service.check_availability(db, user)
|
||||
|
||||
prizes_display = [
|
||||
WheelPrizeDisplay(
|
||||
id=p.id,
|
||||
display_name=p.display_name,
|
||||
emoji=p.emoji,
|
||||
color=p.color,
|
||||
prize_type=p.prize_type,
|
||||
)
|
||||
for p in prizes
|
||||
]
|
||||
|
||||
return WheelConfigResponse(
|
||||
is_enabled=config.is_enabled,
|
||||
name=config.name,
|
||||
spin_cost_stars=config.spin_cost_stars if config.spin_cost_stars_enabled else None,
|
||||
spin_cost_days=config.spin_cost_days if config.spin_cost_days_enabled else None,
|
||||
spin_cost_stars_enabled=config.spin_cost_stars_enabled,
|
||||
spin_cost_days_enabled=config.spin_cost_days_enabled,
|
||||
prizes=prizes_display,
|
||||
daily_limit=config.daily_spin_limit,
|
||||
user_spins_today=spins_today,
|
||||
can_spin=availability.can_spin,
|
||||
can_spin_reason=availability.reason,
|
||||
can_pay_stars=availability.can_pay_stars,
|
||||
can_pay_days=availability.can_pay_days,
|
||||
user_balance_kopeks=availability.user_balance_kopeks,
|
||||
required_balance_kopeks=availability.required_balance_kopeks,
|
||||
)
|
||||
|
||||
|
||||
@router.get('/availability', response_model=SpinAvailabilityResponse)
|
||||
async def check_spin_availability(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Проверить доступность спина."""
|
||||
availability = await wheel_service.check_availability(db, user)
|
||||
|
||||
return SpinAvailabilityResponse(
|
||||
can_spin=availability.can_spin,
|
||||
reason=availability.reason,
|
||||
spins_remaining_today=availability.spins_remaining_today,
|
||||
can_pay_stars=availability.can_pay_stars,
|
||||
can_pay_days=availability.can_pay_days,
|
||||
min_subscription_days=availability.min_subscription_days,
|
||||
user_subscription_days=availability.user_subscription_days,
|
||||
user_balance_kopeks=availability.user_balance_kopeks,
|
||||
required_balance_kopeks=availability.required_balance_kopeks,
|
||||
)
|
||||
|
||||
|
||||
@router.post('/spin', response_model=SpinResultResponse)
|
||||
async def spin_wheel(
|
||||
request: SpinRequest,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Крутить колесо удачи."""
|
||||
result = await wheel_service.spin(db, user, request.payment_type.value)
|
||||
|
||||
if not result.success:
|
||||
# Возвращаем ошибку в теле ответа, а не HTTP exception
|
||||
return SpinResultResponse(
|
||||
success=False,
|
||||
error=result.error,
|
||||
message=result.message,
|
||||
)
|
||||
|
||||
return SpinResultResponse(
|
||||
success=True,
|
||||
prize_id=result.prize_id,
|
||||
prize_type=result.prize_type,
|
||||
prize_value=result.prize_value,
|
||||
prize_display_name=result.prize_display_name,
|
||||
emoji=result.emoji,
|
||||
color=result.color,
|
||||
rotation_degrees=result.rotation_degrees,
|
||||
message=result.message,
|
||||
promocode=result.promocode,
|
||||
)
|
||||
|
||||
|
||||
@router.get('/history', response_model=SpinHistoryResponse)
|
||||
async def get_spin_history(
|
||||
page: int = 1,
|
||||
per_page: int = 20,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Получить историю спинов пользователя."""
|
||||
page = max(page, 1)
|
||||
if per_page < 1 or per_page > 100:
|
||||
per_page = 20
|
||||
|
||||
offset = (page - 1) * per_page
|
||||
|
||||
spins, total = await get_user_spin_history(db, user.id, limit=per_page, offset=offset)
|
||||
|
||||
items = []
|
||||
for spin in spins:
|
||||
# Получаем emoji и color из приза, если он есть
|
||||
emoji = '🎁'
|
||||
color = '#3B82F6'
|
||||
if spin.prize:
|
||||
emoji = spin.prize.emoji
|
||||
color = spin.prize.color
|
||||
|
||||
items.append(
|
||||
SpinHistoryItem(
|
||||
id=spin.id,
|
||||
payment_type=spin.payment_type,
|
||||
payment_amount=spin.payment_amount,
|
||||
prize_type=spin.prize_type,
|
||||
prize_value=spin.prize_value,
|
||||
prize_display_name=spin.prize_display_name,
|
||||
emoji=emoji,
|
||||
color=color,
|
||||
prize_value_kopeks=spin.prize_value_kopeks,
|
||||
created_at=spin.created_at,
|
||||
)
|
||||
)
|
||||
|
||||
pages = math.ceil(total / per_page) if total > 0 else 1
|
||||
|
||||
return SpinHistoryResponse(
|
||||
items=items,
|
||||
total=total,
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
pages=pages,
|
||||
)
|
||||
|
||||
|
||||
class StarsInvoiceResponse(BaseModel):
|
||||
"""Ответ с ссылкой на Stars invoice."""
|
||||
|
||||
invoice_url: str
|
||||
stars_amount: int
|
||||
|
||||
|
||||
@router.post('/stars-invoice', response_model=StarsInvoiceResponse)
|
||||
async def create_stars_invoice(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""
|
||||
Создать Telegram Stars invoice для оплаты спина колеса.
|
||||
Используется в Telegram Mini App для прямой оплаты Stars.
|
||||
"""
|
||||
config = await get_or_create_wheel_config(db)
|
||||
|
||||
if not config.is_enabled:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Колесо удачи недоступно',
|
||||
)
|
||||
|
||||
if not config.spin_cost_stars_enabled or not config.spin_cost_stars:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Оплата Stars не включена',
|
||||
)
|
||||
|
||||
# Проверяем лимит спинов
|
||||
spins_today = await get_user_spins_today(db, user.id)
|
||||
if config.daily_spin_limit > 0 and spins_today >= config.daily_spin_limit:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Достигнут дневной лимит спинов',
|
||||
)
|
||||
|
||||
# Проверяем наличие призов
|
||||
prizes = await get_wheel_prizes(db, config.id, active_only=True)
|
||||
if not prizes:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Призы не настроены',
|
||||
)
|
||||
|
||||
stars_amount = config.spin_cost_stars
|
||||
payload = f'wheel_spin_{user.id}_{int(time.time())}'
|
||||
|
||||
# Создаем invoice через Telegram Bot API
|
||||
try:
|
||||
bot_token = settings.BOT_TOKEN
|
||||
api_url = f'https://api.telegram.org/bot{bot_token}/createInvoiceLink'
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
api_url,
|
||||
json={
|
||||
'title': 'Колесо удачи',
|
||||
'description': f'Спин колеса удачи ({stars_amount} ⭐)',
|
||||
'payload': payload,
|
||||
'provider_token': '', # Пустой для Stars
|
||||
'currency': 'XTR',
|
||||
'prices': [{'label': 'Спин колеса', 'amount': stars_amount}],
|
||||
},
|
||||
)
|
||||
|
||||
result = response.json()
|
||||
|
||||
if not result.get('ok'):
|
||||
logger.error(f'Telegram API error: {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}')
|
||||
|
||||
return StarsInvoiceResponse(
|
||||
invoice_url=invoice_url,
|
||||
stars_amount=stars_amount,
|
||||
)
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f'HTTP error creating invoice: {e}')
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Ошибка соединения с Telegram',
|
||||
)
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Cabinet Pydantic schemas."""
|
||||
|
||||
from .auth import (
|
||||
AuthResponse,
|
||||
EmailLoginRequest,
|
||||
EmailRegisterRequest,
|
||||
EmailVerifyRequest,
|
||||
PasswordForgotRequest,
|
||||
PasswordResetRequest,
|
||||
RefreshTokenRequest,
|
||||
TelegramAuthRequest,
|
||||
TelegramWidgetAuthRequest,
|
||||
TokenResponse,
|
||||
UserResponse,
|
||||
)
|
||||
from .balance import (
|
||||
BalanceResponse,
|
||||
PaymentMethodResponse,
|
||||
TopUpRequest,
|
||||
TopUpResponse,
|
||||
TransactionListResponse,
|
||||
TransactionResponse,
|
||||
)
|
||||
from .referral import (
|
||||
ReferralEarningResponse,
|
||||
ReferralInfoResponse,
|
||||
ReferralListResponse,
|
||||
ReferralTermsResponse,
|
||||
)
|
||||
from .subscription import (
|
||||
AutopayUpdateRequest,
|
||||
DevicePurchaseRequest,
|
||||
RenewalOptionResponse,
|
||||
RenewalRequest,
|
||||
SubscriptionResponse,
|
||||
TrafficPackageResponse,
|
||||
TrafficPurchaseInfo,
|
||||
TrafficPurchaseRequest,
|
||||
)
|
||||
from .tickets import (
|
||||
TicketCreateRequest,
|
||||
TicketListResponse,
|
||||
TicketMessageCreateRequest,
|
||||
TicketMessageResponse,
|
||||
TicketResponse,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
'AuthResponse',
|
||||
'AutopayUpdateRequest',
|
||||
# Balance
|
||||
'BalanceResponse',
|
||||
'DevicePurchaseRequest',
|
||||
'EmailLoginRequest',
|
||||
'EmailRegisterRequest',
|
||||
'EmailVerifyRequest',
|
||||
'PasswordForgotRequest',
|
||||
'PasswordResetRequest',
|
||||
'PaymentMethodResponse',
|
||||
'ReferralEarningResponse',
|
||||
# Referral
|
||||
'ReferralInfoResponse',
|
||||
'ReferralListResponse',
|
||||
'ReferralTermsResponse',
|
||||
'RefreshTokenRequest',
|
||||
'RenewalOptionResponse',
|
||||
'RenewalRequest',
|
||||
# Subscription
|
||||
'SubscriptionResponse',
|
||||
# Auth
|
||||
'TelegramAuthRequest',
|
||||
'TelegramWidgetAuthRequest',
|
||||
'TicketCreateRequest',
|
||||
'TicketListResponse',
|
||||
'TicketMessageCreateRequest',
|
||||
'TicketMessageResponse',
|
||||
# Tickets
|
||||
'TicketResponse',
|
||||
'TokenResponse',
|
||||
'TopUpRequest',
|
||||
'TopUpResponse',
|
||||
'TrafficPackageResponse',
|
||||
'TrafficPurchaseInfo',
|
||||
'TrafficPurchaseRequest',
|
||||
'TransactionListResponse',
|
||||
'TransactionResponse',
|
||||
'UserResponse',
|
||||
]
|
||||
@@ -0,0 +1,140 @@
|
||||
"""Authentication schemas for cabinet."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
|
||||
|
||||
class TelegramAuthRequest(BaseModel):
|
||||
"""Request for Telegram WebApp initData authentication."""
|
||||
|
||||
init_data: str = Field(..., description='Telegram WebApp initData string')
|
||||
|
||||
|
||||
class TelegramWidgetAuthRequest(BaseModel):
|
||||
"""Request for Telegram Login Widget authentication."""
|
||||
|
||||
id: int = Field(..., description='Telegram user ID')
|
||||
first_name: str = Field(..., description="User's first name")
|
||||
last_name: str | None = Field(None, description="User's last name")
|
||||
username: str | None = Field(None, description="User's username")
|
||||
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')
|
||||
|
||||
|
||||
class EmailRegisterRequest(BaseModel):
|
||||
"""Request to register/link email to existing Telegram account."""
|
||||
|
||||
email: EmailStr = Field(..., description='Email address')
|
||||
password: str = Field(..., min_length=8, max_length=128, description='Password (min 8 chars)')
|
||||
|
||||
|
||||
class EmailVerifyRequest(BaseModel):
|
||||
"""Request to verify email with token."""
|
||||
|
||||
token: str = Field(..., description='Email verification token')
|
||||
|
||||
|
||||
class EmailLoginRequest(BaseModel):
|
||||
"""Request to login with email and password."""
|
||||
|
||||
email: EmailStr = Field(..., description='Email address')
|
||||
password: str = Field(..., description='Password')
|
||||
|
||||
|
||||
class RefreshTokenRequest(BaseModel):
|
||||
"""Request to refresh access token."""
|
||||
|
||||
refresh_token: str = Field(..., description='Refresh token')
|
||||
|
||||
|
||||
class PasswordForgotRequest(BaseModel):
|
||||
"""Request to initiate password reset."""
|
||||
|
||||
email: EmailStr = Field(..., description='Email address')
|
||||
|
||||
|
||||
class PasswordResetRequest(BaseModel):
|
||||
"""Request to reset password with token."""
|
||||
|
||||
token: str = Field(..., description='Password reset token')
|
||||
password: str = Field(..., min_length=8, max_length=128, description='New password (min 8 chars)')
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
"""Token pair response."""
|
||||
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str = 'bearer'
|
||||
expires_in: int = Field(..., description='Access token expiration in seconds')
|
||||
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
"""User data response."""
|
||||
|
||||
id: int
|
||||
telegram_id: int | None = None # Nullable для email-only пользователей
|
||||
username: str | None = None
|
||||
first_name: str | None = None
|
||||
last_name: str | None = None
|
||||
email: str | None = None
|
||||
email_verified: bool = False
|
||||
balance_kopeks: int = 0
|
||||
balance_rubles: float = 0.0
|
||||
referral_code: str | None = None
|
||||
language: str = 'ru'
|
||||
created_at: datetime
|
||||
auth_type: str = 'telegram' # "telegram" или "email"
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class EmailRegisterStandaloneRequest(BaseModel):
|
||||
"""Request to register new account with email (no Telegram required)."""
|
||||
|
||||
email: EmailStr = Field(..., description='Email address')
|
||||
password: str = Field(..., min_length=8, max_length=128, description='Password (min 8 chars)')
|
||||
first_name: str | None = Field(None, max_length=64, description='First name')
|
||||
language: str = Field('ru', description='Preferred language')
|
||||
referral_code: str | None = Field(None, max_length=32, description='Referral code of inviter')
|
||||
|
||||
|
||||
class AuthResponse(BaseModel):
|
||||
"""Full authentication response with tokens and user."""
|
||||
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str = 'bearer'
|
||||
expires_in: int
|
||||
user: UserResponse
|
||||
|
||||
|
||||
class RegisterResponse(BaseModel):
|
||||
"""Response for email registration (before verification)."""
|
||||
|
||||
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')
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Balance and payment schemas for cabinet."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class BalanceResponse(BaseModel):
|
||||
"""User balance data."""
|
||||
|
||||
balance_kopeks: int
|
||||
balance_rubles: float
|
||||
|
||||
|
||||
class TransactionResponse(BaseModel):
|
||||
"""Transaction history item."""
|
||||
|
||||
id: int
|
||||
type: str
|
||||
amount_kopeks: int
|
||||
amount_rubles: float
|
||||
description: str | None = None
|
||||
payment_method: str | None = None
|
||||
is_completed: bool
|
||||
created_at: datetime
|
||||
completed_at: datetime | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class TransactionListResponse(BaseModel):
|
||||
"""Paginated transaction list."""
|
||||
|
||||
items: list[TransactionResponse]
|
||||
total: int
|
||||
page: int
|
||||
per_page: int
|
||||
pages: int
|
||||
|
||||
|
||||
class PaymentOptionResponse(BaseModel):
|
||||
"""Payment method option (e.g. Platega sub-methods)."""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
description: str | None = None
|
||||
|
||||
|
||||
class PaymentMethodResponse(BaseModel):
|
||||
"""Available payment method."""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
description: str | None = None
|
||||
min_amount_kopeks: int
|
||||
max_amount_kopeks: int
|
||||
is_available: bool = True
|
||||
options: list[dict[str, Any]] | None = None
|
||||
|
||||
|
||||
class TopUpRequest(BaseModel):
|
||||
"""Request to create payment for balance top-up."""
|
||||
|
||||
amount_kopeks: int = Field(..., ge=1000, description='Amount in kopeks (min 10 rubles)')
|
||||
payment_method: str = Field(..., description='Payment method ID')
|
||||
payment_option: str | None = Field(None, description='Payment option (e.g. Platega method code)')
|
||||
|
||||
|
||||
class TopUpResponse(BaseModel):
|
||||
"""Response with payment info."""
|
||||
|
||||
payment_id: str
|
||||
payment_url: str
|
||||
amount_kopeks: int
|
||||
amount_rubles: float
|
||||
status: str
|
||||
expires_at: datetime | None = None
|
||||
|
||||
|
||||
class StarsInvoiceRequest(BaseModel):
|
||||
"""Request to create Telegram Stars invoice for balance top-up."""
|
||||
|
||||
amount_kopeks: int = Field(..., ge=100, description='Amount in kopeks (min 1 ruble)')
|
||||
|
||||
|
||||
class StarsInvoiceResponse(BaseModel):
|
||||
"""Response with Telegram Stars invoice link."""
|
||||
|
||||
invoice_url: str
|
||||
stars_amount: int
|
||||
amount_kopeks: int
|
||||
|
||||
|
||||
class PendingPaymentResponse(BaseModel):
|
||||
"""Pending payment details for manual verification."""
|
||||
|
||||
id: int
|
||||
method: str
|
||||
method_display: str
|
||||
identifier: str
|
||||
amount_kopeks: int
|
||||
amount_rubles: float
|
||||
status: str
|
||||
status_emoji: str
|
||||
status_text: str
|
||||
is_paid: bool
|
||||
is_checkable: bool
|
||||
created_at: datetime
|
||||
expires_at: datetime | None = None
|
||||
payment_url: str | None = None
|
||||
user_id: int | None = None
|
||||
user_telegram_id: int | None = None
|
||||
user_username: str | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class PendingPaymentListResponse(BaseModel):
|
||||
"""Paginated list of pending payments."""
|
||||
|
||||
items: list[PendingPaymentResponse]
|
||||
total: int
|
||||
page: int
|
||||
per_page: int
|
||||
pages: int
|
||||
|
||||
|
||||
class ManualCheckResponse(BaseModel):
|
||||
"""Response after manual payment status check."""
|
||||
|
||||
success: bool
|
||||
message: str
|
||||
payment: PendingPaymentResponse | None = None
|
||||
status_changed: bool = False
|
||||
old_status: str | None = None
|
||||
new_status: str | None = None
|
||||
@@ -0,0 +1,386 @@
|
||||
"""Schemas for Ban System integration in cabinet."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# === Status ===
|
||||
|
||||
|
||||
class BanSystemStatusResponse(BaseModel):
|
||||
"""Ban System integration status."""
|
||||
|
||||
enabled: bool
|
||||
configured: bool
|
||||
|
||||
|
||||
# === Stats ===
|
||||
|
||||
|
||||
class BanSystemStatsResponse(BaseModel):
|
||||
"""Overall Ban System statistics."""
|
||||
|
||||
total_users: int = 0
|
||||
active_users: int = 0
|
||||
users_over_limit: int = 0
|
||||
total_requests: int = 0
|
||||
total_punishments: int = 0
|
||||
active_punishments: int = 0
|
||||
nodes_online: int = 0
|
||||
nodes_total: int = 0
|
||||
agents_online: int = 0
|
||||
agents_total: int = 0
|
||||
panel_connected: bool = False
|
||||
uptime_seconds: int | None = None
|
||||
|
||||
|
||||
# === Users ===
|
||||
|
||||
|
||||
class BanUserIPInfo(BaseModel):
|
||||
"""User IP address information."""
|
||||
|
||||
ip: str
|
||||
first_seen: datetime | None = None
|
||||
last_seen: datetime | None = None
|
||||
node: str | None = None
|
||||
request_count: int = 0
|
||||
country_code: str | None = None
|
||||
country_name: str | None = None
|
||||
city: str | None = None
|
||||
|
||||
|
||||
class BanUserRequestLog(BaseModel):
|
||||
"""User request log entry."""
|
||||
|
||||
timestamp: datetime
|
||||
source_ip: str
|
||||
destination: str | None = None
|
||||
dest_port: int | None = None
|
||||
protocol: str | None = None
|
||||
action: str | None = None
|
||||
node: str | None = None
|
||||
|
||||
|
||||
class BanUserListItem(BaseModel):
|
||||
"""User in the list."""
|
||||
|
||||
email: str
|
||||
unique_ip_count: int = 0
|
||||
total_requests: int = 0
|
||||
limit: int | None = None
|
||||
is_over_limit: bool = False
|
||||
blocked_count: int = 0
|
||||
last_seen: datetime | None = None
|
||||
|
||||
|
||||
class BanUsersListResponse(BaseModel):
|
||||
"""Paginated list of users."""
|
||||
|
||||
users: list[BanUserListItem] = []
|
||||
total: int = 0
|
||||
offset: int = 0
|
||||
limit: int = 50
|
||||
|
||||
|
||||
class BanUserDetailResponse(BaseModel):
|
||||
"""Detailed user information."""
|
||||
|
||||
email: str
|
||||
unique_ip_count: int = 0
|
||||
total_requests: int = 0
|
||||
limit: int | None = None
|
||||
is_over_limit: bool = False
|
||||
blocked_count: int = 0
|
||||
ips: list[BanUserIPInfo] = []
|
||||
recent_requests: list[BanUserRequestLog] = []
|
||||
network_type: str | None = None # wifi, mobile, mixed
|
||||
|
||||
|
||||
# === Punishments (Bans) ===
|
||||
|
||||
|
||||
class BanPunishmentItem(BaseModel):
|
||||
"""Punishment/ban entry."""
|
||||
|
||||
id: int | None = None
|
||||
user_id: str
|
||||
uuid: str | None = None
|
||||
username: str
|
||||
reason: str | None = None
|
||||
punished_at: datetime
|
||||
enable_at: datetime | None = None
|
||||
ip_count: int = 0
|
||||
limit: int = 0
|
||||
enabled: bool = False
|
||||
enabled_at: datetime | None = None
|
||||
node_name: str | None = None
|
||||
|
||||
|
||||
class BanPunishmentsListResponse(BaseModel):
|
||||
"""List of active punishments."""
|
||||
|
||||
punishments: list[BanPunishmentItem] = []
|
||||
total: int = 0
|
||||
|
||||
|
||||
class BanHistoryResponse(BaseModel):
|
||||
"""Punishment history."""
|
||||
|
||||
items: list[BanPunishmentItem] = []
|
||||
total: int = 0
|
||||
|
||||
|
||||
class BanUserRequest(BaseModel):
|
||||
"""Request to ban a user."""
|
||||
|
||||
username: str = Field(..., min_length=1)
|
||||
minutes: int = Field(default=30, ge=1)
|
||||
reason: str | None = Field(None, max_length=500)
|
||||
|
||||
|
||||
class UnbanResponse(BaseModel):
|
||||
"""Unban response."""
|
||||
|
||||
success: bool
|
||||
message: str
|
||||
|
||||
|
||||
# === Nodes ===
|
||||
|
||||
|
||||
class BanNodeItem(BaseModel):
|
||||
"""Node information."""
|
||||
|
||||
name: str
|
||||
address: str | None = None
|
||||
is_connected: bool = False
|
||||
last_seen: datetime | None = None
|
||||
users_count: int = 0
|
||||
agent_stats: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class BanNodesListResponse(BaseModel):
|
||||
"""List of nodes."""
|
||||
|
||||
nodes: list[BanNodeItem] = []
|
||||
total: int = 0
|
||||
online: int = 0
|
||||
|
||||
|
||||
# === Agents ===
|
||||
|
||||
|
||||
class BanAgentItem(BaseModel):
|
||||
"""Monitoring agent information."""
|
||||
|
||||
node_name: str
|
||||
sent_total: int = 0
|
||||
dropped_total: int = 0
|
||||
batches_total: int = 0
|
||||
reconnects: int = 0
|
||||
failures: int = 0
|
||||
queue_size: int = 0
|
||||
queue_max: int = 0
|
||||
dedup_checked: int = 0
|
||||
dedup_skipped: int = 0
|
||||
filter_checked: int = 0
|
||||
filter_filtered: int = 0
|
||||
health: str = 'unknown' # healthy, warning, critical
|
||||
is_online: bool = False
|
||||
last_report: datetime | None = None
|
||||
|
||||
|
||||
class BanAgentsSummary(BaseModel):
|
||||
"""Agents summary statistics."""
|
||||
|
||||
total_agents: int = 0
|
||||
online_agents: int = 0
|
||||
total_sent: int = 0
|
||||
total_dropped: int = 0
|
||||
avg_queue_size: float = 0.0
|
||||
healthy_count: int = 0
|
||||
warning_count: int = 0
|
||||
critical_count: int = 0
|
||||
|
||||
|
||||
class BanAgentsListResponse(BaseModel):
|
||||
"""List of agents."""
|
||||
|
||||
agents: list[BanAgentItem] = []
|
||||
summary: BanAgentsSummary | None = None
|
||||
total: int = 0
|
||||
online: int = 0
|
||||
|
||||
|
||||
# === Traffic ===
|
||||
|
||||
|
||||
class BanTrafficStats(BaseModel):
|
||||
"""Traffic statistics."""
|
||||
|
||||
total_bytes: int = 0
|
||||
upload_bytes: int = 0
|
||||
download_bytes: int = 0
|
||||
total_users: int = 0
|
||||
violators_count: int = 0
|
||||
|
||||
|
||||
class BanTrafficUserItem(BaseModel):
|
||||
"""User traffic information."""
|
||||
|
||||
username: str
|
||||
email: str | None = None
|
||||
total_bytes: int = 0
|
||||
upload_bytes: int = 0
|
||||
download_bytes: int = 0
|
||||
limit_bytes: int | None = None
|
||||
is_over_limit: bool = False
|
||||
|
||||
|
||||
class BanTrafficViolationItem(BaseModel):
|
||||
"""Traffic limit violation entry."""
|
||||
|
||||
id: int | None = None
|
||||
username: str
|
||||
email: str | None = None
|
||||
violation_type: str
|
||||
description: str | None = None
|
||||
bytes_used: int = 0
|
||||
bytes_limit: int = 0
|
||||
detected_at: datetime
|
||||
resolved: bool = False
|
||||
|
||||
|
||||
class BanTrafficViolationsResponse(BaseModel):
|
||||
"""List of traffic violations."""
|
||||
|
||||
violations: list[BanTrafficViolationItem] = []
|
||||
total: int = 0
|
||||
|
||||
|
||||
class BanTrafficTopItem(BaseModel):
|
||||
"""Top user by traffic."""
|
||||
|
||||
username: str
|
||||
bytes_total: int = 0
|
||||
bytes_limit: int | None = None
|
||||
over_limit: bool = False
|
||||
|
||||
|
||||
class BanTrafficResponse(BaseModel):
|
||||
"""Full traffic statistics response."""
|
||||
|
||||
enabled: bool = False
|
||||
stats: dict[str, Any] | None = None
|
||||
top_users: list[BanTrafficTopItem] = []
|
||||
recent_violations: list[BanTrafficViolationItem] = []
|
||||
|
||||
|
||||
# === Settings ===
|
||||
|
||||
|
||||
class BanSettingDefinition(BaseModel):
|
||||
"""Setting definition with value."""
|
||||
|
||||
key: str
|
||||
value: Any
|
||||
type: str # bool, int, str, list
|
||||
min_value: int | None = None
|
||||
max_value: int | None = None
|
||||
editable: bool = True
|
||||
description: str | None = None
|
||||
category: str | None = None
|
||||
|
||||
|
||||
class BanSettingsResponse(BaseModel):
|
||||
"""All settings response."""
|
||||
|
||||
settings: list[BanSettingDefinition] = []
|
||||
|
||||
|
||||
class BanSettingUpdateRequest(BaseModel):
|
||||
"""Request to update a setting."""
|
||||
|
||||
value: Any
|
||||
|
||||
|
||||
class BanWhitelistRequest(BaseModel):
|
||||
"""Request to add/remove from whitelist."""
|
||||
|
||||
username: str = Field(..., min_length=1)
|
||||
|
||||
|
||||
# === Reports ===
|
||||
|
||||
|
||||
class BanReportTopViolator(BaseModel):
|
||||
"""Top violator in report."""
|
||||
|
||||
username: str
|
||||
count: int = 0
|
||||
|
||||
|
||||
class BanReportResponse(BaseModel):
|
||||
"""Period report response."""
|
||||
|
||||
period_hours: int = 24
|
||||
current_users: int = 0
|
||||
current_ips: int = 0
|
||||
punishment_stats: dict[str, Any] | None = None
|
||||
top_violators: list[BanReportTopViolator] = []
|
||||
|
||||
|
||||
# === Health ===
|
||||
|
||||
|
||||
class BanHealthComponent(BaseModel):
|
||||
"""Health component status."""
|
||||
|
||||
name: str
|
||||
status: str # healthy, degraded, unhealthy
|
||||
message: str | None = None
|
||||
details: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class BanHealthResponse(BaseModel):
|
||||
"""Health status response."""
|
||||
|
||||
status: str # healthy, degraded, unhealthy
|
||||
uptime: int | None = None
|
||||
components: list[BanHealthComponent] = []
|
||||
|
||||
|
||||
class BanHealthDetailedResponse(BaseModel):
|
||||
"""Detailed health response."""
|
||||
|
||||
status: str
|
||||
uptime: int | None = None
|
||||
components: dict[str, Any] = {}
|
||||
|
||||
|
||||
# === Agent History ===
|
||||
|
||||
|
||||
class BanAgentHistoryItem(BaseModel):
|
||||
"""Agent history item."""
|
||||
|
||||
timestamp: datetime
|
||||
sent_total: int = 0
|
||||
dropped_total: int = 0
|
||||
queue_size: int = 0
|
||||
batches_total: int = 0
|
||||
|
||||
|
||||
class BanAgentHistoryResponse(BaseModel):
|
||||
"""Agent history response."""
|
||||
|
||||
node: str
|
||||
hours: int = 24
|
||||
records: int = 0
|
||||
delta: dict[str, Any] | None = None
|
||||
first: dict[str, Any] | None = None
|
||||
last: dict[str, Any] | None = None
|
||||
history: list[BanAgentHistoryItem] = []
|
||||
@@ -0,0 +1,209 @@
|
||||
"""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 ============
|
||||
|
||||
|
||||
class BroadcastFilter(BaseModel):
|
||||
"""Single broadcast filter."""
|
||||
|
||||
key: str
|
||||
label: str
|
||||
count: int | None = None
|
||||
group: str | None = None # basic, subscription, traffic, registration, source, activity
|
||||
|
||||
|
||||
class TariffFilter(BaseModel):
|
||||
"""Tariff-based filter."""
|
||||
|
||||
key: str # tariff_1, tariff_2, ...
|
||||
label: str # tariff name
|
||||
tariff_id: int
|
||||
count: int
|
||||
|
||||
|
||||
class BroadcastFiltersResponse(BaseModel):
|
||||
"""Response with all available filters."""
|
||||
|
||||
filters: list[BroadcastFilter] # basic filters
|
||||
tariff_filters: list[TariffFilter] # tariff filters
|
||||
custom_filters: list[BroadcastFilter] # custom filters
|
||||
|
||||
|
||||
# ============ Tariffs ============
|
||||
|
||||
|
||||
class TariffForBroadcast(BaseModel):
|
||||
"""Tariff info for broadcast filtering."""
|
||||
|
||||
id: int
|
||||
name: str
|
||||
filter_key: str # tariff_{id}
|
||||
active_users_count: int
|
||||
|
||||
|
||||
class BroadcastTariffsResponse(BaseModel):
|
||||
"""Response with tariffs for filtering."""
|
||||
|
||||
tariffs: list[TariffForBroadcast]
|
||||
|
||||
|
||||
# ============ Buttons ============
|
||||
|
||||
|
||||
class BroadcastButton(BaseModel):
|
||||
"""Single broadcast button."""
|
||||
|
||||
key: str
|
||||
label: str
|
||||
default: bool = False
|
||||
|
||||
|
||||
class BroadcastButtonsResponse(BaseModel):
|
||||
"""Response with available buttons."""
|
||||
|
||||
buttons: list[BroadcastButton]
|
||||
|
||||
|
||||
# ============ Media ============
|
||||
|
||||
|
||||
class BroadcastMediaRequest(BaseModel):
|
||||
"""Media attachment for broadcast."""
|
||||
|
||||
type: str = Field(..., pattern=r'^(photo|video|document)$')
|
||||
file_id: str
|
||||
caption: str | None = None
|
||||
|
||||
|
||||
# ============ Create ============
|
||||
|
||||
|
||||
class BroadcastCreateRequest(BaseModel):
|
||||
"""Request to create a broadcast."""
|
||||
|
||||
target: str
|
||||
message_text: str = Field(..., min_length=1, max_length=4000)
|
||||
selected_buttons: list[str] = Field(default_factory=lambda: ['home'])
|
||||
media: BroadcastMediaRequest | None = None
|
||||
|
||||
|
||||
# ============ Response ============
|
||||
|
||||
|
||||
class BroadcastResponse(BaseModel):
|
||||
"""Broadcast response."""
|
||||
|
||||
id: int
|
||||
target_type: str
|
||||
message_text: str | None = None
|
||||
has_media: bool
|
||||
media_type: str | None = None
|
||||
media_file_id: str | None = None
|
||||
media_caption: str | None = None
|
||||
total_count: int
|
||||
sent_count: int
|
||||
failed_count: int
|
||||
status: str # queued|in_progress|completed|partial|failed|cancelled|cancelling
|
||||
admin_id: int | None = None
|
||||
admin_name: str | None = None
|
||||
created_at: datetime
|
||||
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
|
||||
|
||||
|
||||
class BroadcastListResponse(BaseModel):
|
||||
"""Paginated list of broadcasts."""
|
||||
|
||||
items: list[BroadcastResponse]
|
||||
total: int
|
||||
limit: int
|
||||
offset: int
|
||||
|
||||
|
||||
# ============ Preview ============
|
||||
|
||||
|
||||
class BroadcastPreviewRequest(BaseModel):
|
||||
"""Request to preview broadcast recipients count."""
|
||||
|
||||
target: str
|
||||
|
||||
|
||||
class BroadcastPreviewResponse(BaseModel):
|
||||
"""Preview response with recipients count."""
|
||||
|
||||
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
|
||||
@@ -0,0 +1,203 @@
|
||||
"""Schemas for advertising campaigns management in cabinet."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
CampaignBonusType = Literal['balance', 'subscription', 'none', 'tariff']
|
||||
|
||||
|
||||
class TariffInfo(BaseModel):
|
||||
"""Tariff info for campaign."""
|
||||
|
||||
id: int
|
||||
name: str
|
||||
|
||||
|
||||
class CampaignListItem(BaseModel):
|
||||
"""Campaign item for list view."""
|
||||
|
||||
id: int
|
||||
name: str
|
||||
start_parameter: str
|
||||
bonus_type: CampaignBonusType
|
||||
is_active: bool
|
||||
registrations_count: int
|
||||
total_revenue_kopeks: int = 0
|
||||
conversion_rate: float = 0.0
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class CampaignListResponse(BaseModel):
|
||||
"""Response with list of campaigns."""
|
||||
|
||||
campaigns: list[CampaignListItem]
|
||||
total: int
|
||||
|
||||
|
||||
class CampaignDetailResponse(BaseModel):
|
||||
"""Detailed campaign response."""
|
||||
|
||||
id: int
|
||||
name: str
|
||||
start_parameter: str
|
||||
bonus_type: CampaignBonusType
|
||||
is_active: bool
|
||||
# Balance bonus
|
||||
balance_bonus_kopeks: int = 0
|
||||
balance_bonus_rubles: float = 0.0
|
||||
# Subscription bonus
|
||||
subscription_duration_days: int | None = None
|
||||
subscription_traffic_gb: int | None = None
|
||||
subscription_device_limit: int | None = None
|
||||
subscription_squads: list[str] = Field(default_factory=list)
|
||||
# Tariff bonus
|
||||
tariff_id: int | None = None
|
||||
tariff_duration_days: int | None = None
|
||||
tariff: TariffInfo | None = None
|
||||
# Meta
|
||||
created_by: int | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime | None = None
|
||||
# Deep link
|
||||
deep_link: str | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
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_-]+$')
|
||||
bonus_type: CampaignBonusType
|
||||
is_active: bool = True
|
||||
# Balance bonus
|
||||
balance_bonus_kopeks: int = Field(0, ge=0)
|
||||
# Subscription bonus
|
||||
subscription_duration_days: int | None = Field(None, ge=1)
|
||||
subscription_traffic_gb: int | None = Field(None, ge=0)
|
||||
subscription_device_limit: int | None = Field(None, ge=1)
|
||||
subscription_squads: list[str] = Field(default_factory=list)
|
||||
# Tariff bonus
|
||||
tariff_id: int | None = None
|
||||
tariff_duration_days: int | None = Field(None, ge=1)
|
||||
|
||||
|
||||
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_-]+$')
|
||||
bonus_type: CampaignBonusType | None = None
|
||||
is_active: bool | None = None
|
||||
# Balance bonus
|
||||
balance_bonus_kopeks: int | None = Field(None, ge=0)
|
||||
# Subscription bonus
|
||||
subscription_duration_days: int | None = Field(None, ge=1)
|
||||
subscription_traffic_gb: int | None = Field(None, ge=0)
|
||||
subscription_device_limit: int | None = Field(None, ge=1)
|
||||
subscription_squads: list[str] | None = None
|
||||
# Tariff bonus
|
||||
tariff_id: int | None = None
|
||||
tariff_duration_days: int | None = Field(None, ge=1)
|
||||
|
||||
|
||||
class CampaignToggleResponse(BaseModel):
|
||||
"""Response after toggling campaign."""
|
||||
|
||||
id: int
|
||||
is_active: bool
|
||||
message: str
|
||||
|
||||
|
||||
class CampaignStatisticsResponse(BaseModel):
|
||||
"""Detailed campaign statistics."""
|
||||
|
||||
id: int
|
||||
name: str
|
||||
start_parameter: str
|
||||
bonus_type: CampaignBonusType
|
||||
is_active: bool
|
||||
# Registration stats
|
||||
registrations: int = 0
|
||||
balance_issued_kopeks: int = 0
|
||||
balance_issued_rubles: float = 0.0
|
||||
subscription_issued: int = 0
|
||||
last_registration: datetime | None = None
|
||||
# Revenue stats
|
||||
total_revenue_kopeks: int = 0
|
||||
total_revenue_rubles: float = 0.0
|
||||
avg_revenue_per_user_kopeks: int = 0
|
||||
avg_revenue_per_user_rubles: float = 0.0
|
||||
avg_first_payment_kopeks: int = 0
|
||||
avg_first_payment_rubles: float = 0.0
|
||||
# Trial & Conversion stats
|
||||
trial_users_count: int = 0
|
||||
active_trials_count: int = 0
|
||||
conversion_count: int = 0
|
||||
paid_users_count: int = 0
|
||||
conversion_rate: float = 0.0
|
||||
trial_conversion_rate: float = 0.0
|
||||
# Deep link
|
||||
deep_link: str | None = None
|
||||
|
||||
|
||||
class CampaignRegistrationItem(BaseModel):
|
||||
"""Campaign registration item."""
|
||||
|
||||
id: int
|
||||
user_id: int
|
||||
telegram_id: int | None = None
|
||||
username: str | None = None
|
||||
first_name: str | None = None
|
||||
bonus_type: str
|
||||
balance_bonus_kopeks: int = 0
|
||||
subscription_duration_days: int | None = None
|
||||
tariff_id: int | None = None
|
||||
tariff_duration_days: int | None = None
|
||||
created_at: datetime
|
||||
# User stats
|
||||
user_balance_kopeks: int = 0
|
||||
has_subscription: bool = False
|
||||
has_paid: bool = False
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class CampaignRegistrationsResponse(BaseModel):
|
||||
"""Response with campaign registrations."""
|
||||
|
||||
registrations: list[CampaignRegistrationItem]
|
||||
total: int
|
||||
page: int
|
||||
per_page: int
|
||||
|
||||
|
||||
class CampaignsOverviewResponse(BaseModel):
|
||||
"""Overview of all campaigns."""
|
||||
|
||||
total: int
|
||||
active: int
|
||||
inactive: int
|
||||
total_registrations: int
|
||||
total_balance_issued_kopeks: int
|
||||
total_balance_issued_rubles: float
|
||||
total_subscription_issued: int
|
||||
total_tariff_issued: int = 0
|
||||
|
||||
|
||||
class ServerSquadInfo(BaseModel):
|
||||
"""Server squad info for campaign selection."""
|
||||
|
||||
id: int
|
||||
squad_uuid: str
|
||||
display_name: str
|
||||
country_code: str | None = None
|
||||
@@ -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
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Referral program schemas for cabinet."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class ReferralInfoResponse(BaseModel):
|
||||
"""Referral program info for current user."""
|
||||
|
||||
referral_code: str
|
||||
referral_link: str
|
||||
total_referrals: int
|
||||
active_referrals: int
|
||||
total_earnings_kopeks: int
|
||||
total_earnings_rubles: float
|
||||
commission_percent: int
|
||||
|
||||
|
||||
class ReferralItemResponse(BaseModel):
|
||||
"""Single referral info."""
|
||||
|
||||
id: int
|
||||
username: str | None = None
|
||||
first_name: str | None = None
|
||||
created_at: datetime
|
||||
has_subscription: bool
|
||||
has_paid: bool
|
||||
|
||||
|
||||
class ReferralListResponse(BaseModel):
|
||||
"""Paginated referral list."""
|
||||
|
||||
items: list[ReferralItemResponse]
|
||||
total: int
|
||||
page: int
|
||||
per_page: int
|
||||
pages: int
|
||||
|
||||
|
||||
class ReferralEarningResponse(BaseModel):
|
||||
"""Referral earning history item."""
|
||||
|
||||
id: int
|
||||
amount_kopeks: int
|
||||
amount_rubles: float
|
||||
reason: str
|
||||
referral_username: str | None = None
|
||||
referral_first_name: str | None = None
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ReferralEarningsListResponse(BaseModel):
|
||||
"""Paginated referral earnings list."""
|
||||
|
||||
items: list[ReferralEarningResponse]
|
||||
total: int
|
||||
total_amount_kopeks: int
|
||||
total_amount_rubles: float
|
||||
page: int
|
||||
per_page: int
|
||||
pages: int
|
||||
|
||||
|
||||
class ReferralTermsResponse(BaseModel):
|
||||
"""Referral program terms."""
|
||||
|
||||
is_enabled: bool
|
||||
commission_percent: int
|
||||
minimum_topup_kopeks: int
|
||||
minimum_topup_rubles: float
|
||||
first_topup_bonus_kopeks: int
|
||||
first_topup_bonus_rubles: float
|
||||
inviter_bonus_kopeks: int
|
||||
inviter_bonus_rubles: float
|
||||
@@ -0,0 +1,396 @@
|
||||
"""Schemas for RemnaWave management in cabinet admin panel."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# ============ Status & Connection ============
|
||||
|
||||
|
||||
class ConnectionStatus(BaseModel):
|
||||
"""RemnaWave API connection status."""
|
||||
|
||||
status: str
|
||||
message: str
|
||||
api_url: str | None = None
|
||||
status_code: int | None = None
|
||||
system_info: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class RemnaWaveStatusResponse(BaseModel):
|
||||
"""RemnaWave configuration and connection status."""
|
||||
|
||||
is_configured: bool
|
||||
configuration_error: str | None = None
|
||||
connection: ConnectionStatus | None = None
|
||||
|
||||
|
||||
# ============ System Statistics ============
|
||||
|
||||
|
||||
class SystemSummary(BaseModel):
|
||||
"""System summary statistics."""
|
||||
|
||||
users_online: int
|
||||
total_users: int
|
||||
active_connections: int
|
||||
nodes_online: int
|
||||
users_last_day: int
|
||||
users_last_week: int
|
||||
users_never_online: int
|
||||
total_user_traffic: int
|
||||
|
||||
|
||||
class ServerInfo(BaseModel):
|
||||
"""Server hardware info."""
|
||||
|
||||
cpu_cores: int
|
||||
cpu_physical_cores: int
|
||||
memory_total: int
|
||||
memory_used: int
|
||||
memory_free: int
|
||||
memory_available: int
|
||||
uptime_seconds: int
|
||||
|
||||
|
||||
class Bandwidth(BaseModel):
|
||||
"""Realtime bandwidth statistics."""
|
||||
|
||||
realtime_download: int
|
||||
realtime_upload: int
|
||||
realtime_total: int
|
||||
|
||||
|
||||
class TrafficPeriod(BaseModel):
|
||||
"""Traffic statistics for a period."""
|
||||
|
||||
current: int
|
||||
previous: int
|
||||
difference: str | None = None
|
||||
|
||||
|
||||
class TrafficPeriods(BaseModel):
|
||||
"""Traffic statistics for multiple periods."""
|
||||
|
||||
last_2_days: TrafficPeriod
|
||||
last_7_days: TrafficPeriod
|
||||
last_30_days: TrafficPeriod
|
||||
current_month: TrafficPeriod
|
||||
current_year: TrafficPeriod
|
||||
|
||||
|
||||
class SystemStatsResponse(BaseModel):
|
||||
"""Full system statistics response."""
|
||||
|
||||
system: SystemSummary
|
||||
users_by_status: dict[str, int]
|
||||
server_info: ServerInfo
|
||||
bandwidth: Bandwidth
|
||||
traffic_periods: TrafficPeriods
|
||||
nodes_realtime: list[dict[str, Any]] = Field(default_factory=list)
|
||||
nodes_weekly: list[dict[str, Any]] = Field(default_factory=list)
|
||||
last_updated: datetime | None = None
|
||||
|
||||
|
||||
# ============ Nodes ============
|
||||
|
||||
|
||||
class NodeInfo(BaseModel):
|
||||
"""Node information."""
|
||||
|
||||
uuid: str
|
||||
name: str
|
||||
address: str
|
||||
country_code: str | None = None
|
||||
is_connected: bool
|
||||
is_disabled: bool
|
||||
is_node_online: bool
|
||||
is_xray_running: bool
|
||||
users_online: int | None = None
|
||||
traffic_used_bytes: int | None = None
|
||||
traffic_limit_bytes: int | None = None
|
||||
last_status_change: datetime | None = None
|
||||
last_status_message: str | None = None
|
||||
xray_uptime: str | None = None
|
||||
is_traffic_tracking_active: bool = False
|
||||
traffic_reset_day: int | None = None
|
||||
notify_percent: int | None = None
|
||||
consumption_multiplier: float = 1.0
|
||||
cpu_count: int | None = None
|
||||
cpu_model: str | None = None
|
||||
total_ram: str | None = None
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
provider_uuid: str | None = None
|
||||
|
||||
|
||||
class NodesListResponse(BaseModel):
|
||||
"""List of nodes response."""
|
||||
|
||||
items: list[NodeInfo]
|
||||
total: int
|
||||
|
||||
|
||||
class NodesOverview(BaseModel):
|
||||
"""Nodes overview statistics."""
|
||||
|
||||
total: int
|
||||
online: int
|
||||
offline: int
|
||||
disabled: int
|
||||
total_users_online: int
|
||||
nodes: list[NodeInfo]
|
||||
|
||||
|
||||
class NodeStatisticsResponse(BaseModel):
|
||||
"""Node statistics with usage history."""
|
||||
|
||||
node: NodeInfo
|
||||
realtime: dict[str, Any] | None = None
|
||||
usage_history: list[dict[str, Any]] = Field(default_factory=list)
|
||||
last_updated: datetime | None = None
|
||||
|
||||
|
||||
class NodeUsageResponse(BaseModel):
|
||||
"""Node usage history response."""
|
||||
|
||||
items: list[dict[str, Any]] = Field(default_factory=list)
|
||||
|
||||
|
||||
class NodeActionRequest(BaseModel):
|
||||
"""Request to perform node action."""
|
||||
|
||||
action: Literal['enable', 'disable', 'restart']
|
||||
|
||||
|
||||
class NodeActionResponse(BaseModel):
|
||||
"""Response after node action."""
|
||||
|
||||
success: bool
|
||||
message: str | None = None
|
||||
is_disabled: bool | None = None
|
||||
|
||||
|
||||
# ============ Squads (Internal Squads) ============
|
||||
|
||||
|
||||
class SquadInfo(BaseModel):
|
||||
"""Internal Squad information from RemnaWave."""
|
||||
|
||||
uuid: str
|
||||
name: str
|
||||
members_count: int
|
||||
inbounds_count: int
|
||||
inbounds: list[dict[str, Any]] = Field(default_factory=list)
|
||||
|
||||
|
||||
class SquadWithLocalInfo(BaseModel):
|
||||
"""Squad with local database info."""
|
||||
|
||||
uuid: str
|
||||
name: str
|
||||
members_count: int
|
||||
inbounds_count: int
|
||||
inbounds: list[dict[str, Any]] = Field(default_factory=list)
|
||||
# Local DB info
|
||||
local_id: int | None = None
|
||||
display_name: str | None = None
|
||||
country_code: str | None = None
|
||||
is_available: bool | None = None
|
||||
is_trial_eligible: bool | None = None
|
||||
price_kopeks: int | None = None
|
||||
max_users: int | None = None
|
||||
current_users: int | None = None
|
||||
is_synced: bool = False
|
||||
|
||||
|
||||
class SquadsListResponse(BaseModel):
|
||||
"""List of squads response."""
|
||||
|
||||
items: list[SquadWithLocalInfo]
|
||||
total: int
|
||||
|
||||
|
||||
class SquadDetailResponse(BaseModel):
|
||||
"""Detailed squad response."""
|
||||
|
||||
uuid: str
|
||||
name: str
|
||||
members_count: int
|
||||
inbounds_count: int
|
||||
inbounds: list[dict[str, Any]] = Field(default_factory=list)
|
||||
# Local DB info if synced
|
||||
local_id: int | None = None
|
||||
display_name: str | None = None
|
||||
country_code: str | None = None
|
||||
description: str | None = None
|
||||
is_available: bool | None = None
|
||||
is_trial_eligible: bool | None = None
|
||||
price_kopeks: int | None = None
|
||||
max_users: int | None = None
|
||||
current_users: int | None = None
|
||||
sort_order: int | None = None
|
||||
is_synced: bool = False
|
||||
active_subscriptions: int = 0
|
||||
|
||||
|
||||
class SquadCreateRequest(BaseModel):
|
||||
"""Request to create a new squad."""
|
||||
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
inbound_uuids: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class SquadUpdateRequest(BaseModel):
|
||||
"""Request to update a squad."""
|
||||
|
||||
name: str | None = Field(None, min_length=1, max_length=255)
|
||||
inbound_uuids: list[str] | None = None
|
||||
|
||||
|
||||
class SquadActionRequest(BaseModel):
|
||||
"""Request to perform squad action."""
|
||||
|
||||
action: Literal['add_all_users', 'remove_all_users', 'delete', 'rename', 'update_inbounds']
|
||||
name: str | None = None
|
||||
inbound_uuids: list[str] | None = None
|
||||
|
||||
|
||||
class SquadOperationResponse(BaseModel):
|
||||
"""Response after squad operation."""
|
||||
|
||||
success: bool
|
||||
message: str | None = None
|
||||
data: dict[str, Any] | None = None
|
||||
|
||||
|
||||
# ============ Migration ============
|
||||
|
||||
|
||||
class MigrationPreviewResponse(BaseModel):
|
||||
"""Preview of squad migration."""
|
||||
|
||||
squad_uuid: str
|
||||
squad_name: str
|
||||
current_users: int
|
||||
max_users: int | None = None
|
||||
users_to_migrate: int
|
||||
|
||||
|
||||
class MigrationRequest(BaseModel):
|
||||
"""Request to migrate users between squads."""
|
||||
|
||||
source_uuid: str
|
||||
target_uuid: str
|
||||
|
||||
|
||||
class MigrationStats(BaseModel):
|
||||
"""Migration statistics."""
|
||||
|
||||
source_uuid: str
|
||||
target_uuid: str
|
||||
total: int = 0
|
||||
updated: int = 0
|
||||
panel_updated: int = 0
|
||||
panel_failed: int = 0
|
||||
source_removed: int = 0
|
||||
target_added: int = 0
|
||||
|
||||
|
||||
class MigrationResponse(BaseModel):
|
||||
"""Response after migration."""
|
||||
|
||||
success: bool
|
||||
message: str | None = None
|
||||
error: str | None = None
|
||||
data: MigrationStats | None = None
|
||||
|
||||
|
||||
# ============ Inbounds ============
|
||||
|
||||
|
||||
class InboundInfo(BaseModel):
|
||||
"""Inbound information."""
|
||||
|
||||
uuid: str
|
||||
tag: str
|
||||
type: str | None = None
|
||||
network: str | None = None
|
||||
security: str | None = None
|
||||
|
||||
|
||||
class InboundsListResponse(BaseModel):
|
||||
"""List of inbounds response."""
|
||||
|
||||
items: list[dict[str, Any]] = Field(default_factory=list)
|
||||
total: int = 0
|
||||
|
||||
|
||||
# ============ Auto Sync ============
|
||||
|
||||
|
||||
class AutoSyncTime(BaseModel):
|
||||
"""Scheduled sync time."""
|
||||
|
||||
hour: int
|
||||
minute: int
|
||||
|
||||
|
||||
class AutoSyncStatus(BaseModel):
|
||||
"""Auto sync status."""
|
||||
|
||||
enabled: bool
|
||||
times: list[str] = Field(default_factory=list) # HH:MM format
|
||||
next_run: datetime | None = None
|
||||
is_running: bool = False
|
||||
last_run_started_at: datetime | None = None
|
||||
last_run_finished_at: datetime | None = None
|
||||
last_run_success: bool | None = None
|
||||
last_run_reason: str | None = None
|
||||
last_run_error: str | None = None
|
||||
last_user_stats: dict[str, Any] | None = None
|
||||
last_server_stats: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class AutoSyncToggleRequest(BaseModel):
|
||||
"""Request to toggle auto sync."""
|
||||
|
||||
enabled: bool
|
||||
|
||||
|
||||
class AutoSyncRunResponse(BaseModel):
|
||||
"""Response after running sync."""
|
||||
|
||||
started: bool
|
||||
success: bool | None = None
|
||||
error: str | None = None
|
||||
user_stats: dict[str, Any] | None = None
|
||||
server_stats: dict[str, Any] | None = None
|
||||
reason: str | None = None
|
||||
|
||||
|
||||
# ============ Manual Sync ============
|
||||
|
||||
|
||||
class SyncMode(BaseModel):
|
||||
"""Sync mode options."""
|
||||
|
||||
mode: Literal['all', 'new_only', 'update_only'] = 'all'
|
||||
|
||||
|
||||
class SyncResponse(BaseModel):
|
||||
"""Response after sync operation."""
|
||||
|
||||
success: bool
|
||||
message: str | None = None
|
||||
data: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class SyncRecommendations(BaseModel):
|
||||
"""Sync recommendations."""
|
||||
|
||||
success: bool
|
||||
message: str | None = None
|
||||
data: dict[str, Any] | None = None
|
||||
@@ -0,0 +1,129 @@
|
||||
"""Schemas for server management in cabinet."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class PromoGroupInfo(BaseModel):
|
||||
"""Promo group info for server."""
|
||||
|
||||
id: int
|
||||
name: str
|
||||
is_selected: bool = False
|
||||
|
||||
|
||||
class ServerListItem(BaseModel):
|
||||
"""Server item for list view."""
|
||||
|
||||
id: int
|
||||
squad_uuid: str
|
||||
display_name: str
|
||||
original_name: str | None = None
|
||||
country_code: str | None = None
|
||||
is_available: bool
|
||||
is_trial_eligible: bool
|
||||
price_kopeks: int
|
||||
price_rubles: float
|
||||
max_users: int | None = None
|
||||
current_users: int
|
||||
sort_order: int
|
||||
is_full: bool
|
||||
availability_status: str
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ServerListResponse(BaseModel):
|
||||
"""Response with list of servers."""
|
||||
|
||||
servers: list[ServerListItem]
|
||||
total: int
|
||||
|
||||
|
||||
class ServerDetailResponse(BaseModel):
|
||||
"""Detailed server response."""
|
||||
|
||||
id: int
|
||||
squad_uuid: str
|
||||
display_name: str
|
||||
original_name: str | None = None
|
||||
country_code: str | None = None
|
||||
description: str | None = None
|
||||
is_available: bool
|
||||
is_trial_eligible: bool
|
||||
price_kopeks: int
|
||||
price_rubles: float
|
||||
max_users: int | None = None
|
||||
current_users: int
|
||||
sort_order: int
|
||||
is_full: bool
|
||||
availability_status: str
|
||||
promo_groups: list[PromoGroupInfo]
|
||||
active_subscriptions: int
|
||||
tariffs_using: list[str] # Names of tariffs using this server
|
||||
created_at: datetime
|
||||
updated_at: datetime | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ServerUpdateRequest(BaseModel):
|
||||
"""Request to update a server."""
|
||||
|
||||
display_name: str | None = Field(None, min_length=1, max_length=255)
|
||||
description: str | None = None
|
||||
country_code: str | None = Field(None, max_length=5)
|
||||
is_available: bool | None = None
|
||||
is_trial_eligible: bool | None = None
|
||||
price_kopeks: int | None = Field(None, ge=0)
|
||||
max_users: int | None = Field(None, ge=0)
|
||||
sort_order: int | None = Field(None, ge=0)
|
||||
promo_group_ids: list[int] | None = None
|
||||
|
||||
|
||||
class ServerToggleResponse(BaseModel):
|
||||
"""Response after toggling server."""
|
||||
|
||||
id: int
|
||||
is_available: bool
|
||||
message: str
|
||||
|
||||
|
||||
class ServerTrialToggleResponse(BaseModel):
|
||||
"""Response after toggling trial eligibility."""
|
||||
|
||||
id: int
|
||||
is_trial_eligible: bool
|
||||
message: str
|
||||
|
||||
|
||||
class ServerStatsResponse(BaseModel):
|
||||
"""Server statistics."""
|
||||
|
||||
id: int
|
||||
display_name: str
|
||||
squad_uuid: str
|
||||
current_users: int
|
||||
max_users: int | None
|
||||
active_subscriptions: int
|
||||
trial_subscriptions: int
|
||||
usage_percent: float | None = None
|
||||
|
||||
|
||||
class ServerSyncResponse(BaseModel):
|
||||
"""Response after syncing with RemnaWave."""
|
||||
|
||||
created: int
|
||||
updated: int
|
||||
removed: int
|
||||
message: str
|
||||
|
||||
|
||||
class ServerSyncRequest(BaseModel):
|
||||
"""Request to sync servers."""
|
||||
|
||||
force: bool = False # Force sync even if recently synced
|
||||
@@ -0,0 +1,159 @@
|
||||
"""Subscription schemas for cabinet."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class ServerInfo(BaseModel):
|
||||
"""Server info for display."""
|
||||
|
||||
uuid: str
|
||||
name: str
|
||||
country_code: str | None = None
|
||||
|
||||
|
||||
class TrafficPurchaseInfo(BaseModel):
|
||||
"""Purchased traffic package info."""
|
||||
|
||||
id: int
|
||||
traffic_gb: int
|
||||
expires_at: datetime
|
||||
created_at: datetime
|
||||
days_remaining: int
|
||||
progress_percent: float
|
||||
|
||||
|
||||
class SubscriptionData(BaseModel):
|
||||
"""User subscription data."""
|
||||
|
||||
id: int
|
||||
status: str
|
||||
is_trial: bool
|
||||
start_date: datetime
|
||||
end_date: datetime
|
||||
days_left: int
|
||||
hours_left: int = 0
|
||||
minutes_left: int = 0
|
||||
time_left_display: str = '' # Human readable format like "2д 5ч" or "5ч 30м"
|
||||
traffic_limit_gb: int
|
||||
traffic_used_gb: float
|
||||
traffic_used_percent: float
|
||||
device_limit: int
|
||||
connected_squads: list[str] = []
|
||||
servers: list[ServerInfo] = [] # Server display info
|
||||
autopay_enabled: bool
|
||||
autopay_days_before: int
|
||||
subscription_url: str | None = None
|
||||
hide_subscription_link: bool = False # Скрывать ли отображение ссылки (но кнопки работают)
|
||||
is_active: bool
|
||||
is_expired: bool
|
||||
traffic_purchases: list[TrafficPurchaseInfo] = []
|
||||
# Daily tariff fields
|
||||
is_daily: bool = False
|
||||
is_daily_paused: bool = False
|
||||
daily_price_kopeks: int | None = None
|
||||
next_daily_charge_at: datetime | None = None # When next daily charge will happen
|
||||
tariff_id: int | None = None
|
||||
tariff_name: 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."""
|
||||
|
||||
period_days: int
|
||||
price_kopeks: int
|
||||
price_rubles: float
|
||||
discount_percent: int = 0
|
||||
original_price_kopeks: int | None = None
|
||||
|
||||
|
||||
class RenewalRequest(BaseModel):
|
||||
"""Request to renew subscription."""
|
||||
|
||||
period_days: int = Field(..., description='Renewal period in days')
|
||||
|
||||
|
||||
class TrafficPackageResponse(BaseModel):
|
||||
"""Available traffic package."""
|
||||
|
||||
gb: int
|
||||
price_kopeks: int
|
||||
price_rubles: float
|
||||
is_unlimited: bool = False
|
||||
|
||||
|
||||
class TrafficPurchaseRequest(BaseModel):
|
||||
"""Request to purchase additional traffic."""
|
||||
|
||||
gb: int = Field(..., ge=0, description='GB to purchase (0 = unlimited)')
|
||||
|
||||
|
||||
class DevicePurchaseRequest(BaseModel):
|
||||
"""Request to purchase additional device slots."""
|
||||
|
||||
devices: int = Field(..., ge=1, description='Number of additional devices')
|
||||
|
||||
|
||||
class AutopayUpdateRequest(BaseModel):
|
||||
"""Request to update autopay settings."""
|
||||
|
||||
enabled: bool
|
||||
days_before: int | None = Field(None, ge=1, le=30, description='Days before expiration to charge')
|
||||
|
||||
|
||||
class TrialInfoResponse(BaseModel):
|
||||
"""Trial subscription info."""
|
||||
|
||||
is_available: bool
|
||||
duration_days: int
|
||||
traffic_limit_gb: int
|
||||
device_limit: int
|
||||
requires_payment: bool = False
|
||||
price_kopeks: int = 0
|
||||
price_rubles: float = 0.0
|
||||
reason_unavailable: str | None = None
|
||||
|
||||
|
||||
# ============ Purchase Options Schemas ============
|
||||
|
||||
|
||||
class PurchaseSelectionRequest(BaseModel):
|
||||
"""User's selection for subscription purchase."""
|
||||
|
||||
period_id: str | None = Field(None, description="Period ID like 'days:30'")
|
||||
period_days: int | None = Field(None, description='Period in days')
|
||||
traffic_value: int | None = Field(None, description='Traffic in GB (0 = unlimited)')
|
||||
servers: list[str] | None = Field(default_factory=list, description='Server UUIDs')
|
||||
devices: int | None = Field(None, description='Device limit')
|
||||
|
||||
|
||||
class PurchasePreviewRequest(BaseModel):
|
||||
"""Request to preview purchase pricing."""
|
||||
|
||||
selection: PurchaseSelectionRequest
|
||||
|
||||
|
||||
# ============ Tariff Purchase Schemas ============
|
||||
|
||||
|
||||
class TariffPurchaseRequest(BaseModel):
|
||||
"""Request to purchase a tariff."""
|
||||
|
||||
tariff_id: int = Field(..., description='Tariff ID to purchase')
|
||||
period_days: int = Field(..., description='Period in days')
|
||||
traffic_gb: int | None = Field(None, ge=0, description='Custom traffic in GB (for custom_traffic_enabled tariffs)')
|
||||
@@ -0,0 +1,228 @@
|
||||
"""Schemas for tariff management in cabinet."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class PeriodPrice(BaseModel):
|
||||
"""Price for a specific period."""
|
||||
|
||||
days: int = Field(..., ge=1, description='Period in days')
|
||||
price_kopeks: int = Field(..., ge=0, description='Price in kopeks')
|
||||
price_rubles: float | None = None
|
||||
|
||||
def __init__(self, **data):
|
||||
super().__init__(**data)
|
||||
if self.price_rubles is None:
|
||||
self.price_rubles = self.price_kopeks / 100
|
||||
|
||||
|
||||
class ServerTrafficLimit(BaseModel):
|
||||
"""Traffic limit for a specific server."""
|
||||
|
||||
traffic_limit_gb: int = Field(0, ge=0, description='0 = use default tariff limit')
|
||||
|
||||
|
||||
class ServerInfo(BaseModel):
|
||||
"""Server info for tariff."""
|
||||
|
||||
id: int
|
||||
squad_uuid: str
|
||||
display_name: str
|
||||
country_code: str | None = None
|
||||
is_selected: bool = False
|
||||
traffic_limit_gb: int | None = None # Индивидуальный лимит для сервера
|
||||
|
||||
|
||||
class PromoGroupInfo(BaseModel):
|
||||
"""Promo group info for tariff."""
|
||||
|
||||
id: int
|
||||
name: str
|
||||
is_selected: bool = False
|
||||
|
||||
|
||||
class TariffListItem(BaseModel):
|
||||
"""Tariff item for list view."""
|
||||
|
||||
id: int
|
||||
name: str
|
||||
description: str | None = None
|
||||
is_active: bool
|
||||
is_trial_available: bool
|
||||
is_daily: bool = False
|
||||
daily_price_kopeks: int = 0
|
||||
allow_traffic_topup: bool = True
|
||||
traffic_limit_gb: int
|
||||
device_limit: int
|
||||
tier_level: int
|
||||
display_order: int
|
||||
servers_count: int
|
||||
subscriptions_count: int
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class TariffListResponse(BaseModel):
|
||||
"""Response with list of tariffs."""
|
||||
|
||||
tariffs: list[TariffListItem]
|
||||
total: int
|
||||
|
||||
|
||||
class TariffDetailResponse(BaseModel):
|
||||
"""Detailed tariff response."""
|
||||
|
||||
id: int
|
||||
name: str
|
||||
description: str | None = None
|
||||
is_active: bool
|
||||
is_trial_available: bool
|
||||
allow_traffic_topup: bool = True
|
||||
traffic_topup_enabled: bool = False
|
||||
traffic_topup_packages: dict[str, int] = Field(default_factory=dict)
|
||||
max_topup_traffic_gb: int = 0
|
||||
traffic_limit_gb: int
|
||||
device_limit: int
|
||||
device_price_kopeks: int | None = None
|
||||
max_device_limit: int | None = None
|
||||
tier_level: int
|
||||
display_order: int
|
||||
period_prices: list[PeriodPrice]
|
||||
allowed_squads: list[str] # UUIDs
|
||||
server_traffic_limits: dict[str, ServerTrafficLimit] = Field(default_factory=dict) # {uuid: {traffic_limit_gb}}
|
||||
servers: list[ServerInfo]
|
||||
promo_groups: list[PromoGroupInfo]
|
||||
subscriptions_count: int
|
||||
# Произвольное количество дней
|
||||
custom_days_enabled: bool = False
|
||||
price_per_day_kopeks: int = 0
|
||||
min_days: int = 1
|
||||
max_days: int = 365
|
||||
# Произвольный трафик при покупке
|
||||
custom_traffic_enabled: bool = False
|
||||
traffic_price_per_gb_kopeks: int = 0
|
||||
min_traffic_gb: int = 1
|
||||
max_traffic_gb: int = 1000
|
||||
# Дневной тариф
|
||||
is_daily: bool = False
|
||||
daily_price_kopeks: int = 0
|
||||
# Режим сброса трафика
|
||||
traffic_reset_mode: str | None = None # DAY, WEEK, MONTH, NO_RESET, None = глобальная настройка
|
||||
created_at: datetime
|
||||
updated_at: datetime | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class TariffCreateRequest(BaseModel):
|
||||
"""Request to create a tariff."""
|
||||
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
description: str | None = None
|
||||
is_active: bool = True
|
||||
allow_traffic_topup: bool = True
|
||||
traffic_topup_enabled: bool = False
|
||||
traffic_topup_packages: dict[str, int] = Field(default_factory=dict)
|
||||
max_topup_traffic_gb: int = Field(0, ge=0)
|
||||
traffic_limit_gb: int = Field(0, ge=0, description='0 = unlimited')
|
||||
device_limit: int = Field(1, ge=1)
|
||||
device_price_kopeks: int | None = Field(None, ge=0)
|
||||
max_device_limit: int | None = Field(None, ge=1)
|
||||
tier_level: int = Field(1, ge=1, le=10)
|
||||
period_prices: list[PeriodPrice] = Field(default_factory=list)
|
||||
allowed_squads: list[str] = Field(default_factory=list, description='Server UUIDs')
|
||||
server_traffic_limits: dict[str, ServerTrafficLimit] = Field(
|
||||
default_factory=dict, description='Per-server traffic limits'
|
||||
)
|
||||
promo_group_ids: list[int] = Field(default_factory=list)
|
||||
# Произвольное количество дней
|
||||
custom_days_enabled: bool = False
|
||||
price_per_day_kopeks: int = Field(0, ge=0)
|
||||
min_days: int = Field(1, ge=1)
|
||||
max_days: int = Field(365, ge=1)
|
||||
# Произвольный трафик при покупке
|
||||
custom_traffic_enabled: bool = False
|
||||
traffic_price_per_gb_kopeks: int = Field(0, ge=0)
|
||||
min_traffic_gb: int = Field(1, ge=1)
|
||||
max_traffic_gb: int = Field(1000, ge=1)
|
||||
# Дневной тариф
|
||||
is_daily: bool = False
|
||||
daily_price_kopeks: int = Field(0, ge=0)
|
||||
# Режим сброса трафика
|
||||
traffic_reset_mode: str | None = None # DAY, WEEK, MONTH, NO_RESET, None = глобальная настройка
|
||||
|
||||
|
||||
class TariffUpdateRequest(BaseModel):
|
||||
"""Request to update a tariff."""
|
||||
|
||||
name: str | None = Field(None, min_length=1, max_length=255)
|
||||
description: str | None = None
|
||||
is_active: bool | None = None
|
||||
allow_traffic_topup: bool | None = None
|
||||
traffic_topup_enabled: bool | None = None
|
||||
traffic_topup_packages: dict[str, int] | None = None
|
||||
max_topup_traffic_gb: int | None = Field(None, ge=0)
|
||||
traffic_limit_gb: int | None = Field(None, ge=0)
|
||||
device_limit: int | None = Field(None, ge=1)
|
||||
device_price_kopeks: int | None = Field(None, ge=0)
|
||||
max_device_limit: int | None = Field(None, ge=1)
|
||||
tier_level: int | None = Field(None, ge=1, le=10)
|
||||
display_order: int | None = Field(None, ge=0)
|
||||
period_prices: list[PeriodPrice] | None = None
|
||||
allowed_squads: list[str] | None = None
|
||||
server_traffic_limits: dict[str, ServerTrafficLimit] | None = None
|
||||
promo_group_ids: list[int] | None = None
|
||||
# Произвольное количество дней
|
||||
custom_days_enabled: bool | None = None
|
||||
price_per_day_kopeks: int | None = Field(None, ge=0)
|
||||
min_days: int | None = Field(None, ge=1)
|
||||
max_days: int | None = Field(None, ge=1)
|
||||
# Произвольный трафик при покупке
|
||||
custom_traffic_enabled: bool | None = None
|
||||
traffic_price_per_gb_kopeks: int | None = Field(None, ge=0)
|
||||
min_traffic_gb: int | None = Field(None, ge=1)
|
||||
max_traffic_gb: int | None = Field(None, ge=1)
|
||||
# Дневной тариф
|
||||
is_daily: bool | None = None
|
||||
daily_price_kopeks: int | None = Field(None, ge=0)
|
||||
# Режим сброса трафика
|
||||
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."""
|
||||
|
||||
id: int
|
||||
is_active: bool
|
||||
message: str
|
||||
|
||||
|
||||
class TariffTrialResponse(BaseModel):
|
||||
"""Response after setting trial tariff."""
|
||||
|
||||
id: int
|
||||
is_trial_available: bool
|
||||
message: str
|
||||
|
||||
|
||||
class TariffStatsResponse(BaseModel):
|
||||
"""Tariff statistics."""
|
||||
|
||||
id: int
|
||||
name: str
|
||||
subscriptions_count: int
|
||||
active_subscriptions: int
|
||||
trial_subscriptions: int
|
||||
revenue_kopeks: int
|
||||
revenue_rubles: float
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Support tickets schemas for cabinet."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class TicketMessageResponse(BaseModel):
|
||||
"""Ticket message data."""
|
||||
|
||||
id: int
|
||||
message_text: str
|
||||
is_from_admin: bool
|
||||
has_media: bool = False
|
||||
media_type: str | None = None
|
||||
media_file_id: str | None = None
|
||||
media_caption: str | None = None
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class TicketResponse(BaseModel):
|
||||
"""Ticket data."""
|
||||
|
||||
id: int
|
||||
title: str
|
||||
status: str
|
||||
priority: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
closed_at: datetime | None = None
|
||||
messages_count: int = 0
|
||||
last_message: TicketMessageResponse | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class TicketDetailResponse(BaseModel):
|
||||
"""Ticket with all messages."""
|
||||
|
||||
id: int
|
||||
title: str
|
||||
status: str
|
||||
priority: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
closed_at: datetime | None = None
|
||||
is_reply_blocked: bool = False
|
||||
messages: list[TicketMessageResponse] = []
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class TicketListResponse(BaseModel):
|
||||
"""Paginated ticket list."""
|
||||
|
||||
items: list[TicketResponse]
|
||||
total: int
|
||||
page: int
|
||||
per_page: int
|
||||
pages: int
|
||||
|
||||
|
||||
class TicketCreateRequest(BaseModel):
|
||||
"""Request to create a new ticket."""
|
||||
|
||||
title: str = Field(..., min_length=3, max_length=255, description='Ticket title')
|
||||
message: str = Field(..., min_length=10, max_length=4000, description='Initial message')
|
||||
media_type: str | None = Field(None, description='Media type: photo, video, document')
|
||||
media_file_id: str | None = Field(None, description='Telegram file_id of uploaded media')
|
||||
media_caption: str | None = Field(None, max_length=1000, description='Media caption')
|
||||
|
||||
|
||||
class TicketMessageCreateRequest(BaseModel):
|
||||
"""Request to add message to ticket."""
|
||||
|
||||
message: str = Field(..., min_length=1, max_length=4000, description='Message text')
|
||||
media_type: str | None = Field(None, description='Media type: photo, video, document')
|
||||
media_file_id: str | None = Field(None, description='Telegram file_id of uploaded media')
|
||||
media_caption: str | None = Field(None, max_length=1000, description='Media caption')
|
||||
@@ -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
|
||||
@@ -0,0 +1,696 @@
|
||||
"""Schemas for Admin Users management in cabinet."""
|
||||
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class UserStatusEnum(str, Enum):
|
||||
"""User status enum."""
|
||||
|
||||
ACTIVE = 'active'
|
||||
BLOCKED = 'blocked'
|
||||
DELETED = 'deleted'
|
||||
|
||||
|
||||
class SubscriptionStatusEnum(str, Enum):
|
||||
"""Subscription status enum."""
|
||||
|
||||
TRIAL = 'trial'
|
||||
ACTIVE = 'active'
|
||||
EXPIRED = 'expired'
|
||||
DISABLED = 'disabled'
|
||||
PENDING = 'pending'
|
||||
|
||||
|
||||
class SortByEnum(str, Enum):
|
||||
"""Sort options for users list."""
|
||||
|
||||
CREATED_AT = 'created_at'
|
||||
BALANCE = 'balance'
|
||||
TRAFFIC = 'traffic'
|
||||
LAST_ACTIVITY = 'last_activity'
|
||||
TOTAL_SPENT = 'total_spent'
|
||||
PURCHASE_COUNT = 'purchase_count'
|
||||
|
||||
|
||||
# === 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."""
|
||||
|
||||
id: int
|
||||
status: str
|
||||
is_trial: bool
|
||||
start_date: datetime | None = None
|
||||
end_date: datetime | None = None
|
||||
traffic_limit_gb: int = 0
|
||||
traffic_used_gb: float = 0.0
|
||||
device_limit: int = 1
|
||||
tariff_id: int | None = None
|
||||
tariff_name: str | None = None
|
||||
autopay_enabled: bool = False
|
||||
is_active: bool = False
|
||||
days_remaining: int = 0
|
||||
purchased_traffic_gb: int = 0
|
||||
traffic_purchases: list[TrafficPurchaseItem] = []
|
||||
|
||||
|
||||
class UserPromoGroupInfo(BaseModel):
|
||||
"""User promo group info."""
|
||||
|
||||
id: int
|
||||
name: str
|
||||
is_default: bool = False
|
||||
|
||||
|
||||
# === User List ===
|
||||
|
||||
|
||||
class UserListItem(BaseModel):
|
||||
"""User item in list."""
|
||||
|
||||
id: int
|
||||
telegram_id: int | None = None
|
||||
username: str | None = None
|
||||
first_name: str | None = None
|
||||
last_name: str | None = None
|
||||
full_name: str
|
||||
status: str
|
||||
balance_kopeks: int
|
||||
balance_rubles: float
|
||||
created_at: datetime
|
||||
last_activity: datetime | None = None
|
||||
|
||||
# Subscription summary
|
||||
has_subscription: bool = False
|
||||
subscription_status: str | None = None
|
||||
subscription_is_trial: bool = False
|
||||
subscription_end_date: datetime | None = None
|
||||
|
||||
# Promo group
|
||||
promo_group_id: int | None = None
|
||||
promo_group_name: str | None = None
|
||||
|
||||
# Stats
|
||||
total_spent_kopeks: int = 0
|
||||
purchase_count: int = 0
|
||||
|
||||
# Restrictions
|
||||
has_restrictions: bool = False
|
||||
restriction_topup: bool = False
|
||||
restriction_subscription: bool = False
|
||||
|
||||
|
||||
class UsersListResponse(BaseModel):
|
||||
"""Paginated list of users."""
|
||||
|
||||
users: list[UserListItem]
|
||||
total: int
|
||||
offset: int = 0
|
||||
limit: int = 50
|
||||
|
||||
|
||||
# === User Detail ===
|
||||
|
||||
|
||||
class UserTransactionItem(BaseModel):
|
||||
"""User transaction."""
|
||||
|
||||
id: int
|
||||
type: str
|
||||
amount_kopeks: int
|
||||
amount_rubles: float
|
||||
description: str | None = None
|
||||
payment_method: str | None = None
|
||||
is_completed: bool = True
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class UserReferralInfo(BaseModel):
|
||||
"""User referral info."""
|
||||
|
||||
referral_code: str
|
||||
referrals_count: int = 0
|
||||
total_earnings_kopeks: int = 0
|
||||
commission_percent: int | None = None
|
||||
referred_by_id: int | None = None
|
||||
referred_by_username: str | None = None
|
||||
|
||||
|
||||
class UserDetailResponse(BaseModel):
|
||||
"""Detailed user information."""
|
||||
|
||||
id: int
|
||||
telegram_id: int | None = None
|
||||
username: str | None = None
|
||||
first_name: str | None = None
|
||||
last_name: str | None = None
|
||||
full_name: str
|
||||
status: str
|
||||
language: str
|
||||
balance_kopeks: int
|
||||
balance_rubles: float
|
||||
|
||||
# Email (cabinet)
|
||||
email: str | None = None
|
||||
email_verified: bool = False
|
||||
|
||||
# Dates
|
||||
created_at: datetime
|
||||
updated_at: datetime | None = None
|
||||
last_activity: datetime | None = None
|
||||
cabinet_last_login: datetime | None = None
|
||||
|
||||
# Subscription
|
||||
subscription: UserSubscriptionInfo | None = None
|
||||
|
||||
# Promo group
|
||||
promo_group: UserPromoGroupInfo | None = None
|
||||
|
||||
# Referral
|
||||
referral: UserReferralInfo
|
||||
|
||||
# Stats
|
||||
total_spent_kopeks: int = 0
|
||||
purchase_count: int = 0
|
||||
used_promocodes: int = 0
|
||||
has_had_paid_subscription: bool = False
|
||||
lifetime_used_traffic_bytes: int = 0
|
||||
|
||||
# Restrictions
|
||||
restriction_topup: bool = False
|
||||
restriction_subscription: bool = False
|
||||
restriction_reason: str | None = None
|
||||
|
||||
# Promo offer
|
||||
promo_offer_discount_percent: int = 0
|
||||
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 ===
|
||||
|
||||
|
||||
class UpdateBalanceRequest(BaseModel):
|
||||
"""Request to update user balance."""
|
||||
|
||||
amount_kopeks: int = Field(..., description='Amount in kopeks (positive to add, negative to subtract)')
|
||||
description: str = Field(default='Admin balance adjustment', max_length=500)
|
||||
create_transaction: bool = Field(default=True, description='Create transaction record')
|
||||
|
||||
|
||||
class UpdateBalanceResponse(BaseModel):
|
||||
"""Response after balance update."""
|
||||
|
||||
success: bool
|
||||
old_balance_kopeks: int
|
||||
new_balance_kopeks: int
|
||||
message: str
|
||||
|
||||
|
||||
class UpdateSubscriptionRequest(BaseModel):
|
||||
"""Request to update user subscription."""
|
||||
|
||||
action: str = Field(
|
||||
..., description='Action: extend, set_end_date, change_tariff, set_traffic, toggle_autopay, cancel'
|
||||
)
|
||||
|
||||
# For extend action
|
||||
days: int | None = Field(None, ge=1, le=3650, description='Days to extend')
|
||||
|
||||
# For set_end_date action
|
||||
end_date: datetime | None = Field(None, description='New end date')
|
||||
|
||||
# For change_tariff action
|
||||
tariff_id: int | None = Field(None, description='New tariff ID')
|
||||
|
||||
# For set_traffic action
|
||||
traffic_limit_gb: int | None = Field(None, ge=0, description='New traffic limit in GB')
|
||||
traffic_used_gb: float | None = Field(None, ge=0, description='Set traffic used in GB')
|
||||
|
||||
# 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')
|
||||
|
||||
|
||||
class UpdateSubscriptionResponse(BaseModel):
|
||||
"""Response after subscription update."""
|
||||
|
||||
success: bool
|
||||
message: str
|
||||
subscription: UserSubscriptionInfo | None = None
|
||||
|
||||
|
||||
class UpdateUserStatusRequest(BaseModel):
|
||||
"""Request to update user status."""
|
||||
|
||||
status: UserStatusEnum
|
||||
reason: str | None = Field(None, max_length=500, description='Reason for status change')
|
||||
|
||||
|
||||
class UpdateUserStatusResponse(BaseModel):
|
||||
"""Response after status update."""
|
||||
|
||||
success: bool
|
||||
old_status: str
|
||||
new_status: str
|
||||
message: str
|
||||
|
||||
|
||||
class UpdateRestrictionsRequest(BaseModel):
|
||||
"""Request to update user restrictions."""
|
||||
|
||||
restriction_topup: bool | None = Field(None, description='Block balance top-up')
|
||||
restriction_subscription: bool | None = Field(None, description='Block subscription purchase/renewal')
|
||||
restriction_reason: str | None = Field(None, max_length=500, description='Reason for restrictions')
|
||||
|
||||
|
||||
class UpdateRestrictionsResponse(BaseModel):
|
||||
"""Response after restrictions update."""
|
||||
|
||||
success: bool
|
||||
restriction_topup: bool
|
||||
restriction_subscription: bool
|
||||
restriction_reason: str | None = None
|
||||
message: str
|
||||
|
||||
|
||||
class UpdatePromoGroupRequest(BaseModel):
|
||||
"""Request to update user promo group."""
|
||||
|
||||
promo_group_id: int | None = Field(None, description='New promo group ID (null to remove)')
|
||||
|
||||
|
||||
class UpdatePromoGroupResponse(BaseModel):
|
||||
"""Response after promo group update."""
|
||||
|
||||
success: bool
|
||||
old_promo_group_id: int | None = None
|
||||
new_promo_group_id: int | None = None
|
||||
promo_group_name: str | None = None
|
||||
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."""
|
||||
|
||||
soft_delete: bool = Field(default=True, description='Soft delete (mark as deleted) or hard delete')
|
||||
reason: str | None = Field(None, max_length=500, description='Reason for deletion')
|
||||
|
||||
|
||||
class DeleteUserResponse(BaseModel):
|
||||
"""Response after user deletion."""
|
||||
|
||||
success: bool
|
||||
message: str
|
||||
|
||||
|
||||
# === Statistics ===
|
||||
|
||||
|
||||
class UsersStatsResponse(BaseModel):
|
||||
"""Users statistics."""
|
||||
|
||||
total_users: int = 0
|
||||
active_users: int = 0
|
||||
blocked_users: int = 0
|
||||
deleted_users: int = 0
|
||||
new_today: int = 0
|
||||
new_week: int = 0
|
||||
new_month: int = 0
|
||||
|
||||
# Subscription stats
|
||||
users_with_subscription: int = 0
|
||||
users_with_active_subscription: int = 0
|
||||
users_with_trial: int = 0
|
||||
users_with_expired_subscription: int = 0
|
||||
|
||||
# Financial stats
|
||||
total_balance_kopeks: int = 0
|
||||
total_balance_rubles: float = 0.0
|
||||
avg_balance_kopeks: int = 0
|
||||
|
||||
# Activity stats
|
||||
active_today: int = 0
|
||||
active_week: int = 0
|
||||
active_month: int = 0
|
||||
|
||||
|
||||
# === Search ===
|
||||
|
||||
|
||||
class UserSearchRequest(BaseModel):
|
||||
"""Request for user search."""
|
||||
|
||||
query: str = Field(..., min_length=1, max_length=255)
|
||||
search_by: list[str] = Field(
|
||||
default=['telegram_id', 'username', 'first_name', 'last_name', 'email'], description='Fields to search in'
|
||||
)
|
||||
limit: int = Field(default=20, ge=1, le=100)
|
||||
|
||||
|
||||
# === Tariffs for User ===
|
||||
|
||||
|
||||
class PeriodPriceInfo(BaseModel):
|
||||
"""Period price info."""
|
||||
|
||||
days: int
|
||||
price_kopeks: int
|
||||
price_rubles: float
|
||||
|
||||
|
||||
class UserAvailableTariffItem(BaseModel):
|
||||
"""Tariff available for user."""
|
||||
|
||||
id: int
|
||||
name: str
|
||||
description: str | None = None
|
||||
is_active: bool = True
|
||||
is_trial_available: bool = False
|
||||
traffic_limit_gb: int = 0
|
||||
device_limit: int = 1
|
||||
tier_level: int = 1
|
||||
display_order: int = 0
|
||||
|
||||
# Pricing
|
||||
period_prices: list[PeriodPriceInfo] = []
|
||||
is_daily: bool = False
|
||||
daily_price_kopeks: int = 0
|
||||
|
||||
# Custom options
|
||||
custom_days_enabled: bool = False
|
||||
price_per_day_kopeks: int = 0
|
||||
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
|
||||
|
||||
|
||||
class UserAvailableTariffsResponse(BaseModel):
|
||||
"""List of tariffs available for user."""
|
||||
|
||||
user_id: int
|
||||
promo_group_id: int | None = None
|
||||
promo_group_name: str | None = None
|
||||
tariffs: list[UserAvailableTariffItem] = []
|
||||
total: int = 0
|
||||
|
||||
# Current subscription tariff
|
||||
current_tariff_id: int | None = None
|
||||
current_tariff_name: str | None = None
|
||||
|
||||
|
||||
# === Panel Sync ===
|
||||
|
||||
|
||||
class PanelUserInfo(BaseModel):
|
||||
"""User info from panel."""
|
||||
|
||||
uuid: str | None = None
|
||||
short_uuid: str | None = None
|
||||
username: str | None = None
|
||||
status: str | None = None
|
||||
expire_at: datetime | None = None
|
||||
traffic_limit_gb: float = 0
|
||||
traffic_used_gb: float = 0
|
||||
device_limit: int = 1
|
||||
subscription_url: str | None = None
|
||||
active_squads: list[str] = []
|
||||
|
||||
|
||||
class SyncFromPanelRequest(BaseModel):
|
||||
"""Request to sync user from panel."""
|
||||
|
||||
update_subscription: bool = Field(default=True, description='Update subscription data')
|
||||
update_traffic: bool = Field(default=True, description='Update traffic usage')
|
||||
create_if_missing: bool = Field(
|
||||
default=False, description='Create subscription if user exists in panel but not in bot'
|
||||
)
|
||||
|
||||
|
||||
class SyncFromPanelResponse(BaseModel):
|
||||
"""Response after syncing from panel."""
|
||||
|
||||
success: bool
|
||||
message: str
|
||||
panel_user: PanelUserInfo | None = None
|
||||
changes: dict[str, Any] = {}
|
||||
errors: list[str] = []
|
||||
|
||||
|
||||
class SyncToPanelRequest(BaseModel):
|
||||
"""Request to sync user to panel."""
|
||||
|
||||
create_if_missing: bool = Field(default=True, description='Create user in panel if not exists')
|
||||
update_status: bool = Field(default=True, description='Update user status in panel')
|
||||
update_traffic_limit: bool = Field(default=True, description='Update traffic limit in panel')
|
||||
update_expire_date: bool = Field(default=True, description='Update expire date in panel')
|
||||
update_squads: bool = Field(default=True, description='Update connected squads in panel')
|
||||
|
||||
|
||||
class SyncToPanelResponse(BaseModel):
|
||||
"""Response after syncing to panel."""
|
||||
|
||||
success: bool
|
||||
message: str
|
||||
action: str = '' # created, updated, no_changes
|
||||
panel_uuid: str | None = None
|
||||
changes: dict[str, Any] = {}
|
||||
errors: list[str] = []
|
||||
|
||||
|
||||
class PanelSyncStatusResponse(BaseModel):
|
||||
"""Panel sync status for user."""
|
||||
|
||||
user_id: int
|
||||
telegram_id: int | None = None
|
||||
remnawave_uuid: str | None = None
|
||||
last_sync: datetime | None = None
|
||||
|
||||
# Bot data
|
||||
bot_subscription_status: str | None = None
|
||||
bot_subscription_end_date: datetime | None = None
|
||||
bot_traffic_limit_gb: int = 0
|
||||
bot_traffic_used_gb: float = 0
|
||||
bot_device_limit: int = 0
|
||||
bot_squads: list[str] = []
|
||||
|
||||
# Panel data (if available)
|
||||
panel_found: bool = False
|
||||
panel_status: str | None = None
|
||||
panel_expire_at: datetime | None = None
|
||||
panel_traffic_limit_gb: float = 0
|
||||
panel_traffic_used_gb: float = 0
|
||||
panel_device_limit: int = 0
|
||||
panel_squads: list[str] = []
|
||||
|
||||
# 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,277 @@
|
||||
"""Схемы для колеса удачи (Fortune Wheel)."""
|
||||
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# ==================== ENUMS ====================
|
||||
|
||||
|
||||
class WheelPaymentType(str, Enum):
|
||||
"""Способы оплаты спина."""
|
||||
|
||||
TELEGRAM_STARS = 'telegram_stars'
|
||||
SUBSCRIPTION_DAYS = 'subscription_days'
|
||||
|
||||
|
||||
class WheelPrizeType(str, Enum):
|
||||
"""Типы призов."""
|
||||
|
||||
SUBSCRIPTION_DAYS = 'subscription_days'
|
||||
BALANCE_BONUS = 'balance_bonus'
|
||||
TRAFFIC_GB = 'traffic_gb'
|
||||
PROMOCODE = 'promocode'
|
||||
NOTHING = 'nothing'
|
||||
|
||||
|
||||
# ==================== USER SCHEMAS ====================
|
||||
|
||||
|
||||
class WheelPrizeDisplay(BaseModel):
|
||||
"""Отображение приза для пользователя."""
|
||||
|
||||
id: int
|
||||
display_name: str
|
||||
emoji: str
|
||||
color: str
|
||||
prize_type: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class WheelConfigResponse(BaseModel):
|
||||
"""Конфигурация колеса для пользователя."""
|
||||
|
||||
is_enabled: bool
|
||||
name: str
|
||||
spin_cost_stars: int | None = None
|
||||
spin_cost_days: int | None = None
|
||||
spin_cost_stars_enabled: bool
|
||||
spin_cost_days_enabled: bool
|
||||
prizes: list[WheelPrizeDisplay]
|
||||
daily_limit: int
|
||||
user_spins_today: int
|
||||
can_spin: bool
|
||||
can_spin_reason: str | None = None
|
||||
can_pay_stars: bool = False
|
||||
can_pay_days: bool = False
|
||||
user_balance_kopeks: int = 0
|
||||
required_balance_kopeks: int = 0
|
||||
|
||||
|
||||
class SpinAvailabilityResponse(BaseModel):
|
||||
"""Доступность спина."""
|
||||
|
||||
can_spin: bool
|
||||
reason: str | None = None
|
||||
spins_remaining_today: int
|
||||
can_pay_stars: bool
|
||||
can_pay_days: bool
|
||||
min_subscription_days: int
|
||||
user_subscription_days: int
|
||||
user_balance_kopeks: int = 0
|
||||
required_balance_kopeks: int = 0
|
||||
|
||||
|
||||
class SpinRequest(BaseModel):
|
||||
"""Запрос на спин."""
|
||||
|
||||
payment_type: WheelPaymentType
|
||||
|
||||
|
||||
class SpinResultResponse(BaseModel):
|
||||
"""Результат спина."""
|
||||
|
||||
success: bool
|
||||
prize_id: int | None = None
|
||||
prize_type: str | None = None
|
||||
prize_value: int = 0
|
||||
prize_display_name: str = ''
|
||||
emoji: str = '🎁'
|
||||
color: str = '#3B82F6'
|
||||
rotation_degrees: float = 0.0
|
||||
message: str = ''
|
||||
promocode: str | None = None
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class SpinHistoryItem(BaseModel):
|
||||
"""Элемент истории спинов."""
|
||||
|
||||
id: int
|
||||
payment_type: str
|
||||
payment_amount: int
|
||||
prize_type: str
|
||||
prize_value: int
|
||||
prize_display_name: str
|
||||
emoji: str = '🎁'
|
||||
color: str = '#3B82F6'
|
||||
prize_value_kopeks: int
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class SpinHistoryResponse(BaseModel):
|
||||
"""История спинов с пагинацией."""
|
||||
|
||||
items: list[SpinHistoryItem]
|
||||
total: int
|
||||
page: int
|
||||
per_page: int
|
||||
pages: int
|
||||
|
||||
|
||||
# ==================== ADMIN SCHEMAS ====================
|
||||
|
||||
|
||||
class WheelPrizeAdminResponse(BaseModel):
|
||||
"""Полная информация о призе для админа."""
|
||||
|
||||
id: int
|
||||
config_id: int
|
||||
prize_type: str
|
||||
prize_value: int
|
||||
display_name: str
|
||||
emoji: str
|
||||
color: str
|
||||
prize_value_kopeks: int
|
||||
sort_order: int
|
||||
manual_probability: float | None = None
|
||||
is_active: bool
|
||||
promo_balance_bonus_kopeks: int = 0
|
||||
promo_subscription_days: int = 0
|
||||
promo_traffic_gb: int = 0
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class AdminWheelConfigResponse(BaseModel):
|
||||
"""Полная конфигурация колеса для админа."""
|
||||
|
||||
id: int
|
||||
is_enabled: bool
|
||||
name: str
|
||||
spin_cost_stars: int
|
||||
spin_cost_days: int
|
||||
spin_cost_stars_enabled: bool
|
||||
spin_cost_days_enabled: bool
|
||||
rtp_percent: int
|
||||
daily_spin_limit: int
|
||||
min_subscription_days_for_day_payment: int
|
||||
promo_prefix: str
|
||||
promo_validity_days: int
|
||||
prizes: list[WheelPrizeAdminResponse]
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class UpdateWheelConfigRequest(BaseModel):
|
||||
"""Запрос на обновление конфига колеса."""
|
||||
|
||||
is_enabled: bool | None = None
|
||||
name: str | None = Field(None, min_length=1, max_length=255)
|
||||
spin_cost_stars: int | None = Field(None, ge=1, le=1000)
|
||||
spin_cost_days: int | None = Field(None, ge=1, le=30)
|
||||
spin_cost_stars_enabled: bool | None = None
|
||||
spin_cost_days_enabled: bool | None = None
|
||||
rtp_percent: int | None = Field(None, ge=0, le=100)
|
||||
daily_spin_limit: int | None = Field(None, ge=0, le=100)
|
||||
min_subscription_days_for_day_payment: int | None = Field(None, ge=1, le=30)
|
||||
promo_prefix: str | None = Field(None, min_length=1, max_length=20)
|
||||
promo_validity_days: int | None = Field(None, ge=1, le=365)
|
||||
|
||||
|
||||
class CreatePrizeRequest(BaseModel):
|
||||
"""Запрос на создание приза."""
|
||||
|
||||
prize_type: WheelPrizeType
|
||||
prize_value: int = Field(..., ge=0)
|
||||
display_name: str = Field(..., min_length=1, max_length=100)
|
||||
emoji: str = Field(default='🎁', max_length=10)
|
||||
color: str = Field(default='#3B82F6', pattern=r'^#[0-9A-Fa-f]{6}$')
|
||||
prize_value_kopeks: int = Field(..., ge=0)
|
||||
sort_order: int = Field(default=0, ge=0)
|
||||
manual_probability: float | None = Field(None, ge=0, le=1)
|
||||
is_active: bool = True
|
||||
promo_balance_bonus_kopeks: int = Field(default=0, ge=0)
|
||||
promo_subscription_days: int = Field(default=0, ge=0)
|
||||
promo_traffic_gb: int = Field(default=0, ge=0)
|
||||
|
||||
|
||||
class UpdatePrizeRequest(BaseModel):
|
||||
"""Запрос на обновление приза."""
|
||||
|
||||
prize_type: WheelPrizeType | None = None
|
||||
prize_value: int | None = Field(None, ge=0)
|
||||
display_name: str | None = Field(None, min_length=1, max_length=100)
|
||||
emoji: str | None = Field(None, max_length=10)
|
||||
color: str | None = Field(None, pattern=r'^#[0-9A-Fa-f]{6}$')
|
||||
prize_value_kopeks: int | None = Field(None, ge=0)
|
||||
sort_order: int | None = Field(None, ge=0)
|
||||
manual_probability: float | None = Field(None, ge=0, le=1)
|
||||
is_active: bool | None = None
|
||||
promo_balance_bonus_kopeks: int | None = Field(None, ge=0)
|
||||
promo_subscription_days: int | None = Field(None, ge=0)
|
||||
promo_traffic_gb: int | None = Field(None, ge=0)
|
||||
|
||||
|
||||
class ReorderPrizesRequest(BaseModel):
|
||||
"""Запрос на переупорядочивание призов."""
|
||||
|
||||
prize_ids: list[int]
|
||||
|
||||
|
||||
class AdminSpinItem(BaseModel):
|
||||
"""Спин для админки."""
|
||||
|
||||
id: int
|
||||
user_id: int
|
||||
username: str | None = None
|
||||
payment_type: str
|
||||
payment_amount: int
|
||||
payment_value_kopeks: int
|
||||
prize_type: str
|
||||
prize_value: int
|
||||
prize_display_name: str
|
||||
prize_value_kopeks: int
|
||||
is_applied: bool
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class AdminSpinsResponse(BaseModel):
|
||||
"""Список спинов для админки с пагинацией."""
|
||||
|
||||
items: list[AdminSpinItem]
|
||||
total: int
|
||||
page: int
|
||||
per_page: int
|
||||
pages: int
|
||||
|
||||
|
||||
class WheelStatisticsResponse(BaseModel):
|
||||
"""Статистика колеса."""
|
||||
|
||||
total_spins: int
|
||||
total_revenue_kopeks: int
|
||||
total_payout_kopeks: int
|
||||
actual_rtp_percent: float
|
||||
configured_rtp_percent: int
|
||||
spins_by_payment_type: dict
|
||||
prizes_distribution: list[dict]
|
||||
top_wins: list[dict]
|
||||
period_from: str | None = None
|
||||
period_to: str | None = None
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Cabinet services."""
|
||||
|
||||
from .email_service import EmailService, email_service
|
||||
|
||||
|
||||
__all__ = ['EmailService', 'email_service']
|
||||
@@ -0,0 +1,486 @@
|
||||
"""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
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EmailService:
|
||||
"""Service for sending emails via SMTP."""
|
||||
|
||||
def __init__(self):
|
||||
self.host = settings.SMTP_HOST
|
||||
self.port = settings.SMTP_PORT
|
||||
self.user = settings.SMTP_USER
|
||||
self.password = settings.SMTP_PASSWORD
|
||||
self.from_email = settings.get_smtp_from_email()
|
||||
self.from_name = settings.SMTP_FROM_NAME
|
||||
self.use_tls = settings.SMTP_USE_TLS
|
||||
|
||||
def is_configured(self) -> bool:
|
||||
"""Check if SMTP is properly configured."""
|
||||
return settings.is_smtp_configured()
|
||||
|
||||
def _get_smtp_connection(self) -> smtplib.SMTP:
|
||||
"""Create and return SMTP connection."""
|
||||
smtp = smtplib.SMTP(self.host, self.port)
|
||||
smtp.ehlo()
|
||||
|
||||
if self.use_tls:
|
||||
smtp.starttls()
|
||||
smtp.ehlo()
|
||||
|
||||
# Only attempt login if credentials are provided AND server supports AUTH
|
||||
if self.user and self.password:
|
||||
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')
|
||||
|
||||
return smtp
|
||||
|
||||
def send_email(
|
||||
self,
|
||||
to_email: str,
|
||||
subject: str,
|
||||
body_html: str,
|
||||
body_text: str | None = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Send an email.
|
||||
|
||||
Args:
|
||||
to_email: Recipient email address
|
||||
subject: Email subject
|
||||
body_html: HTML body content
|
||||
body_text: Plain text body (optional, generated from HTML if not provided)
|
||||
|
||||
Returns:
|
||||
True if email was sent successfully, False otherwise
|
||||
"""
|
||||
if not self.is_configured():
|
||||
logger.warning('SMTP is not configured, cannot send email')
|
||||
return False
|
||||
|
||||
try:
|
||||
msg = MIMEMultipart('alternative')
|
||||
msg['Subject'] = subject
|
||||
msg['From'] = f'{self.from_name} <{self.from_email}>'
|
||||
msg['To'] = to_email
|
||||
|
||||
# Plain text version
|
||||
if body_text is None:
|
||||
# Simple HTML to text conversion
|
||||
import re
|
||||
|
||||
body_text = re.sub(r'<[^>]+>', '', body_html)
|
||||
body_text = body_text.replace(' ', ' ')
|
||||
body_text = body_text.replace('&', '&')
|
||||
body_text = body_text.replace('<', '<')
|
||||
body_text = body_text.replace('>', '>')
|
||||
|
||||
part1 = MIMEText(body_text, 'plain', 'utf-8')
|
||||
part2 = MIMEText(body_html, 'html', 'utf-8')
|
||||
|
||||
msg.attach(part1)
|
||||
msg.attach(part2)
|
||||
|
||||
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}')
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to send email to {to_email}: {e}')
|
||||
return False
|
||||
|
||||
def send_verification_email(
|
||||
self,
|
||||
to_email: str,
|
||||
verification_token: str,
|
||||
verification_url: str,
|
||||
username: str | None = None,
|
||||
language: str = 'ru',
|
||||
custom_subject: str | None = None,
|
||||
custom_body_html: str | None = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Send email verification email.
|
||||
|
||||
Args:
|
||||
to_email: Recipient email address
|
||||
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, fa)
|
||||
custom_subject: Override subject from admin template
|
||||
custom_body_html: Override body HTML from admin template (already wrapped in base 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)
|
||||
|
||||
full_url = f'{verification_url}?token={verification_token}'
|
||||
expire_hours = settings.get_cabinet_email_verification_expire_hours()
|
||||
|
||||
# Localized content
|
||||
texts = {
|
||||
'ru': {
|
||||
'greeting': f'Здравствуйте{", " + username if username else ""}!',
|
||||
'subject': 'Подтверждение email адреса',
|
||||
'intro': 'Спасибо за регистрацию! Пожалуйста, подтвердите ваш email адрес, нажав на кнопку ниже:',
|
||||
'button': 'Подтвердить email',
|
||||
'or_copy': 'Или скопируйте и вставьте эту ссылку в браузер:',
|
||||
'expires': f'Ссылка действительна в течение {expire_hours} часов.',
|
||||
'ignore': 'Если вы не создавали аккаунт, просто проигнорируйте это письмо.',
|
||||
'regards': 'С уважением,',
|
||||
},
|
||||
'en': {
|
||||
'greeting': f'Hello{", " + username if username else ""}!',
|
||||
'subject': 'Verify your email address',
|
||||
'intro': 'Thank you for registering! Please verify your email address by clicking the button below:',
|
||||
'button': 'Verify Email',
|
||||
'or_copy': 'Or copy and paste this link in your browser:',
|
||||
'expires': f'This link will expire in {expire_hours} hours.',
|
||||
'ignore': "If you didn't create an account, you can safely ignore this email.",
|
||||
'regards': 'Best regards,',
|
||||
},
|
||||
'zh': {
|
||||
'greeting': f'您好{", " + username if username else ""}!',
|
||||
'subject': '验证您的邮箱地址',
|
||||
'intro': '感谢您的注册!请点击下方按钮验证您的邮箱地址:',
|
||||
'button': '验证邮箱',
|
||||
'or_copy': '或将此链接复制并粘贴到浏览器中:',
|
||||
'expires': f'此链接将在 {expire_hours} 小时后过期。',
|
||||
'ignore': '如果您没有创建账户,请忽略此邮件。',
|
||||
'regards': '此致,',
|
||||
},
|
||||
'ua': {
|
||||
'greeting': f'Вітаємо{", " + username if username else ""}!',
|
||||
'subject': 'Підтвердження email адреси',
|
||||
'intro': 'Дякуємо за реєстрацію! Будь ласка, підтвердіть вашу email адресу, натиснувши на кнопку нижче:',
|
||||
'button': 'Підтвердити email',
|
||||
'or_copy': 'Або скопіюйте та вставте це посилання в браузер:',
|
||||
'expires': f'Посилання дійсне протягом {expire_hours} годин.',
|
||||
'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'])
|
||||
|
||||
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; }}
|
||||
.button {{
|
||||
display: inline-block;
|
||||
padding: 12px 24px;
|
||||
background-color: #007bff;
|
||||
color: white !important;
|
||||
text-decoration: none;
|
||||
border-radius: 5px;
|
||||
margin: 20px 0;
|
||||
}}
|
||||
.footer {{ margin-top: 30px; font-size: 12px; color: #666; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h2>{t['greeting']}</h2>
|
||||
<p>{t['intro']}</p>
|
||||
<a href="{full_url}" class="button">{t['button']}</a>
|
||||
<p>{t['or_copy']}</p>
|
||||
<p><a href="{full_url}">{full_url}</a></p>
|
||||
<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)
|
||||
|
||||
def send_password_reset_email(
|
||||
self,
|
||||
to_email: str,
|
||||
reset_token: str,
|
||||
reset_url: str,
|
||||
username: str | None = None,
|
||||
language: str = 'ru',
|
||||
custom_subject: str | None = None,
|
||||
custom_body_html: str | None = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Send password reset email.
|
||||
|
||||
Args:
|
||||
to_email: Recipient email address
|
||||
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, fa)
|
||||
custom_subject: Override subject from admin template
|
||||
custom_body_html: Override body HTML from admin template (already wrapped in base 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)
|
||||
|
||||
full_url = f'{reset_url}?token={reset_token}'
|
||||
expire_hours = settings.get_cabinet_password_reset_expire_hours()
|
||||
|
||||
# Localized content
|
||||
texts = {
|
||||
'ru': {
|
||||
'greeting': f'Здравствуйте{", " + username if username else ""}!',
|
||||
'subject': 'Сброс пароля',
|
||||
'intro': 'Мы получили запрос на сброс вашего пароля. Нажмите на кнопку ниже, чтобы установить новый пароль:',
|
||||
'button': 'Сбросить пароль',
|
||||
'or_copy': 'Или скопируйте и вставьте эту ссылку в браузер:',
|
||||
'expires': f'Ссылка действительна в течение {expire_hours} часов.',
|
||||
'warning': 'Если вы не запрашивали сброс пароля, проигнорируйте это письмо или свяжитесь с поддержкой.',
|
||||
'regards': 'С уважением,',
|
||||
},
|
||||
'en': {
|
||||
'greeting': f'Hello{", " + username if username else ""}!',
|
||||
'subject': 'Reset your password',
|
||||
'intro': 'We received a request to reset your password. Click the button below to set a new password:',
|
||||
'button': 'Reset Password',
|
||||
'or_copy': 'Or copy and paste this link in your browser:',
|
||||
'expires': f'This link will expire in {expire_hours} hour(s).',
|
||||
'warning': "If you didn't request a password reset, please ignore this email or contact support if you're concerned.",
|
||||
'regards': 'Best regards,',
|
||||
},
|
||||
'zh': {
|
||||
'greeting': f'您好{", " + username if username else ""}!',
|
||||
'subject': '重置您的密码',
|
||||
'intro': '我们收到了重置您密码的请求。点击下方按钮设置新密码:',
|
||||
'button': '重置密码',
|
||||
'or_copy': '或将此链接复制并粘贴到浏览器中:',
|
||||
'expires': f'此链接将在 {expire_hours} 小时后过期。',
|
||||
'warning': '如果您没有请求重置密码,请忽略此邮件或联系客服。',
|
||||
'regards': '此致,',
|
||||
},
|
||||
'ua': {
|
||||
'greeting': f'Вітаємо{", " + username if username else ""}!',
|
||||
'subject': 'Скидання пароля',
|
||||
'intro': 'Ми отримали запит на скидання вашого пароля. Натисніть на кнопку нижче, щоб встановити новий пароль:',
|
||||
'button': 'Скинути пароль',
|
||||
'or_copy': 'Або скопіюйте та вставте це посилання в браузер:',
|
||||
'expires': f'Посилання дійсне протягом {expire_hours} годин.',
|
||||
'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'])
|
||||
|
||||
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; }}
|
||||
.button {{
|
||||
display: inline-block;
|
||||
padding: 12px 24px;
|
||||
background-color: #dc3545;
|
||||
color: white !important;
|
||||
text-decoration: none;
|
||||
border-radius: 5px;
|
||||
margin: 20px 0;
|
||||
}}
|
||||
.footer {{ margin-top: 30px; font-size: 12px; color: #666; }}
|
||||
.warning {{ color: #dc3545; font-weight: bold; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h2>{t['greeting']}</h2>
|
||||
<p>{t['intro']}</p>
|
||||
<a href="{full_url}" class="button">{t['button']}</a>
|
||||
<p>{t['or_copy']}</p>
|
||||
<p><a href="{full_url}">{full_url}</a></p>
|
||||
<p>{t['expires']}</p>
|
||||
<p class="warning">{t['warning']}</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)
|
||||
|
||||
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()
|
||||
@@ -0,0 +1,220 @@
|
||||
"""
|
||||
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 typing import Any
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.database import AsyncSessionLocal
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def get_template_override(
|
||||
notification_type: str,
|
||||
language: str,
|
||||
db: AsyncSession | None = None,
|
||||
) -> dict[str, str] | None:
|
||||
"""
|
||||
Get custom email template from the database.
|
||||
|
||||
Returns:
|
||||
Dict with 'subject' and 'body_html' if found, None otherwise.
|
||||
"""
|
||||
try:
|
||||
if db:
|
||||
result = await db.execute(
|
||||
text(
|
||||
'SELECT subject, body_html FROM email_templates '
|
||||
'WHERE notification_type = :ntype AND language = :lang AND is_active = :active'
|
||||
),
|
||||
{'ntype': notification_type, 'lang': language, 'active': True},
|
||||
)
|
||||
row = result.fetchone()
|
||||
if row:
|
||||
return {'subject': row[0], 'body_html': row[1]}
|
||||
return None
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
result = await session.execute(
|
||||
text(
|
||||
'SELECT subject, body_html FROM email_templates '
|
||||
'WHERE notification_type = :ntype AND language = :lang AND is_active = :active'
|
||||
),
|
||||
{'ntype': notification_type, 'lang': language, 'active': True},
|
||||
)
|
||||
row = result.fetchone()
|
||||
if row:
|
||||
return {'subject': row[0], 'body_html': row[1]}
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.debug('Не удалось получить override шаблона %s/%s: %s', notification_type, language, e)
|
||||
return None
|
||||
|
||||
|
||||
async def get_all_overrides(db: AsyncSession) -> list[dict[str, Any]]:
|
||||
"""Get all custom template overrides from the database."""
|
||||
result = await db.execute(
|
||||
text(
|
||||
'SELECT id, notification_type, language, subject, body_html, is_active, created_at, updated_at FROM email_templates ORDER BY notification_type, language'
|
||||
)
|
||||
)
|
||||
rows = result.fetchall()
|
||||
return [
|
||||
{
|
||||
'id': row[0],
|
||||
'notification_type': row[1],
|
||||
'language': row[2],
|
||||
'subject': row[3],
|
||||
'body_html': row[4],
|
||||
'is_active': row[5],
|
||||
'created_at': str(row[6]) if row[6] else None,
|
||||
'updated_at': str(row[7]) if row[7] else None,
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
async def get_overrides_for_type(notification_type: str, db: AsyncSession) -> list[dict[str, Any]]:
|
||||
"""Get all language overrides for a specific notification type."""
|
||||
result = await db.execute(
|
||||
text(
|
||||
'SELECT id, language, subject, body_html, is_active, created_at, updated_at '
|
||||
'FROM email_templates WHERE notification_type = :ntype ORDER BY language'
|
||||
),
|
||||
{'ntype': notification_type},
|
||||
)
|
||||
rows = result.fetchall()
|
||||
return [
|
||||
{
|
||||
'id': row[0],
|
||||
'language': row[1],
|
||||
'subject': row[2],
|
||||
'body_html': row[3],
|
||||
'is_active': row[4],
|
||||
'created_at': str(row[5]) if row[5] else None,
|
||||
'updated_at': str(row[6]) if row[6] else None,
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
async def save_template_override(
|
||||
notification_type: str,
|
||||
language: str,
|
||||
subject: str,
|
||||
body_html: str,
|
||||
db: AsyncSession,
|
||||
) -> dict[str, Any]:
|
||||
"""Save or update a custom email template in the database."""
|
||||
# Check if exists
|
||||
existing = await db.execute(
|
||||
text('SELECT id FROM email_templates WHERE notification_type = :ntype AND language = :lang'),
|
||||
{'ntype': notification_type, 'lang': language},
|
||||
)
|
||||
row = existing.fetchone()
|
||||
|
||||
now = datetime.utcnow()
|
||||
|
||||
if row:
|
||||
# Update
|
||||
await db.execute(
|
||||
text(
|
||||
'UPDATE email_templates SET subject = :subject, body_html = :body_html, '
|
||||
'is_active = :active, updated_at = :now '
|
||||
'WHERE notification_type = :ntype AND language = :lang'
|
||||
),
|
||||
{
|
||||
'subject': subject,
|
||||
'body_html': body_html,
|
||||
'active': True,
|
||||
'now': now,
|
||||
'ntype': notification_type,
|
||||
'lang': language,
|
||||
},
|
||||
)
|
||||
else:
|
||||
# Insert
|
||||
await db.execute(
|
||||
text(
|
||||
'INSERT INTO email_templates (notification_type, language, subject, body_html, is_active, created_at, updated_at) '
|
||||
'VALUES (:ntype, :lang, :subject, :body_html, :active, :now, :now)'
|
||||
),
|
||||
{
|
||||
'ntype': notification_type,
|
||||
'lang': language,
|
||||
'subject': subject,
|
||||
'body_html': body_html,
|
||||
'active': True,
|
||||
'now': now,
|
||||
},
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
return {
|
||||
'notification_type': notification_type,
|
||||
'language': language,
|
||||
'subject': subject,
|
||||
'body_html': body_html,
|
||||
'is_active': True,
|
||||
}
|
||||
|
||||
|
||||
async def get_rendered_override(
|
||||
notification_type: str,
|
||||
language: str,
|
||||
context: dict[str, Any] | None = None,
|
||||
db: AsyncSession | None = None,
|
||||
) -> tuple[str, str] | None:
|
||||
"""
|
||||
Get a custom template override rendered with the base email template.
|
||||
|
||||
Returns:
|
||||
Tuple of (subject, body_html) if override exists, None otherwise.
|
||||
"""
|
||||
override = await get_template_override(notification_type, language, db)
|
||||
if not override:
|
||||
return None
|
||||
|
||||
from .email_templates import EmailNotificationTemplates
|
||||
|
||||
templates = EmailNotificationTemplates()
|
||||
body_html = override['body_html']
|
||||
|
||||
# Simple variable substitution for context vars like {username}, {verification_url}, etc.
|
||||
if context:
|
||||
for key, value in context.items():
|
||||
body_html = body_html.replace(f'{{{key}}}', str(value))
|
||||
|
||||
rendered = templates._get_base_template(body_html, language)
|
||||
subject = override['subject']
|
||||
|
||||
# Also substitute in subject
|
||||
if context:
|
||||
for key, value in context.items():
|
||||
subject = subject.replace(f'{{{key}}}', str(value))
|
||||
|
||||
return (subject, rendered)
|
||||
|
||||
|
||||
async def delete_template_override(
|
||||
notification_type: str,
|
||||
language: str,
|
||||
db: AsyncSession,
|
||||
) -> bool:
|
||||
"""Delete a custom template override (revert to default)."""
|
||||
result = await db.execute(
|
||||
text('DELETE FROM email_templates WHERE notification_type = :ntype AND language = :lang'),
|
||||
{'ntype': notification_type, 'lang': language},
|
||||
)
|
||||
await db.commit()
|
||||
return result.rowcount > 0
|
||||
File diff suppressed because it is too large
Load Diff
+1900
-621
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
||||
"""Database package exports."""
|
||||
|
||||
from .database import (
|
||||
DatabaseManager,
|
||||
batch_ops,
|
||||
close_db,
|
||||
db_manager,
|
||||
get_db,
|
||||
get_db_read_only,
|
||||
get_pool_metrics,
|
||||
init_db,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
'DatabaseManager',
|
||||
'batch_ops',
|
||||
'close_db',
|
||||
'db_manager',
|
||||
'get_db',
|
||||
'get_db_read_only',
|
||||
'get_pool_metrics',
|
||||
'init_db',
|
||||
]
|
||||
@@ -1,8 +1,7 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from sqlalchemy import and_, func, select, update, delete
|
||||
from sqlalchemy import and_, delete, func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
@@ -17,6 +16,7 @@ from app.database.models import (
|
||||
User,
|
||||
)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -26,12 +26,15 @@ async def create_campaign(
|
||||
name: str,
|
||||
start_parameter: str,
|
||||
bonus_type: str,
|
||||
created_by: Optional[int] = None,
|
||||
created_by: int | None = None,
|
||||
balance_bonus_kopeks: int = 0,
|
||||
subscription_duration_days: Optional[int] = None,
|
||||
subscription_traffic_gb: Optional[int] = None,
|
||||
subscription_device_limit: Optional[int] = None,
|
||||
subscription_squads: Optional[List[str]] = None,
|
||||
subscription_duration_days: int | None = None,
|
||||
subscription_traffic_gb: int | None = None,
|
||||
subscription_device_limit: int | None = None,
|
||||
subscription_squads: list[str] | None = None,
|
||||
# Поля для типа "tariff"
|
||||
tariff_id: int | None = None,
|
||||
tariff_duration_days: int | None = None,
|
||||
is_active: bool = True,
|
||||
) -> AdvertisingCampaign:
|
||||
campaign = AdvertisingCampaign(
|
||||
@@ -43,6 +46,8 @@ async def create_campaign(
|
||||
subscription_traffic_gb=subscription_traffic_gb,
|
||||
subscription_device_limit=subscription_device_limit,
|
||||
subscription_squads=subscription_squads or [],
|
||||
tariff_id=tariff_id,
|
||||
tariff_duration_days=tariff_duration_days,
|
||||
created_by=created_by,
|
||||
is_active=is_active,
|
||||
)
|
||||
@@ -52,7 +57,7 @@ async def create_campaign(
|
||||
await db.refresh(campaign)
|
||||
|
||||
logger.info(
|
||||
"📣 Создана рекламная кампания %s (start=%s, bonus=%s)",
|
||||
'📣 Создана рекламная кампания %s (start=%s, bonus=%s)',
|
||||
campaign.name,
|
||||
campaign.start_parameter,
|
||||
campaign.bonus_type,
|
||||
@@ -60,12 +65,13 @@ async def create_campaign(
|
||||
return campaign
|
||||
|
||||
|
||||
async def get_campaign_by_id(
|
||||
db: AsyncSession, campaign_id: int
|
||||
) -> Optional[AdvertisingCampaign]:
|
||||
async def get_campaign_by_id(db: AsyncSession, campaign_id: int) -> AdvertisingCampaign | None:
|
||||
result = await db.execute(
|
||||
select(AdvertisingCampaign)
|
||||
.options(selectinload(AdvertisingCampaign.registrations))
|
||||
.options(
|
||||
selectinload(AdvertisingCampaign.registrations),
|
||||
selectinload(AdvertisingCampaign.tariff),
|
||||
)
|
||||
.where(AdvertisingCampaign.id == campaign_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
@@ -76,10 +82,8 @@ async def get_campaign_by_start_parameter(
|
||||
start_parameter: str,
|
||||
*,
|
||||
only_active: bool = False,
|
||||
) -> Optional[AdvertisingCampaign]:
|
||||
stmt = select(AdvertisingCampaign).where(
|
||||
AdvertisingCampaign.start_parameter == start_parameter
|
||||
)
|
||||
) -> AdvertisingCampaign | None:
|
||||
stmt = select(AdvertisingCampaign).where(AdvertisingCampaign.start_parameter == start_parameter)
|
||||
if only_active:
|
||||
stmt = stmt.where(AdvertisingCampaign.is_active.is_(True))
|
||||
|
||||
@@ -93,10 +97,13 @@ async def get_campaigns_list(
|
||||
offset: int = 0,
|
||||
limit: int = 20,
|
||||
include_inactive: bool = True,
|
||||
) -> List[AdvertisingCampaign]:
|
||||
) -> list[AdvertisingCampaign]:
|
||||
stmt = (
|
||||
select(AdvertisingCampaign)
|
||||
.options(selectinload(AdvertisingCampaign.registrations))
|
||||
.options(
|
||||
selectinload(AdvertisingCampaign.registrations),
|
||||
selectinload(AdvertisingCampaign.tariff),
|
||||
)
|
||||
.order_by(AdvertisingCampaign.created_at.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
@@ -108,9 +115,7 @@ async def get_campaigns_list(
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def get_campaigns_count(
|
||||
db: AsyncSession, *, is_active: Optional[bool] = None
|
||||
) -> int:
|
||||
async def get_campaigns_count(db: AsyncSession, *, is_active: bool | None = None) -> int:
|
||||
stmt = select(func.count(AdvertisingCampaign.id))
|
||||
if is_active is not None:
|
||||
stmt = stmt.where(AdvertisingCampaign.is_active.is_(is_active))
|
||||
@@ -125,15 +130,17 @@ async def update_campaign(
|
||||
**kwargs,
|
||||
) -> AdvertisingCampaign:
|
||||
allowed_fields = {
|
||||
"name",
|
||||
"start_parameter",
|
||||
"bonus_type",
|
||||
"balance_bonus_kopeks",
|
||||
"subscription_duration_days",
|
||||
"subscription_traffic_gb",
|
||||
"subscription_device_limit",
|
||||
"subscription_squads",
|
||||
"is_active",
|
||||
'name',
|
||||
'start_parameter',
|
||||
'bonus_type',
|
||||
'balance_bonus_kopeks',
|
||||
'subscription_duration_days',
|
||||
'subscription_traffic_gb',
|
||||
'subscription_device_limit',
|
||||
'subscription_squads',
|
||||
'tariff_id',
|
||||
'tariff_duration_days',
|
||||
'is_active',
|
||||
}
|
||||
|
||||
update_data = {}
|
||||
@@ -144,33 +151,27 @@ async def update_campaign(
|
||||
if not update_data:
|
||||
return campaign
|
||||
|
||||
update_data["updated_at"] = datetime.utcnow()
|
||||
update_data['updated_at'] = datetime.utcnow()
|
||||
|
||||
await db.execute(
|
||||
update(AdvertisingCampaign)
|
||||
.where(AdvertisingCampaign.id == campaign.id)
|
||||
.values(**update_data)
|
||||
)
|
||||
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('✏️ Обновлена рекламная кампания %s (%s)', campaign.name, 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.execute(delete(AdvertisingCampaign).where(AdvertisingCampaign.id == campaign.id))
|
||||
await db.commit()
|
||||
logger.info("🗑️ Удалена рекламная кампания %s", campaign.name)
|
||||
logger.info('🗑️ Удалена рекламная кампания %s', campaign.name)
|
||||
return True
|
||||
|
||||
|
||||
async def get_campaign_registration_by_user(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
) -> Optional[AdvertisingCampaignRegistration]:
|
||||
) -> AdvertisingCampaignRegistration | None:
|
||||
result = await db.execute(
|
||||
select(AdvertisingCampaignRegistration)
|
||||
.options(selectinload(AdvertisingCampaignRegistration.campaign))
|
||||
@@ -187,7 +188,9 @@ async def record_campaign_registration(
|
||||
user_id: int,
|
||||
bonus_type: str,
|
||||
balance_bonus_kopeks: int = 0,
|
||||
subscription_duration_days: Optional[int] = None,
|
||||
subscription_duration_days: int | None = None,
|
||||
tariff_id: int | None = None,
|
||||
tariff_duration_days: int | None = None,
|
||||
) -> AdvertisingCampaignRegistration:
|
||||
existing = await db.execute(
|
||||
select(AdvertisingCampaignRegistration).where(
|
||||
@@ -207,19 +210,21 @@ async def record_campaign_registration(
|
||||
bonus_type=bonus_type,
|
||||
balance_bonus_kopeks=balance_bonus_kopeks or 0,
|
||||
subscription_duration_days=subscription_duration_days,
|
||||
tariff_id=tariff_id,
|
||||
tariff_duration_days=tariff_duration_days,
|
||||
)
|
||||
db.add(registration)
|
||||
await db.commit()
|
||||
await db.refresh(registration)
|
||||
|
||||
logger.info("📈 Регистрируем пользователя %s в кампании %s", user_id, campaign_id)
|
||||
logger.info('📈 Регистрируем пользователя %s в кампании %s', user_id, campaign_id)
|
||||
return registration
|
||||
|
||||
|
||||
async def get_campaign_statistics(
|
||||
db: AsyncSession,
|
||||
campaign_id: int,
|
||||
) -> Dict[str, Optional[int]]:
|
||||
) -> dict[str, int | None]:
|
||||
registrations_query = select(AdvertisingCampaignRegistration.user_id).where(
|
||||
AdvertisingCampaignRegistration.campaign_id == campaign_id
|
||||
)
|
||||
@@ -228,9 +233,7 @@ async def get_campaign_statistics(
|
||||
result = await db.execute(
|
||||
select(
|
||||
func.count(AdvertisingCampaignRegistration.id),
|
||||
func.coalesce(
|
||||
func.sum(AdvertisingCampaignRegistration.balance_bonus_kopeks), 0
|
||||
),
|
||||
func.coalesce(func.sum(AdvertisingCampaignRegistration.balance_bonus_kopeks), 0),
|
||||
func.max(AdvertisingCampaignRegistration.created_at),
|
||||
).where(AdvertisingCampaignRegistration.campaign_id == campaign_id)
|
||||
)
|
||||
@@ -242,7 +245,7 @@ async def get_campaign_statistics(
|
||||
select(func.count(AdvertisingCampaignRegistration.id)).where(
|
||||
and_(
|
||||
AdvertisingCampaignRegistration.campaign_id == campaign_id,
|
||||
AdvertisingCampaignRegistration.bonus_type == "subscription",
|
||||
AdvertisingCampaignRegistration.bonus_type == 'subscription',
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -295,11 +298,7 @@ async def get_campaign_statistics(
|
||||
SubscriptionConversion.first_payment_amount_kopeks,
|
||||
SubscriptionConversion.converted_at,
|
||||
)
|
||||
.where(
|
||||
SubscriptionConversion.user_id.in_(
|
||||
select(registrations_subquery.c.user_id)
|
||||
)
|
||||
)
|
||||
.where(SubscriptionConversion.user_id.in_(select(registrations_subquery.c.user_id)))
|
||||
.order_by(SubscriptionConversion.converted_at)
|
||||
)
|
||||
conversion_entries = conversions_rows.all()
|
||||
@@ -322,8 +321,8 @@ async def get_campaign_statistics(
|
||||
subscription_payments_total = 0
|
||||
paid_users_from_transactions = set()
|
||||
conversion_user_ids = set()
|
||||
first_payment_amount_by_user: Dict[int, int] = {}
|
||||
first_payment_time_by_user: Dict[int, Optional[datetime]] = {}
|
||||
first_payment_amount_by_user: dict[int, int] = {}
|
||||
first_payment_time_by_user: dict[int, datetime | None] = {}
|
||||
|
||||
for user_id, amount_kopeks, converted_at in conversion_entries:
|
||||
conversion_user_ids.add(user_id)
|
||||
@@ -341,13 +340,8 @@ async def get_campaign_statistics(
|
||||
first_payment_time_by_user[user_id] = created_at
|
||||
else:
|
||||
existing_time = first_payment_time_by_user.get(user_id)
|
||||
if existing_time is None and created_at is not None:
|
||||
first_payment_amount_by_user[user_id] = amount_value
|
||||
first_payment_time_by_user[user_id] = created_at
|
||||
elif (
|
||||
existing_time is not None
|
||||
and created_at is not None
|
||||
and created_at < existing_time
|
||||
if (existing_time is None and created_at is not None) or (
|
||||
existing_time is not None and created_at is not None and created_at < existing_time
|
||||
):
|
||||
first_payment_amount_by_user[user_id] = amount_value
|
||||
first_payment_time_by_user[user_id] = created_at
|
||||
@@ -359,15 +353,11 @@ async def get_campaign_statistics(
|
||||
paid_users_count = max(len(paid_user_ids), paid_users_from_flag)
|
||||
|
||||
conversion_count = conversion_count or len(paid_user_ids)
|
||||
if conversion_count < len(paid_user_ids):
|
||||
conversion_count = len(paid_user_ids)
|
||||
conversion_count = max(conversion_count, len(paid_user_ids))
|
||||
|
||||
avg_first_payment = 0
|
||||
if first_payment_amount_by_user:
|
||||
avg_first_payment = int(
|
||||
sum(first_payment_amount_by_user.values())
|
||||
/ len(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:
|
||||
@@ -442,50 +432,44 @@ async def get_campaign_statistics(
|
||||
avg_revenue_per_user = int(total_revenue / count)
|
||||
|
||||
return {
|
||||
"registrations": count,
|
||||
"balance_issued": total_balance,
|
||||
"subscription_issued": subscription_bonuses_issued,
|
||||
"last_registration": last_registration,
|
||||
"total_revenue_kopeks": total_revenue,
|
||||
"trial_users_count": trial_users_count,
|
||||
"active_trials_count": active_trials_count,
|
||||
"conversion_count": conversion_count,
|
||||
"paid_users_count": paid_users_count,
|
||||
"conversion_rate": conversion_rate,
|
||||
"trial_conversion_rate": trial_conversion_rate,
|
||||
"avg_revenue_per_user_kopeks": avg_revenue_per_user,
|
||||
"avg_first_payment_kopeks": avg_first_payment,
|
||||
'registrations': count,
|
||||
'balance_issued': total_balance,
|
||||
'subscription_issued': subscription_bonuses_issued,
|
||||
'last_registration': last_registration,
|
||||
'total_revenue_kopeks': total_revenue,
|
||||
'trial_users_count': trial_users_count,
|
||||
'active_trials_count': active_trials_count,
|
||||
'conversion_count': conversion_count,
|
||||
'paid_users_count': paid_users_count,
|
||||
'conversion_rate': conversion_rate,
|
||||
'trial_conversion_rate': trial_conversion_rate,
|
||||
'avg_revenue_per_user_kopeks': avg_revenue_per_user,
|
||||
'avg_first_payment_kopeks': avg_first_payment,
|
||||
}
|
||||
|
||||
|
||||
async def get_campaigns_overview(db: AsyncSession) -> Dict[str, int]:
|
||||
async def get_campaigns_overview(db: AsyncSession) -> dict[str, int]:
|
||||
total = await get_campaigns_count(db)
|
||||
active = await get_campaigns_count(db, is_active=True)
|
||||
inactive = await get_campaigns_count(db, is_active=False)
|
||||
|
||||
registrations_result = await db.execute(
|
||||
select(func.count(AdvertisingCampaignRegistration.id))
|
||||
)
|
||||
registrations_result = await db.execute(select(func.count(AdvertisingCampaignRegistration.id)))
|
||||
|
||||
balance_result = await db.execute(
|
||||
select(
|
||||
func.coalesce(
|
||||
func.sum(AdvertisingCampaignRegistration.balance_bonus_kopeks), 0
|
||||
)
|
||||
)
|
||||
select(func.coalesce(func.sum(AdvertisingCampaignRegistration.balance_bonus_kopeks), 0))
|
||||
)
|
||||
|
||||
subscription_result = await db.execute(
|
||||
select(func.count(AdvertisingCampaignRegistration.id)).where(
|
||||
AdvertisingCampaignRegistration.bonus_type == "subscription"
|
||||
AdvertisingCampaignRegistration.bonus_type == 'subscription'
|
||||
)
|
||||
)
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"active": active,
|
||||
"inactive": inactive,
|
||||
"registrations": registrations_result.scalar() or 0,
|
||||
"balance_total": balance_result.scalar() or 0,
|
||||
"subscription_total": subscription_result.scalar() or 0,
|
||||
'total': total,
|
||||
'active': active,
|
||||
'inactive': inactive,
|
||||
'registrations': registrations_result.scalar() or 0,
|
||||
'balance_total': balance_result.scalar() or 0,
|
||||
'subscription_total': subscription_result.scalar() or 0,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
"""CRUD operations for CloudPayments payments."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import CloudPaymentsPayment
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def create_cloudpayments_payment(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int,
|
||||
invoice_id: str,
|
||||
amount_kopeks: int,
|
||||
description: str | None = None,
|
||||
currency: str = 'RUB',
|
||||
payment_url: str | None = None,
|
||||
email: str | None = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
test_mode: bool = False,
|
||||
) -> CloudPaymentsPayment:
|
||||
"""
|
||||
Create a new CloudPayments payment record.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
user_id: Internal user ID
|
||||
invoice_id: Unique invoice ID
|
||||
amount_kopeks: Amount in kopeks
|
||||
description: Payment description
|
||||
currency: Currency code (default RUB)
|
||||
payment_url: Payment widget URL
|
||||
email: User's email
|
||||
metadata: Additional metadata
|
||||
test_mode: Whether this is a test payment
|
||||
|
||||
Returns:
|
||||
Created CloudPaymentsPayment object
|
||||
"""
|
||||
payment = CloudPaymentsPayment(
|
||||
user_id=user_id,
|
||||
invoice_id=invoice_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
currency=currency,
|
||||
description=description,
|
||||
status='pending',
|
||||
is_paid=False,
|
||||
payment_url=payment_url,
|
||||
email=email,
|
||||
metadata_json=metadata,
|
||||
test_mode=test_mode,
|
||||
)
|
||||
|
||||
db.add(payment)
|
||||
await db.flush()
|
||||
await db.refresh(payment)
|
||||
|
||||
logger.debug(
|
||||
'Created CloudPayments payment: id=%s, invoice=%s, amount=%s',
|
||||
payment.id,
|
||||
invoice_id,
|
||||
amount_kopeks,
|
||||
)
|
||||
|
||||
return payment
|
||||
|
||||
|
||||
async def get_cloudpayments_payment_by_invoice_id(
|
||||
db: AsyncSession,
|
||||
invoice_id: str,
|
||||
) -> CloudPaymentsPayment | None:
|
||||
"""Get CloudPayments payment by invoice ID."""
|
||||
result = await db.execute(select(CloudPaymentsPayment).where(CloudPaymentsPayment.invoice_id == invoice_id))
|
||||
return result.scalars().first()
|
||||
|
||||
|
||||
async def get_cloudpayments_payment_by_id(
|
||||
db: AsyncSession,
|
||||
payment_id: int,
|
||||
) -> CloudPaymentsPayment | None:
|
||||
"""Get CloudPayments payment by internal ID."""
|
||||
result = await db.execute(select(CloudPaymentsPayment).where(CloudPaymentsPayment.id == payment_id))
|
||||
return result.scalars().first()
|
||||
|
||||
|
||||
async def get_cloudpayments_payment_by_transaction_id(
|
||||
db: AsyncSession,
|
||||
transaction_id_cp: int,
|
||||
) -> CloudPaymentsPayment | None:
|
||||
"""Get CloudPayments payment by CloudPayments transaction ID."""
|
||||
result = await db.execute(
|
||||
select(CloudPaymentsPayment).where(CloudPaymentsPayment.transaction_id_cp == transaction_id_cp)
|
||||
)
|
||||
return result.scalars().first()
|
||||
|
||||
|
||||
async def update_cloudpayments_payment(
|
||||
db: AsyncSession,
|
||||
payment_id: int,
|
||||
**kwargs: Any,
|
||||
) -> CloudPaymentsPayment | None:
|
||||
"""
|
||||
Update CloudPayments payment record.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
payment_id: Internal payment ID
|
||||
**kwargs: Fields to update
|
||||
|
||||
Returns:
|
||||
Updated payment or None if not found
|
||||
"""
|
||||
payment = await get_cloudpayments_payment_by_id(db, payment_id)
|
||||
if not payment:
|
||||
return None
|
||||
|
||||
for key, value in kwargs.items():
|
||||
if hasattr(payment, key):
|
||||
setattr(payment, key, value)
|
||||
|
||||
payment.updated_at = datetime.utcnow()
|
||||
await db.flush()
|
||||
await db.refresh(payment)
|
||||
|
||||
return payment
|
||||
|
||||
|
||||
async def mark_cloudpayments_payment_as_paid(
|
||||
db: AsyncSession,
|
||||
payment_id: int,
|
||||
*,
|
||||
transaction_id_cp: int | None = None,
|
||||
token: str | None = None,
|
||||
card_first_six: str | None = None,
|
||||
card_last_four: str | None = None,
|
||||
card_type: str | None = None,
|
||||
card_exp_date: str | None = None,
|
||||
email: str | None = None,
|
||||
callback_payload: dict[str, Any] | None = None,
|
||||
) -> CloudPaymentsPayment | None:
|
||||
"""
|
||||
Mark CloudPayments payment as paid.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
payment_id: Internal payment ID
|
||||
transaction_id_cp: CloudPayments transaction ID
|
||||
token: Card token for recurrent payments
|
||||
card_first_six: First 6 digits of card
|
||||
card_last_four: Last 4 digits of card
|
||||
card_type: Card type (Visa, MasterCard, etc.)
|
||||
card_exp_date: Card expiration date
|
||||
email: Payer's email
|
||||
callback_payload: Full webhook payload
|
||||
|
||||
Returns:
|
||||
Updated payment or None if not found
|
||||
"""
|
||||
payment = await get_cloudpayments_payment_by_id(db, payment_id)
|
||||
if not payment:
|
||||
return None
|
||||
|
||||
payment.status = 'completed'
|
||||
payment.is_paid = True
|
||||
payment.paid_at = datetime.utcnow()
|
||||
|
||||
if transaction_id_cp is not None:
|
||||
payment.transaction_id_cp = transaction_id_cp
|
||||
if token:
|
||||
payment.token = token
|
||||
if card_first_six:
|
||||
payment.card_first_six = card_first_six
|
||||
if card_last_four:
|
||||
payment.card_last_four = card_last_four
|
||||
if card_type:
|
||||
payment.card_type = card_type
|
||||
if card_exp_date:
|
||||
payment.card_exp_date = card_exp_date
|
||||
if email:
|
||||
payment.email = email
|
||||
if callback_payload:
|
||||
payment.callback_payload = callback_payload
|
||||
|
||||
payment.updated_at = datetime.utcnow()
|
||||
await db.flush()
|
||||
await db.refresh(payment)
|
||||
|
||||
logger.info(
|
||||
'Marked CloudPayments payment as paid: id=%s, invoice=%s',
|
||||
payment.id,
|
||||
payment.invoice_id,
|
||||
)
|
||||
|
||||
return payment
|
||||
|
||||
|
||||
async def link_cloudpayments_payment_to_transaction(
|
||||
db: AsyncSession,
|
||||
payment_id: int,
|
||||
transaction_id: int,
|
||||
) -> CloudPaymentsPayment | None:
|
||||
"""Link CloudPayments payment to internal transaction."""
|
||||
payment = await get_cloudpayments_payment_by_id(db, payment_id)
|
||||
if not payment:
|
||||
return None
|
||||
|
||||
payment.transaction_id = transaction_id
|
||||
await db.flush()
|
||||
await db.refresh(payment)
|
||||
|
||||
return payment
|
||||
|
||||
|
||||
async def get_user_cloudpayments_payments(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
*,
|
||||
limit: int = 10,
|
||||
offset: int = 0,
|
||||
) -> list[CloudPaymentsPayment]:
|
||||
"""Get CloudPayments payments for a user."""
|
||||
result = await db.execute(
|
||||
select(CloudPaymentsPayment)
|
||||
.where(CloudPaymentsPayment.user_id == user_id)
|
||||
.order_by(CloudPaymentsPayment.created_at.desc())
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
@@ -0,0 +1,225 @@
|
||||
import logging
|
||||
from collections.abc import Sequence
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import and_, delete, desc, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import ContestAttempt, ContestRound, ContestTemplate, User
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Templates
|
||||
async def get_template_by_id(db: AsyncSession, template_id: int) -> ContestTemplate | None:
|
||||
result = await db.execute(select(ContestTemplate).where(ContestTemplate.id == template_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_template_by_slug(db: AsyncSession, slug: str) -> ContestTemplate | None:
|
||||
result = await db.execute(select(ContestTemplate).where(ContestTemplate.slug == slug))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def list_templates(db: AsyncSession, enabled_only: bool = True) -> list[ContestTemplate]:
|
||||
query = select(ContestTemplate).order_by(ContestTemplate.id)
|
||||
if enabled_only:
|
||||
query = query.where(ContestTemplate.is_enabled.is_(True))
|
||||
result = await db.execute(query)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def upsert_template(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
slug: str,
|
||||
name: str,
|
||||
description: str = '',
|
||||
prize_type: str = 'days',
|
||||
prize_value: str = '1',
|
||||
max_winners: int = 1,
|
||||
attempts_per_user: int = 1,
|
||||
times_per_day: int = 1,
|
||||
schedule_times: str | None = None,
|
||||
cooldown_hours: int = 24,
|
||||
payload: dict | None = None,
|
||||
is_enabled: bool | None = None,
|
||||
) -> ContestTemplate:
|
||||
template = await get_template_by_slug(db, slug)
|
||||
if not template:
|
||||
template = ContestTemplate(slug=slug)
|
||||
db.add(template)
|
||||
|
||||
template.name = name
|
||||
template.description = description
|
||||
template.prize_type = prize_type
|
||||
template.prize_value = prize_value
|
||||
template.max_winners = max_winners
|
||||
template.attempts_per_user = attempts_per_user
|
||||
template.times_per_day = times_per_day
|
||||
template.schedule_times = schedule_times
|
||||
template.cooldown_hours = cooldown_hours
|
||||
template.payload = payload or {}
|
||||
if is_enabled is not None:
|
||||
template.is_enabled = is_enabled
|
||||
await db.commit()
|
||||
await db.refresh(template)
|
||||
return template
|
||||
|
||||
|
||||
async def update_template_fields(
|
||||
db: AsyncSession,
|
||||
template: ContestTemplate,
|
||||
**fields: object,
|
||||
) -> ContestTemplate:
|
||||
for key, value in fields.items():
|
||||
if hasattr(template, key):
|
||||
setattr(template, key, value)
|
||||
await db.commit()
|
||||
await db.refresh(template)
|
||||
return template
|
||||
|
||||
|
||||
# Rounds
|
||||
async def create_round(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
template: ContestTemplate,
|
||||
starts_at: datetime,
|
||||
ends_at: datetime,
|
||||
payload: dict,
|
||||
) -> ContestRound:
|
||||
round_obj = ContestRound(
|
||||
template_id=template.id,
|
||||
starts_at=starts_at,
|
||||
ends_at=ends_at,
|
||||
status='active',
|
||||
payload=payload,
|
||||
max_winners=template.max_winners,
|
||||
attempts_per_user=template.attempts_per_user,
|
||||
)
|
||||
db.add(round_obj)
|
||||
await db.commit()
|
||||
await db.refresh(round_obj)
|
||||
return round_obj
|
||||
|
||||
|
||||
async def get_active_rounds(db: AsyncSession) -> list[ContestRound]:
|
||||
now = datetime.utcnow()
|
||||
result = await db.execute(
|
||||
select(ContestRound)
|
||||
.options(selectinload(ContestRound.template))
|
||||
.where(
|
||||
and_(
|
||||
ContestRound.status == 'active',
|
||||
ContestRound.starts_at <= now,
|
||||
ContestRound.ends_at >= now,
|
||||
)
|
||||
)
|
||||
.order_by(ContestRound.starts_at)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_active_round_by_template(db: AsyncSession, template_id: int) -> ContestRound | None:
|
||||
now = datetime.utcnow()
|
||||
result = await db.execute(
|
||||
select(ContestRound)
|
||||
.options(selectinload(ContestRound.template))
|
||||
.where(
|
||||
and_(
|
||||
ContestRound.template_id == template_id,
|
||||
ContestRound.status == 'active',
|
||||
ContestRound.starts_at <= now,
|
||||
ContestRound.ends_at >= now,
|
||||
)
|
||||
)
|
||||
.order_by(desc(ContestRound.starts_at))
|
||||
)
|
||||
return result.scalars().first()
|
||||
|
||||
|
||||
async def finish_round(db: AsyncSession, round_obj: ContestRound) -> ContestRound:
|
||||
round_obj.status = 'finished'
|
||||
await db.commit()
|
||||
await db.refresh(round_obj)
|
||||
return round_obj
|
||||
|
||||
|
||||
async def increment_winner_count(db: AsyncSession, round_obj: ContestRound) -> ContestRound:
|
||||
round_obj.winners_count += 1
|
||||
await db.commit()
|
||||
await db.refresh(round_obj)
|
||||
return round_obj
|
||||
|
||||
|
||||
# Attempts
|
||||
async def get_attempt(db: AsyncSession, round_id: int, user_id: int) -> ContestAttempt | None:
|
||||
result = await db.execute(
|
||||
select(ContestAttempt).where(
|
||||
and_(
|
||||
ContestAttempt.round_id == round_id,
|
||||
ContestAttempt.user_id == user_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def create_attempt(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
round_id: int,
|
||||
user_id: int,
|
||||
answer: str | None,
|
||||
is_winner: bool,
|
||||
) -> ContestAttempt:
|
||||
attempt = ContestAttempt(
|
||||
round_id=round_id,
|
||||
user_id=user_id,
|
||||
answer=answer,
|
||||
is_winner=is_winner,
|
||||
)
|
||||
db.add(attempt)
|
||||
await db.commit()
|
||||
await db.refresh(attempt)
|
||||
return attempt
|
||||
|
||||
|
||||
async def update_attempt(
|
||||
db: AsyncSession,
|
||||
attempt: ContestAttempt,
|
||||
*,
|
||||
answer: str | None = None,
|
||||
is_winner: bool = False,
|
||||
) -> ContestAttempt:
|
||||
"""Update existing attempt with answer and winner status."""
|
||||
if answer is not None:
|
||||
attempt.answer = answer
|
||||
attempt.is_winner = is_winner
|
||||
await db.commit()
|
||||
await db.refresh(attempt)
|
||||
return attempt
|
||||
|
||||
|
||||
async def clear_attempts(db: AsyncSession, round_id: int) -> int:
|
||||
result = await db.execute(delete(ContestAttempt).where(ContestAttempt.round_id == round_id))
|
||||
deleted_count = result.rowcount
|
||||
await db.commit()
|
||||
return deleted_count
|
||||
|
||||
|
||||
async def list_winners(db: AsyncSession, round_id: int) -> Sequence[tuple[User, ContestAttempt]]:
|
||||
result = await db.execute(
|
||||
select(User, ContestAttempt)
|
||||
.join(ContestAttempt, ContestAttempt.user_id == User.id)
|
||||
.where(
|
||||
and_(
|
||||
ContestAttempt.round_id == round_id,
|
||||
ContestAttempt.is_winner.is_(True),
|
||||
)
|
||||
)
|
||||
)
|
||||
return result.all()
|
||||
@@ -1,12 +1,13 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
from sqlalchemy import select, and_
|
||||
|
||||
from sqlalchemy import and_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import CryptoBotPayment
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -16,14 +17,13 @@ async def create_cryptobot_payment(
|
||||
invoice_id: str,
|
||||
amount: str,
|
||||
asset: str,
|
||||
status: str = "active",
|
||||
description: Optional[str] = None,
|
||||
payload: Optional[str] = None,
|
||||
bot_invoice_url: Optional[str] = None,
|
||||
mini_app_invoice_url: Optional[str] = None,
|
||||
web_app_invoice_url: Optional[str] = None
|
||||
status: str = 'active',
|
||||
description: str | None = None,
|
||||
payload: str | None = None,
|
||||
bot_invoice_url: str | None = None,
|
||||
mini_app_invoice_url: str | None = None,
|
||||
web_app_invoice_url: str | None = None,
|
||||
) -> CryptoBotPayment:
|
||||
|
||||
payment = CryptoBotPayment(
|
||||
user_id=user_id,
|
||||
invoice_id=invoice_id,
|
||||
@@ -34,22 +34,18 @@ async def create_cryptobot_payment(
|
||||
payload=payload,
|
||||
bot_invoice_url=bot_invoice_url,
|
||||
mini_app_invoice_url=mini_app_invoice_url,
|
||||
web_app_invoice_url=web_app_invoice_url
|
||||
web_app_invoice_url=web_app_invoice_url,
|
||||
)
|
||||
|
||||
|
||||
db.add(payment)
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
|
||||
logger.info(f"Создан CryptoBot платеж: {invoice_id} на {amount} {asset} для пользователя {user_id}")
|
||||
|
||||
logger.info(f'Создан CryptoBot платеж: {invoice_id} на {amount} {asset} для пользователя {user_id}')
|
||||
return payment
|
||||
|
||||
|
||||
async def get_cryptobot_payment_by_invoice_id(
|
||||
db: AsyncSession,
|
||||
invoice_id: str
|
||||
) -> Optional[CryptoBotPayment]:
|
||||
|
||||
async def get_cryptobot_payment_by_invoice_id(db: AsyncSession, invoice_id: str) -> CryptoBotPayment | None:
|
||||
result = await db.execute(
|
||||
select(CryptoBotPayment)
|
||||
.options(selectinload(CryptoBotPayment.user))
|
||||
@@ -58,72 +54,55 @@ async def get_cryptobot_payment_by_invoice_id(
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_cryptobot_payment_by_id(
|
||||
db: AsyncSession,
|
||||
payment_id: int
|
||||
) -> Optional[CryptoBotPayment]:
|
||||
|
||||
async def get_cryptobot_payment_by_id(db: AsyncSession, payment_id: int) -> CryptoBotPayment | None:
|
||||
result = await db.execute(
|
||||
select(CryptoBotPayment)
|
||||
.options(selectinload(CryptoBotPayment.user))
|
||||
.where(CryptoBotPayment.id == payment_id)
|
||||
select(CryptoBotPayment).options(selectinload(CryptoBotPayment.user)).where(CryptoBotPayment.id == payment_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def update_cryptobot_payment_status(
|
||||
db: AsyncSession,
|
||||
invoice_id: str,
|
||||
status: str,
|
||||
paid_at: Optional[datetime] = None
|
||||
) -> Optional[CryptoBotPayment]:
|
||||
|
||||
db: AsyncSession, invoice_id: str, status: str, paid_at: datetime | None = None
|
||||
) -> CryptoBotPayment | None:
|
||||
payment = await get_cryptobot_payment_by_invoice_id(db, invoice_id)
|
||||
|
||||
|
||||
if not payment:
|
||||
return None
|
||||
|
||||
|
||||
payment.status = status
|
||||
payment.updated_at = datetime.utcnow()
|
||||
|
||||
if status == "paid" and paid_at:
|
||||
|
||||
if status == 'paid' and paid_at:
|
||||
payment.paid_at = paid_at
|
||||
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
|
||||
logger.info(f"Обновлен статус CryptoBot платежа {invoice_id}: {status}")
|
||||
|
||||
logger.info(f'Обновлен статус CryptoBot платежа {invoice_id}: {status}')
|
||||
return payment
|
||||
|
||||
|
||||
async def link_cryptobot_payment_to_transaction(
|
||||
db: AsyncSession,
|
||||
invoice_id: str,
|
||||
transaction_id: int
|
||||
) -> Optional[CryptoBotPayment]:
|
||||
|
||||
db: AsyncSession, invoice_id: str, transaction_id: int
|
||||
) -> CryptoBotPayment | None:
|
||||
payment = await get_cryptobot_payment_by_invoice_id(db, invoice_id)
|
||||
|
||||
|
||||
if not payment:
|
||||
return None
|
||||
|
||||
|
||||
payment.transaction_id = transaction_id
|
||||
payment.updated_at = datetime.utcnow()
|
||||
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
|
||||
logger.info(f"Связан CryptoBot платеж {invoice_id} с транзакцией {transaction_id}")
|
||||
|
||||
logger.info(f'Связан CryptoBot платеж {invoice_id} с транзакцией {transaction_id}')
|
||||
return payment
|
||||
|
||||
|
||||
async def get_user_cryptobot_payments(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
limit: int = 50,
|
||||
offset: int = 0
|
||||
) -> List[CryptoBotPayment]:
|
||||
|
||||
db: AsyncSession, user_id: int, limit: int = 50, offset: int = 0
|
||||
) -> list[CryptoBotPayment]:
|
||||
result = await db.execute(
|
||||
select(CryptoBotPayment)
|
||||
.where(CryptoBotPayment.user_id == user_id)
|
||||
@@ -134,23 +113,15 @@ async def get_user_cryptobot_payments(
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def get_pending_cryptobot_payments(
|
||||
db: AsyncSession,
|
||||
older_than_hours: int = 24
|
||||
) -> List[CryptoBotPayment]:
|
||||
|
||||
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)
|
||||
|
||||
|
||||
result = await db.execute(
|
||||
select(CryptoBotPayment)
|
||||
.options(selectinload(CryptoBotPayment.user))
|
||||
.where(
|
||||
and_(
|
||||
CryptoBotPayment.status == "active",
|
||||
CryptoBotPayment.created_at < cutoff_time
|
||||
)
|
||||
)
|
||||
.where(and_(CryptoBotPayment.status == 'active', CryptoBotPayment.created_at < cutoff_time))
|
||||
.order_by(CryptoBotPayment.created_at)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
@@ -2,7 +2,6 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Optional
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -11,6 +10,7 @@ from sqlalchemy.orm import selectinload
|
||||
from app.database.crud.promo_offer_log import log_promo_offer_action
|
||||
from app.database.models import DiscountOffer
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -18,13 +18,13 @@ async def upsert_discount_offer(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int,
|
||||
subscription_id: Optional[int],
|
||||
subscription_id: int | None,
|
||||
notification_type: str,
|
||||
discount_percent: int,
|
||||
bonus_amount_kopeks: int,
|
||||
valid_hours: int,
|
||||
effect_type: str = "percent_discount",
|
||||
extra_data: Optional[dict] = None,
|
||||
effect_type: str = 'percent_discount',
|
||||
extra_data: dict | None = None,
|
||||
) -> DiscountOffer:
|
||||
"""Create or refresh a discount offer for a user."""
|
||||
|
||||
@@ -35,7 +35,7 @@ async def upsert_discount_offer(
|
||||
.where(
|
||||
DiscountOffer.user_id == user_id,
|
||||
DiscountOffer.notification_type == notification_type,
|
||||
DiscountOffer.is_active == True, # noqa: E712
|
||||
DiscountOffer.is_active == True,
|
||||
)
|
||||
.order_by(DiscountOffer.created_at.desc())
|
||||
)
|
||||
@@ -67,7 +67,7 @@ async def upsert_discount_offer(
|
||||
return offer
|
||||
|
||||
|
||||
async def get_offer_by_id(db: AsyncSession, offer_id: int) -> Optional[DiscountOffer]:
|
||||
async def get_offer_by_id(db: AsyncSession, offer_id: int) -> DiscountOffer | None:
|
||||
result = await db.execute(
|
||||
select(DiscountOffer)
|
||||
.options(
|
||||
@@ -84,10 +84,10 @@ async def list_discount_offers(
|
||||
*,
|
||||
offset: int = 0,
|
||||
limit: int = 50,
|
||||
user_id: Optional[int] = None,
|
||||
notification_type: Optional[str] = None,
|
||||
is_active: Optional[bool] = None,
|
||||
) -> List[DiscountOffer]:
|
||||
user_id: int | None = None,
|
||||
notification_type: str | None = None,
|
||||
is_active: bool | None = None,
|
||||
) -> list[DiscountOffer]:
|
||||
stmt = (
|
||||
select(DiscountOffer)
|
||||
.options(
|
||||
@@ -113,7 +113,7 @@ async def list_discount_offers(
|
||||
async def list_active_discount_offers_for_user(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
) -> List[DiscountOffer]:
|
||||
) -> list[DiscountOffer]:
|
||||
"""Return active (not yet claimed) offers for a user."""
|
||||
|
||||
now = datetime.utcnow()
|
||||
@@ -125,7 +125,7 @@ async def list_active_discount_offers_for_user(
|
||||
)
|
||||
.where(
|
||||
DiscountOffer.user_id == user_id,
|
||||
DiscountOffer.is_active == True, # noqa: E712
|
||||
DiscountOffer.is_active == True,
|
||||
DiscountOffer.expires_at > now,
|
||||
)
|
||||
.order_by(DiscountOffer.expires_at.asc())
|
||||
@@ -138,9 +138,9 @@ async def list_active_discount_offers_for_user(
|
||||
async def count_discount_offers(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: Optional[int] = None,
|
||||
notification_type: Optional[str] = None,
|
||||
is_active: Optional[bool] = None,
|
||||
user_id: int | None = None,
|
||||
notification_type: str | None = None,
|
||||
is_active: bool | None = None,
|
||||
) -> int:
|
||||
stmt = select(func.count(DiscountOffer.id))
|
||||
|
||||
@@ -159,7 +159,7 @@ async def mark_offer_claimed(
|
||||
db: AsyncSession,
|
||||
offer: DiscountOffer,
|
||||
*,
|
||||
details: Optional[dict] = None,
|
||||
details: dict | None = None,
|
||||
) -> DiscountOffer:
|
||||
offer.claimed_at = datetime.utcnow()
|
||||
offer.is_active = False
|
||||
@@ -171,7 +171,7 @@ async def mark_offer_claimed(
|
||||
db,
|
||||
user_id=offer.user_id,
|
||||
offer_id=offer.id,
|
||||
action="claimed",
|
||||
action='claimed',
|
||||
source=offer.notification_type,
|
||||
percent=offer.discount_percent,
|
||||
effect_type=offer.effect_type,
|
||||
@@ -179,7 +179,7 @@ async def mark_offer_claimed(
|
||||
)
|
||||
except Exception as exc: # pragma: no cover - defensive logging
|
||||
logger.warning(
|
||||
"Failed to record promo offer claim log for offer %s: %s",
|
||||
'Failed to record promo offer claim log for offer %s: %s',
|
||||
offer.id,
|
||||
exc,
|
||||
)
|
||||
@@ -187,7 +187,7 @@ async def mark_offer_claimed(
|
||||
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",
|
||||
'Failed to rollback session after promo offer claim log failure: %s',
|
||||
rollback_error,
|
||||
)
|
||||
|
||||
@@ -198,7 +198,7 @@ async def deactivate_expired_offers(db: AsyncSession) -> int:
|
||||
now = datetime.utcnow()
|
||||
result = await db.execute(
|
||||
select(DiscountOffer).where(
|
||||
DiscountOffer.is_active == True, # noqa: E712
|
||||
DiscountOffer.is_active == True,
|
||||
DiscountOffer.expires_at < now,
|
||||
)
|
||||
)
|
||||
@@ -213,41 +213,41 @@ async def deactivate_expired_offers(db: AsyncSession) -> int:
|
||||
count += 1
|
||||
log_payloads.append(
|
||||
{
|
||||
"user_id": offer.user_id,
|
||||
"offer_id": offer.id,
|
||||
"source": offer.notification_type,
|
||||
"percent": offer.discount_percent,
|
||||
"effect_type": offer.effect_type,
|
||||
'user_id': offer.user_id,
|
||||
'offer_id': offer.id,
|
||||
'source': offer.notification_type,
|
||||
'percent': offer.discount_percent,
|
||||
'effect_type': offer.effect_type,
|
||||
}
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
for payload in log_payloads:
|
||||
if not payload.get("user_id"):
|
||||
if not payload.get('user_id'):
|
||||
continue
|
||||
try:
|
||||
await log_promo_offer_action(
|
||||
db,
|
||||
user_id=payload["user_id"],
|
||||
offer_id=payload["offer_id"],
|
||||
action="disabled",
|
||||
source=payload.get("source"),
|
||||
percent=payload.get("percent"),
|
||||
effect_type=payload.get("effect_type"),
|
||||
details={"reason": "offer_expired"},
|
||||
user_id=payload['user_id'],
|
||||
offer_id=payload['offer_id'],
|
||||
action='disabled',
|
||||
source=payload.get('source'),
|
||||
percent=payload.get('percent'),
|
||||
effect_type=payload.get('effect_type'),
|
||||
details={'reason': 'offer_expired'},
|
||||
)
|
||||
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"),
|
||||
'Failed to record promo offer disable log for offer %s: %s',
|
||||
payload.get('offer_id'),
|
||||
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",
|
||||
'Failed to rollback session after promo offer disable log failure: %s',
|
||||
rollback_error,
|
||||
)
|
||||
|
||||
@@ -257,8 +257,8 @@ async def deactivate_expired_offers(db: AsyncSession) -> int:
|
||||
async def get_latest_claimed_offer_for_user(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
source: Optional[str] = None,
|
||||
) -> Optional[DiscountOffer]:
|
||||
source: str | None = None,
|
||||
) -> DiscountOffer | None:
|
||||
stmt = (
|
||||
select(DiscountOffer)
|
||||
.where(
|
||||
|
||||
+17
-23
@@ -1,19 +1,18 @@
|
||||
import logging
|
||||
from collections.abc import Iterable
|
||||
from datetime import datetime
|
||||
from typing import Iterable, Optional
|
||||
|
||||
from sqlalchemy import delete, func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import FaqPage, FaqSetting
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def get_faq_setting(db: AsyncSession, language: str) -> Optional[FaqSetting]:
|
||||
result = await db.execute(
|
||||
select(FaqSetting).where(FaqSetting.language == language)
|
||||
)
|
||||
async def get_faq_setting(db: AsyncSession, language: str) -> FaqSetting | None:
|
||||
result = await db.execute(select(FaqSetting).where(FaqSetting.language == language))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
@@ -34,9 +33,9 @@ async def set_faq_enabled(db: AsyncSession, language: str, enabled: bool) -> Faq
|
||||
await db.refresh(setting)
|
||||
|
||||
logger.info(
|
||||
"✅ Статус FAQ для языка %s обновлен: %s",
|
||||
'✅ Статус FAQ для языка %s обновлен: %s',
|
||||
language,
|
||||
"enabled" if setting.is_enabled else "disabled",
|
||||
'enabled' if setting.is_enabled else 'disabled',
|
||||
)
|
||||
|
||||
return setting
|
||||
@@ -64,7 +63,7 @@ async def get_faq_pages(
|
||||
return pages
|
||||
|
||||
|
||||
async def get_faq_page_by_id(db: AsyncSession, page_id: int) -> Optional[FaqPage]:
|
||||
async def get_faq_page_by_id(db: AsyncSession, page_id: int) -> FaqPage | None:
|
||||
result = await db.execute(select(FaqPage).where(FaqPage.id == page_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
@@ -75,13 +74,11 @@ async def create_faq_page(
|
||||
language: str,
|
||||
title: str,
|
||||
content: str,
|
||||
display_order: Optional[int] = None,
|
||||
display_order: int | None = None,
|
||||
is_active: bool = True,
|
||||
) -> FaqPage:
|
||||
if display_order is None:
|
||||
result = await db.execute(
|
||||
select(func.max(FaqPage.display_order)).where(FaqPage.language == language)
|
||||
)
|
||||
result = await db.execute(select(func.max(FaqPage.display_order)).where(FaqPage.language == language))
|
||||
max_order = result.scalar() or 0
|
||||
display_order = max_order + 1
|
||||
|
||||
@@ -97,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 %s для языка %s', page.id, language)
|
||||
|
||||
return page
|
||||
|
||||
@@ -106,10 +103,10 @@ async def update_faq_page(
|
||||
db: AsyncSession,
|
||||
page: FaqPage,
|
||||
*,
|
||||
title: Optional[str] = None,
|
||||
content: Optional[str] = None,
|
||||
display_order: Optional[int] = None,
|
||||
is_active: Optional[bool] = None,
|
||||
title: str | None = None,
|
||||
content: str | None = None,
|
||||
display_order: int | None = None,
|
||||
is_active: bool | None = None,
|
||||
) -> FaqPage:
|
||||
if title is not None:
|
||||
page.title = title
|
||||
@@ -125,7 +122,7 @@ async def update_faq_page(
|
||||
await db.commit()
|
||||
await db.refresh(page)
|
||||
|
||||
logger.info("✅ Страница FAQ %s обновлена", page.id)
|
||||
logger.info('✅ Страница FAQ %s обновлена', page.id)
|
||||
|
||||
return page
|
||||
|
||||
@@ -133,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 %s удалена', page_id)
|
||||
|
||||
|
||||
async def bulk_update_order(
|
||||
@@ -142,9 +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.utcnow())
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
"""CRUD операции для платежей Freekassa."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import FreekassaPayment
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def create_freekassa_payment(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int,
|
||||
order_id: str,
|
||||
amount_kopeks: int,
|
||||
currency: str = 'RUB',
|
||||
description: str | None = None,
|
||||
payment_url: str | None = None,
|
||||
expires_at: datetime | None = None,
|
||||
metadata_json: str | None = None,
|
||||
) -> FreekassaPayment:
|
||||
"""Создает запись о платеже Freekassa."""
|
||||
payment = FreekassaPayment(
|
||||
user_id=user_id,
|
||||
order_id=order_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
currency=currency,
|
||||
description=description,
|
||||
payment_url=payment_url,
|
||||
expires_at=expires_at,
|
||||
metadata_json=json.loads(metadata_json) if metadata_json else None,
|
||||
status='pending',
|
||||
is_paid=False,
|
||||
)
|
||||
db.add(payment)
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
logger.info(f'Создан платеж Freekassa: order_id={order_id}, user_id={user_id}')
|
||||
return payment
|
||||
|
||||
|
||||
async def get_freekassa_payment_by_order_id(db: AsyncSession, order_id: str) -> FreekassaPayment | None:
|
||||
"""Получает платеж по order_id."""
|
||||
result = await db.execute(select(FreekassaPayment).where(FreekassaPayment.order_id == order_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_freekassa_payment_by_fk_order_id(db: AsyncSession, freekassa_order_id: str) -> FreekassaPayment | None:
|
||||
"""Получает платеж по ID от Freekassa (intid)."""
|
||||
result = await db.execute(select(FreekassaPayment).where(FreekassaPayment.freekassa_order_id == freekassa_order_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_freekassa_payment_by_id(db: AsyncSession, payment_id: int) -> FreekassaPayment | None:
|
||||
"""Получает платеж по ID."""
|
||||
result = await db.execute(select(FreekassaPayment).where(FreekassaPayment.id == payment_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def update_freekassa_payment_status(
|
||||
db: AsyncSession,
|
||||
payment: FreekassaPayment,
|
||||
*,
|
||||
status: str,
|
||||
is_paid: bool = False,
|
||||
freekassa_order_id: str | None = None,
|
||||
payment_system_id: int | None = None,
|
||||
callback_payload: dict | None = None,
|
||||
transaction_id: int | None = None,
|
||||
) -> FreekassaPayment:
|
||||
"""Обновляет статус платежа."""
|
||||
payment.status = status
|
||||
payment.is_paid = is_paid
|
||||
payment.updated_at = datetime.utcnow()
|
||||
|
||||
if is_paid:
|
||||
payment.paid_at = datetime.utcnow()
|
||||
if freekassa_order_id:
|
||||
payment.freekassa_order_id = freekassa_order_id
|
||||
if payment_system_id is not None:
|
||||
payment.payment_system_id = payment_system_id
|
||||
if callback_payload:
|
||||
payment.callback_payload = callback_payload
|
||||
if transaction_id:
|
||||
payment.transaction_id = transaction_id
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
logger.info(f'Обновлен статус платежа Freekassa: order_id={payment.order_id}, status={status}, is_paid={is_paid}')
|
||||
return payment
|
||||
|
||||
|
||||
async def get_pending_freekassa_payments(db: AsyncSession, user_id: int) -> list[FreekassaPayment]:
|
||||
"""Получает незавершенные платежи пользователя."""
|
||||
result = await db.execute(
|
||||
select(FreekassaPayment).where(
|
||||
FreekassaPayment.user_id == user_id,
|
||||
FreekassaPayment.status == 'pending',
|
||||
FreekassaPayment.is_paid == False,
|
||||
)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_user_freekassa_payments(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
limit: int = 10,
|
||||
offset: int = 0,
|
||||
) -> list[FreekassaPayment]:
|
||||
"""Получает платежи пользователя с пагинацией."""
|
||||
result = await db.execute(
|
||||
select(FreekassaPayment)
|
||||
.where(FreekassaPayment.user_id == user_id)
|
||||
.order_by(FreekassaPayment.created_at.desc())
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_expired_pending_payments(
|
||||
db: AsyncSession,
|
||||
) -> list[FreekassaPayment]:
|
||||
"""Получает просроченные платежи в статусе pending."""
|
||||
now = datetime.utcnow()
|
||||
result = await db.execute(
|
||||
select(FreekassaPayment).where(
|
||||
FreekassaPayment.status == 'pending',
|
||||
FreekassaPayment.is_paid == False,
|
||||
FreekassaPayment.expires_at < now,
|
||||
)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
@@ -0,0 +1,171 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import HeleketPayment
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def create_heleket_payment(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int,
|
||||
uuid: str,
|
||||
order_id: str,
|
||||
amount: str,
|
||||
currency: str,
|
||||
status: str,
|
||||
payer_amount: str | None = None,
|
||||
payer_currency: str | None = None,
|
||||
exchange_rate: float | None = None,
|
||||
discount_percent: int | None = None,
|
||||
payment_url: str | None = None,
|
||||
expires_at: datetime | None = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> HeleketPayment:
|
||||
payment = HeleketPayment(
|
||||
user_id=user_id,
|
||||
uuid=uuid,
|
||||
order_id=order_id,
|
||||
amount=amount,
|
||||
currency=currency,
|
||||
status=status,
|
||||
payer_amount=payer_amount,
|
||||
payer_currency=payer_currency,
|
||||
exchange_rate=exchange_rate,
|
||||
discount_percent=discount_percent,
|
||||
payment_url=payment_url,
|
||||
expires_at=expires_at,
|
||||
metadata_json=metadata or {},
|
||||
)
|
||||
|
||||
db.add(payment)
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
|
||||
logger.info(
|
||||
'Создан Heleket платеж: uuid=%s order_id=%s amount=%s %s для пользователя %s',
|
||||
uuid,
|
||||
order_id,
|
||||
amount,
|
||||
currency,
|
||||
user_id,
|
||||
)
|
||||
|
||||
return payment
|
||||
|
||||
|
||||
async def get_heleket_payment_by_uuid(
|
||||
db: AsyncSession,
|
||||
uuid: str,
|
||||
) -> HeleketPayment | None:
|
||||
result = await db.execute(
|
||||
select(HeleketPayment).options(selectinload(HeleketPayment.user)).where(HeleketPayment.uuid == uuid)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_heleket_payment_by_order_id(
|
||||
db: AsyncSession,
|
||||
order_id: str,
|
||||
) -> HeleketPayment | None:
|
||||
result = await db.execute(
|
||||
select(HeleketPayment).options(selectinload(HeleketPayment.user)).where(HeleketPayment.order_id == order_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_heleket_payment_by_id(
|
||||
db: AsyncSession,
|
||||
payment_id: int,
|
||||
) -> HeleketPayment | None:
|
||||
result = await db.execute(
|
||||
select(HeleketPayment).options(selectinload(HeleketPayment.user)).where(HeleketPayment.id == payment_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def update_heleket_payment(
|
||||
db: AsyncSession,
|
||||
uuid: str,
|
||||
*,
|
||||
status: str | None = None,
|
||||
payer_amount: str | None = None,
|
||||
payer_currency: str | None = None,
|
||||
exchange_rate: float | None = None,
|
||||
discount_percent: int | None = None,
|
||||
paid_at: datetime | None = None,
|
||||
payment_url: str | None = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> HeleketPayment | None:
|
||||
payment = await get_heleket_payment_by_uuid(db, uuid)
|
||||
|
||||
if not payment:
|
||||
logger.error('Heleket платеж с uuid=%s не найден', uuid)
|
||||
return None
|
||||
|
||||
if status is not None:
|
||||
payment.status = status
|
||||
if payer_amount is not None:
|
||||
payment.payer_amount = payer_amount
|
||||
if payer_currency is not None:
|
||||
payment.payer_currency = payer_currency
|
||||
if exchange_rate is not None:
|
||||
payment.exchange_rate = exchange_rate
|
||||
if discount_percent is not None:
|
||||
payment.discount_percent = discount_percent
|
||||
if payment_url is not None:
|
||||
payment.payment_url = payment_url
|
||||
if metadata is not None:
|
||||
existing = dict(payment.metadata_json or {})
|
||||
existing.update(metadata)
|
||||
payment.metadata_json = existing
|
||||
if paid_at is not None:
|
||||
payment.paid_at = paid_at
|
||||
|
||||
payment.updated_at = datetime.utcnow()
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
return payment
|
||||
|
||||
|
||||
async def link_heleket_payment_to_transaction(
|
||||
db: AsyncSession,
|
||||
uuid: str,
|
||||
transaction_id: int,
|
||||
) -> HeleketPayment | None:
|
||||
payment = await get_heleket_payment_by_uuid(db, uuid)
|
||||
|
||||
if not payment:
|
||||
logger.error('Не найден Heleket платеж для связи с транзакцией: %s', uuid)
|
||||
return None
|
||||
|
||||
payment.transaction_id = transaction_id
|
||||
payment.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
|
||||
logger.info(
|
||||
'Heleket платеж %s связан с транзакцией %s',
|
||||
uuid,
|
||||
transaction_id,
|
||||
)
|
||||
|
||||
return payment
|
||||
@@ -0,0 +1,142 @@
|
||||
"""CRUD операции для платежей KassaAI."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import KassaAiPayment
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def create_kassa_ai_payment(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int,
|
||||
order_id: str,
|
||||
amount_kopeks: int,
|
||||
currency: str = 'RUB',
|
||||
description: str | None = None,
|
||||
payment_url: str | None = None,
|
||||
payment_system_id: int | None = None,
|
||||
expires_at: datetime | None = None,
|
||||
metadata_json: str | None = None,
|
||||
) -> KassaAiPayment:
|
||||
"""Создает запись о платеже KassaAI."""
|
||||
payment = KassaAiPayment(
|
||||
user_id=user_id,
|
||||
order_id=order_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
currency=currency,
|
||||
description=description,
|
||||
payment_url=payment_url,
|
||||
payment_system_id=payment_system_id,
|
||||
expires_at=expires_at,
|
||||
metadata_json=json.loads(metadata_json) if metadata_json else None,
|
||||
status='pending',
|
||||
is_paid=False,
|
||||
)
|
||||
db.add(payment)
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
logger.info(f'Создан платеж KassaAI: order_id={order_id}, user_id={user_id}')
|
||||
return payment
|
||||
|
||||
|
||||
async def get_kassa_ai_payment_by_order_id(db: AsyncSession, order_id: str) -> KassaAiPayment | None:
|
||||
"""Получает платеж по order_id."""
|
||||
result = await db.execute(select(KassaAiPayment).where(KassaAiPayment.order_id == order_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_kassa_ai_payment_by_external_order_id(db: AsyncSession, kassa_ai_order_id: str) -> KassaAiPayment | None:
|
||||
"""Получает платеж по ID от KassaAI (orderId)."""
|
||||
result = await db.execute(select(KassaAiPayment).where(KassaAiPayment.kassa_ai_order_id == kassa_ai_order_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_kassa_ai_payment_by_id(db: AsyncSession, payment_id: int) -> KassaAiPayment | None:
|
||||
"""Получает платеж по ID."""
|
||||
result = await db.execute(select(KassaAiPayment).where(KassaAiPayment.id == payment_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def update_kassa_ai_payment_status(
|
||||
db: AsyncSession,
|
||||
payment: KassaAiPayment,
|
||||
*,
|
||||
status: str,
|
||||
is_paid: bool = False,
|
||||
kassa_ai_order_id: str | None = None,
|
||||
payment_system_id: int | None = None,
|
||||
callback_payload: dict | None = None,
|
||||
transaction_id: int | None = None,
|
||||
) -> KassaAiPayment:
|
||||
"""Обновляет статус платежа."""
|
||||
payment.status = status
|
||||
payment.is_paid = is_paid
|
||||
payment.updated_at = datetime.utcnow()
|
||||
|
||||
if is_paid:
|
||||
payment.paid_at = datetime.utcnow()
|
||||
if kassa_ai_order_id:
|
||||
payment.kassa_ai_order_id = kassa_ai_order_id
|
||||
if payment_system_id is not None:
|
||||
payment.payment_system_id = payment_system_id
|
||||
if callback_payload:
|
||||
payment.callback_payload = callback_payload
|
||||
if transaction_id:
|
||||
payment.transaction_id = transaction_id
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
logger.info(f'Обновлен статус платежа KassaAI: order_id={payment.order_id}, status={status}, is_paid={is_paid}')
|
||||
return payment
|
||||
|
||||
|
||||
async def get_pending_kassa_ai_payments(db: AsyncSession, user_id: int) -> list[KassaAiPayment]:
|
||||
"""Получает незавершенные платежи пользователя."""
|
||||
result = await db.execute(
|
||||
select(KassaAiPayment).where(
|
||||
KassaAiPayment.user_id == user_id,
|
||||
KassaAiPayment.status == 'pending',
|
||||
KassaAiPayment.is_paid == False,
|
||||
)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_user_kassa_ai_payments(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
limit: int = 10,
|
||||
offset: int = 0,
|
||||
) -> list[KassaAiPayment]:
|
||||
"""Получает платежи пользователя с пагинацией."""
|
||||
result = await db.execute(
|
||||
select(KassaAiPayment)
|
||||
.where(KassaAiPayment.user_id == user_id)
|
||||
.order_by(KassaAiPayment.created_at.desc())
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_expired_pending_kassa_ai_payments(
|
||||
db: AsyncSession,
|
||||
) -> list[KassaAiPayment]:
|
||||
"""Получает просроченные платежи в статусе pending."""
|
||||
now = datetime.utcnow()
|
||||
result = await db.execute(
|
||||
select(KassaAiPayment).where(
|
||||
KassaAiPayment.status == 'pending',
|
||||
KassaAiPayment.is_paid == False,
|
||||
KassaAiPayment.expires_at < now,
|
||||
)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
@@ -1,6 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional, Sequence
|
||||
from collections.abc import Sequence
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -20,8 +20,8 @@ async def count_main_menu_buttons(db: AsyncSession) -> int:
|
||||
async def get_main_menu_buttons(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
limit: Optional[int] = None,
|
||||
offset: Optional[int] = None,
|
||||
limit: int | None = None,
|
||||
offset: int | None = None,
|
||||
) -> list[MainMenuButton]:
|
||||
stmt = select(MainMenuButton).order_by(
|
||||
MainMenuButton.display_order.asc(),
|
||||
@@ -37,12 +37,8 @@ async def get_main_menu_buttons(
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_main_menu_button_by_id(
|
||||
db: AsyncSession, button_id: int
|
||||
) -> MainMenuButton | None:
|
||||
result = await db.execute(
|
||||
select(MainMenuButton).where(MainMenuButton.id == button_id)
|
||||
)
|
||||
async def get_main_menu_button_by_id(db: AsyncSession, button_id: int) -> MainMenuButton | None:
|
||||
result = await db.execute(select(MainMenuButton).where(MainMenuButton.id == button_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
@@ -68,7 +64,7 @@ async def create_main_menu_button(
|
||||
action_value: str,
|
||||
visibility: MainMenuButtonVisibility | str = MainMenuButtonVisibility.ALL,
|
||||
is_active: bool = True,
|
||||
display_order: Optional[int] = None,
|
||||
display_order: int | None = None,
|
||||
) -> MainMenuButton:
|
||||
if display_order is None:
|
||||
display_order = await get_next_display_order(db)
|
||||
@@ -77,8 +73,7 @@ async def create_main_menu_button(
|
||||
text=text,
|
||||
action_type=_enum_value(action_type, MainMenuButtonActionType),
|
||||
action_value=action_value,
|
||||
visibility=_enum_value(visibility, MainMenuButtonVisibility)
|
||||
or MainMenuButtonVisibility.ALL.value,
|
||||
visibility=_enum_value(visibility, MainMenuButtonVisibility) or MainMenuButtonVisibility.ALL.value,
|
||||
is_active=bool(is_active),
|
||||
display_order=int(display_order),
|
||||
)
|
||||
@@ -93,12 +88,12 @@ async def update_main_menu_button(
|
||||
db: AsyncSession,
|
||||
button: MainMenuButton,
|
||||
*,
|
||||
text: Optional[str] = None,
|
||||
text: str | None = None,
|
||||
action_type: MainMenuButtonActionType | str | None = None,
|
||||
action_value: Optional[str] = None,
|
||||
action_value: str | None = None,
|
||||
visibility: MainMenuButtonVisibility | str | None = None,
|
||||
is_active: Optional[bool] = None,
|
||||
display_order: Optional[int] = None,
|
||||
is_active: bool | None = None,
|
||||
display_order: int | None = None,
|
||||
) -> MainMenuButton:
|
||||
if text is not None:
|
||||
button.text = text
|
||||
@@ -132,9 +127,7 @@ async def reorder_main_menu_buttons(
|
||||
|
||||
order_map = {int(button_id): index for index, button_id in enumerate(ordered_ids)}
|
||||
|
||||
result = await db.execute(
|
||||
select(MainMenuButton).where(MainMenuButton.id.in_(order_map.keys()))
|
||||
)
|
||||
result = await db.execute(select(MainMenuButton).where(MainMenuButton.id.in_(order_map.keys())))
|
||||
buttons = result.scalars().all()
|
||||
|
||||
for button in buttons:
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -8,6 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.config import settings
|
||||
from app.database.models import MulenPayPayment
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -18,11 +18,11 @@ async def create_mulenpay_payment(
|
||||
amount_kopeks: int,
|
||||
uuid: str,
|
||||
description: str,
|
||||
payment_url: Optional[str],
|
||||
mulen_payment_id: Optional[int],
|
||||
payment_url: str | None,
|
||||
mulen_payment_id: int | None,
|
||||
currency: str,
|
||||
status: str,
|
||||
metadata: Optional[dict] = None,
|
||||
metadata: dict | None = None,
|
||||
) -> MulenPayPayment:
|
||||
payment = MulenPayPayment(
|
||||
user_id=user_id,
|
||||
@@ -41,7 +41,7 @@ async def create_mulenpay_payment(
|
||||
await db.refresh(payment)
|
||||
|
||||
logger.info(
|
||||
"Создан %s платеж #%s (uuid=%s) на сумму %s копеек для пользователя %s",
|
||||
'Создан %s платеж #%s (uuid=%s) на сумму %s копеек для пользователя %s',
|
||||
settings.get_mulenpay_display_name(),
|
||||
payment.mulen_payment_id,
|
||||
uuid,
|
||||
@@ -52,32 +52,18 @@ async def create_mulenpay_payment(
|
||||
return payment
|
||||
|
||||
|
||||
async def get_mulenpay_payment_by_local_id(
|
||||
db: AsyncSession, payment_id: int
|
||||
) -> Optional[MulenPayPayment]:
|
||||
result = await db.execute(
|
||||
select(MulenPayPayment).where(MulenPayPayment.id == payment_id)
|
||||
)
|
||||
async def get_mulenpay_payment_by_local_id(db: AsyncSession, payment_id: int) -> MulenPayPayment | None:
|
||||
result = await db.execute(select(MulenPayPayment).where(MulenPayPayment.id == payment_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_mulenpay_payment_by_uuid(
|
||||
db: AsyncSession, uuid: str
|
||||
) -> Optional[MulenPayPayment]:
|
||||
result = await db.execute(
|
||||
select(MulenPayPayment).where(MulenPayPayment.uuid == uuid)
|
||||
)
|
||||
async def get_mulenpay_payment_by_uuid(db: AsyncSession, uuid: str) -> MulenPayPayment | None:
|
||||
result = await db.execute(select(MulenPayPayment).where(MulenPayPayment.uuid == uuid))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_mulenpay_payment_by_mulen_id(
|
||||
db: AsyncSession, mulen_payment_id: int
|
||||
) -> Optional[MulenPayPayment]:
|
||||
result = await db.execute(
|
||||
select(MulenPayPayment).where(
|
||||
MulenPayPayment.mulen_payment_id == mulen_payment_id
|
||||
)
|
||||
)
|
||||
async def get_mulenpay_payment_by_mulen_id(db: AsyncSession, mulen_payment_id: int) -> MulenPayPayment | None:
|
||||
result = await db.execute(select(MulenPayPayment).where(MulenPayPayment.mulen_payment_id == mulen_payment_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
@@ -86,10 +72,11 @@ async def update_mulenpay_payment_status(
|
||||
*,
|
||||
payment: MulenPayPayment,
|
||||
status: str,
|
||||
is_paid: Optional[bool] = None,
|
||||
paid_at: Optional[datetime] = None,
|
||||
callback_payload: Optional[dict] = None,
|
||||
mulen_payment_id: Optional[int] = None,
|
||||
is_paid: bool | None = None,
|
||||
paid_at: datetime | None = None,
|
||||
callback_payload: dict | None = None,
|
||||
mulen_payment_id: int | None = None,
|
||||
metadata: dict | None = None,
|
||||
) -> MulenPayPayment:
|
||||
payment.status = status
|
||||
if is_paid is not None:
|
||||
@@ -100,6 +87,8 @@ async def update_mulenpay_payment_status(
|
||||
payment.callback_payload = callback_payload
|
||||
if mulen_payment_id is not None and not payment.mulen_payment_id:
|
||||
payment.mulen_payment_id = mulen_payment_id
|
||||
if metadata is not None:
|
||||
payment.metadata_json = metadata
|
||||
|
||||
payment.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
@@ -107,6 +96,19 @@ async def update_mulenpay_payment_status(
|
||||
return payment
|
||||
|
||||
|
||||
async def update_mulenpay_payment_metadata(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
payment: MulenPayPayment,
|
||||
metadata: dict,
|
||||
) -> MulenPayPayment:
|
||||
payment.metadata_json = metadata
|
||||
payment.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
return payment
|
||||
|
||||
|
||||
async def link_mulenpay_payment_to_transaction(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, delete
|
||||
|
||||
from app.database.models import SentNotification
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -13,17 +14,19 @@ async def notification_sent(
|
||||
user_id: int,
|
||||
subscription_id: int,
|
||||
notification_type: str,
|
||||
days_before: Optional[int] = None,
|
||||
days_before: int | None = None,
|
||||
) -> bool:
|
||||
result = await db.execute(
|
||||
select(SentNotification).where(
|
||||
select(SentNotification)
|
||||
.where(
|
||||
SentNotification.user_id == user_id,
|
||||
SentNotification.subscription_id == subscription_id,
|
||||
SentNotification.notification_type == notification_type,
|
||||
SentNotification.days_before == days_before,
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
return result.scalar_one_or_none() is not None
|
||||
return result.scalars().first() is not None
|
||||
|
||||
|
||||
async def record_notification(
|
||||
@@ -31,8 +34,11 @@ async def record_notification(
|
||||
user_id: int,
|
||||
subscription_id: int,
|
||||
notification_type: str,
|
||||
days_before: Optional[int] = None,
|
||||
days_before: int | None = None,
|
||||
) -> None:
|
||||
already_exists = await notification_sent(db, user_id, subscription_id, notification_type, days_before)
|
||||
if already_exists:
|
||||
return
|
||||
notification = SentNotification(
|
||||
user_id=user_id,
|
||||
subscription_id=subscription_id,
|
||||
@@ -44,11 +50,7 @@ async def record_notification(
|
||||
|
||||
|
||||
async def clear_notifications(db: AsyncSession, subscription_id: int) -> None:
|
||||
await db.execute(
|
||||
delete(SentNotification).where(
|
||||
SentNotification.subscription_id == subscription_id
|
||||
)
|
||||
)
|
||||
await db.execute(delete(SentNotification).where(SentNotification.subscription_id == subscription_id))
|
||||
await db.commit()
|
||||
|
||||
|
||||
|
||||
+45
-56
@@ -4,13 +4,14 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Optional
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import Pal24Payment
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -20,15 +21,15 @@ async def create_pal24_payment(
|
||||
user_id: int,
|
||||
bill_id: str,
|
||||
amount_kopeks: int,
|
||||
description: Optional[str],
|
||||
description: str | None,
|
||||
status: str,
|
||||
type_: str,
|
||||
currency: str,
|
||||
link_url: Optional[str],
|
||||
link_page_url: Optional[str],
|
||||
order_id: Optional[str] = None,
|
||||
ttl: Optional[int] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
link_url: str | None,
|
||||
link_page_url: str | None,
|
||||
order_id: str | None = None,
|
||||
ttl: int | None = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> Pal24Payment:
|
||||
payment = Pal24Payment(
|
||||
user_id=user_id,
|
||||
@@ -50,7 +51,7 @@ async def create_pal24_payment(
|
||||
await db.refresh(payment)
|
||||
|
||||
logger.info(
|
||||
"Создан Pal24 платеж #%s для пользователя %s: %s копеек (статус %s)",
|
||||
'Создан Pal24 платеж #%s для пользователя %s: %s копеек (статус %s)',
|
||||
payment.id,
|
||||
user_id,
|
||||
amount_kopeks,
|
||||
@@ -60,24 +61,18 @@ async def create_pal24_payment(
|
||||
return payment
|
||||
|
||||
|
||||
async def get_pal24_payment_by_id(db: AsyncSession, payment_id: int) -> Optional[Pal24Payment]:
|
||||
result = await db.execute(
|
||||
select(Pal24Payment).where(Pal24Payment.id == payment_id)
|
||||
)
|
||||
async def get_pal24_payment_by_id(db: AsyncSession, payment_id: int) -> Pal24Payment | None:
|
||||
result = await db.execute(select(Pal24Payment).where(Pal24Payment.id == payment_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_pal24_payment_by_bill_id(db: AsyncSession, bill_id: str) -> Optional[Pal24Payment]:
|
||||
result = await db.execute(
|
||||
select(Pal24Payment).where(Pal24Payment.bill_id == bill_id)
|
||||
)
|
||||
async def get_pal24_payment_by_bill_id(db: AsyncSession, bill_id: str) -> Pal24Payment | None:
|
||||
result = await db.execute(select(Pal24Payment).where(Pal24Payment.bill_id == bill_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_pal24_payment_by_order_id(db: AsyncSession, order_id: str) -> Optional[Pal24Payment]:
|
||||
result = await db.execute(
|
||||
select(Pal24Payment).where(Pal24Payment.order_id == order_id)
|
||||
)
|
||||
async def get_pal24_payment_by_order_id(db: AsyncSession, order_id: str) -> Pal24Payment | None:
|
||||
result = await db.execute(select(Pal24Payment).where(Pal24Payment.order_id == order_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
@@ -86,55 +81,54 @@ async def update_pal24_payment_status(
|
||||
payment: Pal24Payment,
|
||||
*,
|
||||
status: str,
|
||||
is_active: Optional[bool] = None,
|
||||
is_paid: Optional[bool] = None,
|
||||
paid_at: Optional[datetime] = None,
|
||||
payment_id: Optional[str] = None,
|
||||
payment_status: Optional[str] = None,
|
||||
payment_method: Optional[str] = None,
|
||||
balance_amount: Optional[str] = None,
|
||||
balance_currency: Optional[str] = None,
|
||||
payer_account: Optional[str] = None,
|
||||
callback_payload: Optional[Dict[str, Any]] = None,
|
||||
is_active: bool | None = None,
|
||||
is_paid: bool | None = None,
|
||||
paid_at: datetime | None = None,
|
||||
payment_id: str | None = None,
|
||||
payment_status: str | None = None,
|
||||
payment_method: str | None = None,
|
||||
balance_amount: str | None = None,
|
||||
balance_currency: str | None = None,
|
||||
payer_account: str | None = None,
|
||||
callback_payload: dict[str, Any] | None = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> Pal24Payment:
|
||||
update_values: Dict[str, Any] = {
|
||||
"status": status,
|
||||
update_values: dict[str, Any] = {
|
||||
'status': status,
|
||||
}
|
||||
|
||||
if is_active is not None:
|
||||
update_values["is_active"] = is_active
|
||||
update_values['is_active'] = is_active
|
||||
if is_paid is not None:
|
||||
update_values["is_paid"] = is_paid
|
||||
update_values['is_paid'] = is_paid
|
||||
if paid_at is not None:
|
||||
update_values["paid_at"] = paid_at
|
||||
update_values['paid_at'] = paid_at
|
||||
if payment_id is not None:
|
||||
update_values["payment_id"] = payment_id
|
||||
update_values['payment_id'] = payment_id
|
||||
if payment_status is not None:
|
||||
update_values["payment_status"] = payment_status
|
||||
update_values['payment_status'] = payment_status
|
||||
if payment_method is not None:
|
||||
update_values["payment_method"] = payment_method
|
||||
update_values['payment_method'] = payment_method
|
||||
if balance_amount is not None:
|
||||
update_values["balance_amount"] = balance_amount
|
||||
update_values['balance_amount'] = balance_amount
|
||||
if balance_currency is not None:
|
||||
update_values["balance_currency"] = balance_currency
|
||||
update_values['balance_currency'] = balance_currency
|
||||
if payer_account is not None:
|
||||
update_values["payer_account"] = payer_account
|
||||
update_values['payer_account'] = payer_account
|
||||
if callback_payload is not None:
|
||||
update_values["callback_payload"] = callback_payload
|
||||
update_values['callback_payload'] = callback_payload
|
||||
if metadata is not None:
|
||||
update_values['metadata_json'] = metadata
|
||||
|
||||
update_values["last_status"] = status
|
||||
update_values['last_status'] = status
|
||||
|
||||
await db.execute(
|
||||
update(Pal24Payment)
|
||||
.where(Pal24Payment.id == payment.id)
|
||||
.values(**update_values)
|
||||
)
|
||||
await db.execute(update(Pal24Payment).where(Pal24Payment.id == payment.id).values(**update_values))
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
|
||||
logger.info(
|
||||
"Обновлен Pal24 платеж %s: статус=%s, is_paid=%s",
|
||||
'Обновлен Pal24 платеж %s: статус=%s, is_paid=%s',
|
||||
payment.bill_id,
|
||||
payment.status,
|
||||
payment.is_paid,
|
||||
@@ -148,17 +142,12 @@ async def link_pal24_payment_to_transaction(
|
||||
payment: Pal24Payment,
|
||||
transaction_id: int,
|
||||
) -> Pal24Payment:
|
||||
await db.execute(
|
||||
update(Pal24Payment)
|
||||
.where(Pal24Payment.id == payment.id)
|
||||
.values(transaction_id=transaction_id)
|
||||
)
|
||||
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",
|
||||
'Pal24 платеж %s привязан к транзакции %s',
|
||||
payment.bill_id,
|
||||
transaction_id,
|
||||
)
|
||||
return payment
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user