Compare commits
863 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 799c83dd84 | |||
| 4cc18cbc9a | |||
| 4645be53cb | |||
| 79ea398d1d | |||
| 30b1402b54 | |||
| 15d848c1ca | |||
| c9877a3cbe | |||
| 68499ee043 | |||
| bdb61613de | |||
| 59383bdbd8 | |||
| 5d4a94b8ce | |||
| 0c07812ecc | |||
| eb9dba3f47 | |||
| 6c4e035146 | |||
| e78b1040a5 | |||
| b6c7f91a7c | |||
| e998059d81 | |||
| 764e063bfe | |||
| 784616b349 | |||
| b4b10c998c | |||
| 366df18c54 | |||
| 7883efc3d6 | |||
| 6881d97bbb | |||
| 90278f1f5f | |||
| df5b1a072d | |||
| 10e231e52e | |||
| 7c20fde4e8 | |||
| fcf3a2c806 | |||
| 88997492c3 | |||
| 327d4f4d15 | |||
| cf7cc5a84e | |||
| 28f524b762 | |||
| c4dc43e054 | |||
| 767e965028 | |||
| d39063b22f | |||
| ea5d932476 | |||
| acc1323a54 | |||
| 58bfaeaddb | |||
| df5415f30b | |||
| 330d670f3f | |||
| 41cb122a2f | |||
| 1b3e6f2f11 | |||
| 6fa49485d9 | |||
| 71aa023133 | |||
| e567c02658 | |||
| f393dc0840 | |||
| 5dc4b0ec15 | |||
| e68760cc66 | |||
| d9552799c1 | |||
| c75ec0b22a | |||
| 27309f53d9 | |||
| 4193f717ee | |||
| d297985b0b | |||
| 6dcf3a9f0d | |||
| 094609005a | |||
| eb18994b7d | |||
| ff21b27b98 | |||
| 4247981c98 | |||
| c30972f6a7 | |||
| 7628fb9f6e | |||
| 4c48eadebc | |||
| 6ea3860a2f | |||
| 1b8ef69a1b | |||
| 9d710050ad | |||
| 491a7e1c42 | |||
| 7eb8d4e153 | |||
| f63720467a | |||
| 516be6e600 | |||
| 0807a9ff19 | |||
| a93a32f3a7 | |||
| 68de66f526 | |||
| 15aba2b3db | |||
| fa78fa6d09 | |||
| 11f8af003f | |||
| 11ef714e0d | |||
| 909a4039c4 | |||
| bf646112df | |||
| 8a6650e57c | |||
| 25e8c9f8fc | |||
| 1f0fef114b | |||
| be6036e879 | |||
| bba85a309a | |||
| 448be6e512 | |||
| 871ceb866c | |||
| 8e61fe4774 | |||
| d4dfa235e5 | |||
| 97ec39aa80 | |||
| 61a97220d3 | |||
| 2d04f2aa28 | |||
| d6e79161e7 | |||
| 45fd543206 | |||
| ba0a5e9abd | |||
| d712ab8301 | |||
| 1e2a7e3096 | |||
| 64a684cd2f | |||
| e4c207ecff | |||
| 80914c1af7 | |||
| e1822800ab | |||
| 68773b7e77 | |||
| 10538e7351 | |||
| a9687912df | |||
| 46c1a69456 | |||
| bf2b2f1c56 | |||
| 9ac6da490d | |||
| ad87c5fb5e | |||
| 7ac73e5745 | |||
| 61be89743d | |||
| d174d9a927 | |||
| 4048aebb9f | |||
| bfd66c42c1 | |||
| 351c95bac1 | |||
| 1d43ae5e25 | |||
| 476b89fe8e | |||
| 14e13177b5 | |||
| 760c833b74 | |||
| 1a476c49c1 | |||
| 454b83138e | |||
| 2de438426a | |||
| 6039db997c | |||
| 940959c951 | |||
| e688110129 | |||
| 57dc1ff47f | |||
| fc42916b10 | |||
| 5893874776 | |||
| 60305d8d5b | |||
| 07ef3b46d9 | |||
| f9d58e964c | |||
| d3c14ac303 | |||
| fda9f3beec | |||
| 27365b3c75 | |||
| 3dac332a9f | |||
| c5124b97b6 | |||
| ee2e79db31 | |||
| d05ff678ab | |||
| fcaa9dfb27 | |||
| c30c2feee1 | |||
| 640da34736 | |||
| 93bb8e0eb4 | |||
| 7d9ced8f4f | |||
| b5998ea9d2 | |||
| aabadf1ffd | |||
| e5e5bb3354 | |||
| ea41b0af7a | |||
| 3193ffbd1b | |||
| 5da01cc6df | |||
| 887ea9cf5a | |||
| bee4aa4284 | |||
| b167ed3dd1 | |||
| 6cec024e46 | |||
| 2094886990 | |||
| b0fd38d60c | |||
| 3a680b41b0 | |||
| 02e40bd6f7 | |||
| 19dabf3851 | |||
| eaf3a07579 | |||
| be1da976e1 | |||
| a1ffd5bda6 | |||
| d58a80f3ea | |||
| 45c7afe34c | |||
| e43a8d6ce4 | |||
| e94b93d0c1 | |||
| 2ad26a9156 | |||
| 3383b9c790 | |||
| 6acaf18203 | |||
| 019fbc12b6 | |||
| 5156d635f0 | |||
| f77922522a | |||
| 0db00a8f90 | |||
| fe54640885 | |||
| ec8eaf52bf | |||
| fe5f5ded96 | |||
| 2cb6d731e9 | |||
| 184c52d4ea | |||
| 8e85e244cb | |||
| 43a326a98c | |||
| d9de15a5a0 | |||
| 17ce64037f | |||
| 79793c47bb | |||
| 7091eb9c14 | |||
| dc1e96bbe9 | |||
| 1e37fd9dd2 | |||
| 9aa22af339 | |||
| 26637f0ae5 | |||
| 6d67cad3e7 | |||
| 90d9df8f0e | |||
| ef654a09bb | |||
| 74f3b388d8 | |||
| 5af1f358b2 | |||
| 994325360c | |||
| f0e7f8e3be | |||
| 40d8a6dc8b | |||
| 6488dcfcb2 | |||
| 9ec5f7f59e | |||
| 0621a3febc | |||
| ebd6bee05e | |||
| 119f463c36 | |||
| a3903a252e | |||
| 65ba50c2cf | |||
| cc54a7ad2f | |||
| 7b0403a307 | |||
| 142ff14a50 | |||
| 29a3b395b6 | |||
| 49871f82f3 | |||
| efa3a5d457 | |||
| 0b86f379b4 | |||
| 1cae7130bc | |||
| 45410168af | |||
| 5482e609f8 | |||
| e79f598d17 | |||
| 056070b6a4 | |||
| 8b53c73ce8 | |||
| e6ebf81752 | |||
| 11b8ab1959 | |||
| 17e9259eb1 | |||
| 02c30f8e7e | |||
| 15c7cc2a58 | |||
| f2dbab6171 | |||
| 17af51ce0b | |||
| 8f7fa76e6a | |||
| 4648a82da9 | |||
| 5be82f2d78 | |||
| 9e3aa23f69 | |||
| 46da31d89c | |||
| 5f219c33e6 | |||
| 94fcf20d17 | |||
| 9d39901f78 | |||
| 5cf3f2f76e | |||
| 2f90f9134d | |||
| c57de1081a | |||
| 33d5155a8d | |||
| 9828ff0845 | |||
| da6f746b09 | |||
| 165965d8ea | |||
| e7e01ce9c8 | |||
| c4c49571ec | |||
| 4a63124818 | |||
| d6fa86b870 | |||
| 55d281b0e3 | |||
| a42bc9b281 | |||
| 5bc5567ab1 | |||
| d88ca980ec | |||
| 0ef4f55304 | |||
| 5070bb34e8 | |||
| 02d38d7891 | |||
| c46cc85144 | |||
| 071c23dd52 | |||
| 5f3e426750 | |||
| a9eee19c95 | |||
| 552a8ff8d8 | |||
| bec78beb25 | |||
| a6561a4788 | |||
| c49acc956f | |||
| 4c40b5b370 | |||
| 7c1a142653 | |||
| a161e2f904 | |||
| ad260d9fe0 | |||
| 3fd3bce2cf | |||
| ad6522f547 | |||
| 9ea533a864 | |||
| 61bb8fcafd | |||
| ff5bba3fc5 | |||
| cc1c8bacb4 | |||
| b707b7995b | |||
| a076dfb550 | |||
| 91ac90c2ae | |||
| b12544d3ea | |||
| 38018514dc | |||
| 924d6bc09c | |||
| 1021c2cdcd | |||
| fa01819674 | |||
| eeed2d6369 | |||
| a194be0843 | |||
| aa1cd3829c | |||
| 6c2c25d2cc | |||
| 0b61c7fe48 | |||
| b6745508da | |||
| f5391c3159 | |||
| 9a81932d2b | |||
| 8b50fde9aa | |||
| 8b924df64f | |||
| 7102c50f52 | |||
| e4c65ca220 | |||
| 557dbf3ebe | |||
| 462f7a99b9 | |||
| c68c4e5984 | |||
| f00a051bb3 | |||
| b94e3edf80 | |||
| 51ca3e42b7 | |||
| 943e9a86aa | |||
| c4da591731 | |||
| 287a43ba65 | |||
| 070321230b | |||
| 8886d0dea2 | |||
| d3819c492f | |||
| 3cbb9ef024 | |||
| 41633af763 | |||
| 916ad9d567 | |||
| ccd9ab02c5 | |||
| d0a9cfe6a9 | |||
| 333a3c5901 | |||
| 0de6418bca | |||
| e9b98b837a | |||
| 97be4afbff | |||
| 9ca24efe43 | |||
| 116c8453bb | |||
| 4e7438b9f9 | |||
| c4794db1dd | |||
| 1ffb8a5b85 | |||
| 7ab1a7b88d | |||
| e3f932afe4 | |||
| 5ca2f62854 | |||
| 8afe613451 | |||
| 8de9c6e532 | |||
| b69fcbde11 | |||
| 44d6b6b266 | |||
| 4234769e92 | |||
| c2cabbee09 | |||
| 067b1b6716 | |||
| 5b64046137 | |||
| 085a61721a | |||
| 4c2e11e64b | |||
| 7c5f35b1cf | |||
| 561708b777 | |||
| 806a959662 | |||
| 966a599c2c | |||
| 0ed98c39b6 | |||
| 095bc00b33 | |||
| 43762ce8f4 | |||
| 51752713b3 | |||
| b6fc63e33c | |||
| 488d5c99f7 | |||
| 9151882245 | |||
| 02eca28bc0 | |||
| 3f7ca7be3a | |||
| f7abe03dba | |||
| 992a5cb97f | |||
| 3d94e63c3c | |||
| 79569510d2 | |||
| b9352a5bd5 | |||
| 13ebfdb5c4 | |||
| e8a413c3c3 | |||
| aa1d3289e1 | |||
| 94a00ab269 | |||
| b8682adbbf | |||
| cf10eeda53 | |||
| 3cfac7e2dc | |||
| 39e111c91b | |||
| ba42517808 | |||
| 13846d621a | |||
| e612e2f383 | |||
| 1f524ccd80 | |||
| 37bde2985e | |||
| c6a5e0d4be | |||
| 3985053636 | |||
| 4cfb1dd38f | |||
| 117a417ce0 | |||
| a2e0474572 | |||
| e992891691 | |||
| 57cf8687d4 | |||
| 27870bbdcb | |||
| 21d48078ed | |||
| afb4f162d0 | |||
| 96d479780f | |||
| 2e0cd5d54c | |||
| c4374ce483 | |||
| bd1a0d4a4e | |||
| 97f4cc0f7c | |||
| 3ebbb42096 | |||
| 07ae7c2a7f | |||
| 5c3505aec9 | |||
| fffa231b7e | |||
| f8db099d0f | |||
| 9483517258 | |||
| 0c0ab58236 | |||
| 92ec1219fa | |||
| bf72e81d55 | |||
| 5a008e59a2 | |||
| 61b8d586d3 | |||
| df62e2bd96 | |||
| 07e8990ddb | |||
| 40fc4d7267 | |||
| 5ffd3093ea | |||
| 01ac2c7ed0 | |||
| 937a25aafd | |||
| aeee018a53 | |||
| 5cef11f32b | |||
| 467f67907f | |||
| 6d38531f42 | |||
| 2dd057a911 | |||
| 4178e4b024 | |||
| 21bcde26e5 | |||
| 878606d745 | |||
| 45a90876da | |||
| bb4f496b21 | |||
| 47c1de1cc8 | |||
| f69156d7ee | |||
| c9d559e3f2 | |||
| e28a48853d | |||
| b13da1f2e8 | |||
| ba1bf677d6 | |||
| ccdff05dca | |||
| 03875b593e | |||
| 06224d798d | |||
| 966f436723 | |||
| 4941fe9469 | |||
| fe56078481 | |||
| 39742499b8 | |||
| d8207fa1f0 | |||
| 7eb302aab0 | |||
| 8cb5da4f74 | |||
| cad786f6ee | |||
| 0adc7145a1 | |||
| d35f54a4db | |||
| 000d670869 | |||
| 0c9b69deb0 | |||
| 63e31e84fc | |||
| 1bd301f21a | |||
| 3dbaf99733 | |||
| 4380611bee | |||
| 5aca466d72 | |||
| 86970f0398 | |||
| 733be09658 | |||
| 5e75210c8b | |||
| cb0ccc9c77 | |||
| 078eebfbb1 | |||
| 4049e0d9ff | |||
| 7bf7b942ba | |||
| 4f1c14fda0 | |||
| 7bd6ae3c26 | |||
| caeafc7abd | |||
| 4d249a3bc9 | |||
| 1f26b522b4 | |||
| d1ed6c1b18 | |||
| d4d89ec20d | |||
| 6a2fd5a7de | |||
| f41c5a10b6 | |||
| 3e873a05a4 | |||
| c22f41cbf2 | |||
| f3851b9ecc | |||
| 9a258744f2 | |||
| 8780ae9407 | |||
| 3f886df44f | |||
| e3c6d4a5a1 | |||
| c3215a1e54 | |||
| fdd5a8aa6e | |||
| c1792f487a | |||
| f27f0f8bca | |||
| c70ddfe157 | |||
| 554d776b77 | |||
| 34ac9eb6ed | |||
| 6cc48d2872 | |||
| 9fc31c25b2 | |||
| 73585ebc82 | |||
| e9d3e9a2be | |||
| 3e6e2f577c | |||
| 39c644b505 | |||
| 62500a6369 | |||
| 9e16b56d9a | |||
| 9dfadcda72 | |||
| e2afdc28f3 | |||
| 86bd24edd4 | |||
| 2bda556c4b | |||
| 60724a0354 | |||
| 250edec20e | |||
| 6b1e78f990 | |||
| 56f784c8bf | |||
| ce822ead2b | |||
| e606c1d4d5 | |||
| c204194b8b | |||
| 5a878239f3 | |||
| 08aa8dabfd | |||
| 20ed6071e2 | |||
| 8d34a4b3d2 | |||
| 94a9528397 | |||
| 403052a840 | |||
| bb8e5bb6ca | |||
| 11ee8764a6 | |||
| f9be0e6315 | |||
| 9e56c56528 | |||
| 85cf96b813 | |||
| c16eee4ef2 | |||
| b6bd2625c2 | |||
| 020343cdf8 | |||
| c07fffd809 | |||
| afaeeaf7f1 | |||
| e946cc7354 | |||
| bf6a966668 | |||
| 7f12fa7003 | |||
| 5f6ef5993c | |||
| f1ac67e511 | |||
| 48f9f606aa | |||
| 7d9d1b0a6f | |||
| afadf7160c | |||
| b546fbd1cc | |||
| b8f1785783 | |||
| 7ee8c8ff4d | |||
| 551112d2d9 | |||
| e9dc9630b2 | |||
| b068c1fb12 | |||
| 9f66e176f7 | |||
| c66bad99f0 | |||
| f6a29760a9 | |||
| d7e1b8fd5d | |||
| 54e9175bdf | |||
| f581b10e19 | |||
| 1ae6ea18b7 | |||
| bea6c02d89 | |||
| 153083d791 | |||
| 80538b39ac | |||
| 205f9e8e3c | |||
| 97ccfd3af0 | |||
| 6288d3fb7f | |||
| d611eecece | |||
| 779cccffe6 | |||
| 04144dc7a5 | |||
| b7bfdbb485 | |||
| 21b7c039b7 | |||
| dc7d2eb02a | |||
| 6c26d5c7f8 | |||
| 06e99d5a43 | |||
| a7712c7151 | |||
| 418d329b75 | |||
| a4d5b8067c | |||
| f61ee0f64a | |||
| 841b1e4c52 | |||
| 927ec91e0e | |||
| 9cc2a285dc | |||
| f11173d5aa | |||
| 7bd5d13cc8 | |||
| c30e22e1b1 | |||
| ffff91466e | |||
| a7669d0f35 | |||
| 1371f21d17 | |||
| ec553d3334 | |||
| 638644d1e9 | |||
| 8ae6ef5cb8 | |||
| 1092301767 | |||
| 365f75447f | |||
| 2e3dbaa18c | |||
| 68df186f72 | |||
| 8d3bedefb0 | |||
| 701a4d51de | |||
| fbb45c10c1 | |||
| e4f182ffc6 | |||
| fa94042284 | |||
| b258715cc1 | |||
| 4e9f01d439 | |||
| 5dad41953a | |||
| dd9b33af83 | |||
| fbf2325a42 | |||
| 11eb27437b | |||
| 1bb5ef85aa | |||
| 35c5d78963 | |||
| 38ff15e794 | |||
| 2992dfbada | |||
| a1e5a71ad3 | |||
| 28dbe3dca7 | |||
| 4f77ece187 | |||
| 56a69fa1ba | |||
| 5c94bda60a | |||
| b8d0e6eefb | |||
| 49b48164fd | |||
| f688c74aee | |||
| fe42548dd7 | |||
| 7000cd5bc2 | |||
| e3901c8d39 | |||
| 23f72dc4ec | |||
| 55d817bcad | |||
| 8a9994e539 | |||
| f050a62bc6 | |||
| e26464ddad | |||
| 3263606702 | |||
| 86350424d5 | |||
| d2ade1d9ed | |||
| cc47cea268 | |||
| 6f420264f8 | |||
| 5949460572 | |||
| 4330775d01 | |||
| fa5c217dd0 | |||
| aa270c9ab4 | |||
| b7af25644a | |||
| 8b742082a4 | |||
| 8078a4e64d | |||
| 79f2cc0da5 | |||
| 0c769ac16d | |||
| 25aba75413 | |||
| 9afe370a98 | |||
| 6202801793 | |||
| 4d48418b2c | |||
| a21dfa75f0 | |||
| e09d9b6607 | |||
| e312767247 | |||
| 1dfa243736 | |||
| e0d667df28 | |||
| 68a734051d | |||
| 3f43371e60 | |||
| cb8d233a16 | |||
| d95aa7ca5c | |||
| f7fc7d5cb0 | |||
| 5d6d3b962b | |||
| 6bf8f85c80 | |||
| 0a254b1903 | |||
| 9f33331618 | |||
| 595ecff396 | |||
| ad96e8951d | |||
| 385e1b4287 | |||
| 6551ac1fe9 | |||
| 327ba81d25 | |||
| bf65e16d4d | |||
| 9cf24deb93 | |||
| 3791f1db5a | |||
| 0c3070a0cc | |||
| a37ec7a308 | |||
| b161a8604e | |||
| 1e93f24f78 | |||
| f8cd3076e9 | |||
| e557504309 | |||
| 4602f72030 | |||
| d811321808 | |||
| dffb637e8d | |||
| 32129bebc0 | |||
| dd5ee45ab5 | |||
| 95b7152c05 | |||
| aa093a074a | |||
| 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 |
+90
-14
@@ -27,6 +27,8 @@ 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)
|
||||
@@ -37,10 +39,20 @@ CABINET_REFRESH_TOKEN_EXPIRE_DAYS=7
|
||||
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)
|
||||
@@ -140,7 +152,7 @@ REMNAWAVE_API_KEY=your_api_key_here
|
||||
|
||||
# Тип авторизации: "api_key", "basic_auth", "caddy"
|
||||
REMNAWAVE_AUTH_TYPE=api_key
|
||||
REMNAWAVE_CADDY_TOKEN=YWRtaW46cGFzc3dvcmQ=
|
||||
REMNAWAVE_CADDY_TOKEN=
|
||||
|
||||
# Для панелей с Basic Auth (опционально)
|
||||
REMNAWAVE_USERNAME=
|
||||
@@ -175,6 +187,42 @@ REMNAWAVE_AUTO_SYNC_ENABLED=false
|
||||
# Времена синхронизации (через запятую, формат HH:MM по МСК)
|
||||
REMNAWAVE_AUTO_SYNC_TIMES=03:00
|
||||
|
||||
# ===== REMNAWAVE WEBHOOKS (входящие события из панели) =====
|
||||
# Включить приём вебхуков от панели Remnawave (real-time события)
|
||||
REMNAWAVE_WEBHOOK_ENABLED=false
|
||||
# Путь для приёма вебхуков (должен совпадать с настройкой в панели)
|
||||
REMNAWAVE_WEBHOOK_PATH=/remnawave-webhook
|
||||
# Общий секрет для подписи HMAC-SHA256 (минимум 32 символа)
|
||||
# Сгенерируйте: openssl rand -hex 32
|
||||
# ВАЖНО: этот же секрет указывается в панели Remnawave при создании вебхука
|
||||
REMNAWAVE_WEBHOOK_SECRET=
|
||||
|
||||
# ===== УВЕДОМЛЕНИЯ ОТ ВЕБХУКОВ (что получают пользователи) =====
|
||||
# Глобальный переключатель уведомлений пользователям от вебхуков
|
||||
WEBHOOK_NOTIFY_USER_ENABLED=true
|
||||
# Отключение/активация подписки администратором
|
||||
WEBHOOK_NOTIFY_SUB_STATUS=true
|
||||
# Истечение подписки
|
||||
WEBHOOK_NOTIFY_SUB_EXPIRED=true
|
||||
# Предупреждения о скором истечении (72ч, 48ч, 24ч)
|
||||
WEBHOOK_NOTIFY_SUB_EXPIRING=true
|
||||
# Достижение лимита трафика
|
||||
WEBHOOK_NOTIFY_SUB_LIMITED=true
|
||||
# Сброс счётчика трафика
|
||||
WEBHOOK_NOTIFY_TRAFFIC_RESET=true
|
||||
# Удаление пользователя из панели
|
||||
WEBHOOK_NOTIFY_SUB_DELETED=true
|
||||
# Обновление ключей подписки (revoke)
|
||||
WEBHOOK_NOTIFY_SUB_REVOKED=true
|
||||
# Первое подключение к VPN
|
||||
WEBHOOK_NOTIFY_FIRST_CONNECTED=true
|
||||
# Напоминание о неподключении
|
||||
WEBHOOK_NOTIFY_NOT_CONNECTED=true
|
||||
# Предупреждение о приближении к лимиту трафика
|
||||
WEBHOOK_NOTIFY_BANDWIDTH_THRESHOLD=true
|
||||
# Подключение и отключение устройств
|
||||
WEBHOOK_NOTIFY_DEVICES=true
|
||||
|
||||
# Теги пользователей в Remnawave (A-Z, 0-9, _, макс. 16 символов)
|
||||
# Тег для пробных пользователей (опционально)
|
||||
# TRIAL_USER_TAG=TRIAL
|
||||
@@ -323,7 +371,8 @@ REFERRAL_MINIMUM_TOPUP_KOPEKS=10000
|
||||
REFERRAL_FIRST_TOPUP_BONUS_KOPEKS=10000
|
||||
REFERRAL_INVITER_BONUS_KOPEKS=10000
|
||||
REFERRAL_COMMISSION_PERCENT=25
|
||||
|
||||
# Показывать раздел партнёрки в кабинете
|
||||
REFERRAL_PARTNER_SECTION_VISIBLE=true
|
||||
|
||||
# Уведомления
|
||||
REFERRAL_NOTIFICATIONS_ENABLED=true
|
||||
@@ -336,6 +385,8 @@ REFERRAL_WITHDRAWAL_ENABLED=false
|
||||
REFERRAL_WITHDRAWAL_MIN_AMOUNT_KOPEKS=50000
|
||||
# Интервал между запросами на вывод (дни)
|
||||
REFERRAL_WITHDRAWAL_COOLDOWN_DAYS=30
|
||||
# Текст-подсказка для поля реквизитов при выводе (пустая строка = стандартный текст)
|
||||
REFERRAL_WITHDRAWAL_REQUISITES_TEXT=
|
||||
# Выводить только реферальный баланс (true) или весь баланс (false)
|
||||
REFERRAL_WITHDRAWAL_ONLY_REFERRAL_BALANCE=true
|
||||
# ID топика для уведомлений о заявках на вывод (0 = основной чат)
|
||||
@@ -524,6 +575,8 @@ MULENPAY_MIN_AMOUNT_KOPEKS=10000
|
||||
MULENPAY_MAX_AMOUNT_KOPEKS=10000000
|
||||
# Ожидаемый origin для iframe (опционально, для безопасности)
|
||||
# MULENPAY_IFRAME_EXPECTED_ORIGIN=https://mulenpay.ru
|
||||
# URL для редиректа после оплаты (по умолчанию WEBHOOK_URL)
|
||||
# MULENPAY_WEBSITE_URL=https://your-cabinet-url.com
|
||||
|
||||
# PAYPALYCH / PAL24
|
||||
PAL24_ENABLED=false
|
||||
@@ -532,7 +585,6 @@ PAL24_SHOP_ID=
|
||||
PAL24_SIGNATURE_TOKEN=
|
||||
PAL24_BASE_URL=https://pal24.pro/api/v1/
|
||||
PAL24_WEBHOOK_PATH=/pal24-webhook
|
||||
PAL24_WEBHOOK_PORT=8084
|
||||
PAL24_PAYMENT_DESCRIPTION="Пополнение баланса"
|
||||
PAL24_MIN_AMOUNT_KOPEKS=10000
|
||||
PAL24_MAX_AMOUNT_KOPEKS=100000000
|
||||
@@ -581,6 +633,23 @@ 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
|
||||
@@ -631,8 +700,19 @@ CLOUDPAYMENTS_TEST_MODE=false
|
||||
ENABLE_LOGO_MODE=true
|
||||
LOGO_FILE=vpn_logo.png
|
||||
|
||||
# Режим главного меню (default - классический режим работы бота, text - режим работы с активным ЛК MiniApp, отключает покупку/управление подпиской в меню, заменяет все кнопками открытия в MiniApp ЛК)
|
||||
# Режим главного меню:
|
||||
# default - классический режим работы бота (все кнопки внутри Telegram)
|
||||
# cabinet - режим Cabinet с активным ЛК MiniApp, кнопки ведут на конкретные
|
||||
# разделы кабинета (/balance, /subscription, /referral и т.д.)
|
||||
# Требует MINIAPP_CUSTOM_URL
|
||||
# Алиасы для обратной совместимости: text, text_only, minimal
|
||||
MAIN_MENU_MODE=default
|
||||
# Стиль кнопок в режиме Cabinet (Bot API 9.4):
|
||||
# primary - синий
|
||||
# success - зелёный
|
||||
# danger - красный
|
||||
# (пустое) - цвета по умолчанию для каждой секции
|
||||
CABINET_BUTTON_STYLE=
|
||||
# Включить управление меню через API (позволяет динамически менять структуру кнопок)
|
||||
MENU_LAYOUT_ENABLED=false
|
||||
|
||||
@@ -645,7 +725,7 @@ HIDE_SUBSCRIPTION_LINK=false
|
||||
# miniapp_custom - открывает заданную ссылку в мини-приложении (режим 3)
|
||||
# link - Открывает ссылку напрямую в браузере (режим 4)
|
||||
# happ_cryptolink - Вывод cryptoLink ссылки на подписку Happ (режим 5)
|
||||
CONNECT_BUTTON_MODE=guide
|
||||
CONNECT_BUTTON_MODE=miniapp_subscription
|
||||
|
||||
# URL для режима miniapp_custom (обязателен при CONNECT_BUTTON_MODE=miniapp_custom)
|
||||
MINIAPP_CUSTOM_URL=
|
||||
@@ -712,7 +792,7 @@ MAINTENANCE_MESSAGE=Ведутся технические работы. Серв
|
||||
# ===== ЛОКАЛИЗАЦИЯ =====
|
||||
# Укажите язык из AVAILABLE_LANGUAGES. При некорректном значении используется ru.
|
||||
DEFAULT_LANGUAGE=ru
|
||||
AVAILABLE_LANGUAGES=ru,en,ua,zh
|
||||
AVAILABLE_LANGUAGES=ru,en,ua,zh,fa
|
||||
# Включить выбор языка при старте и отображение кнопки в меню
|
||||
LANGUAGE_SELECTION_ENABLED=true
|
||||
|
||||
@@ -768,6 +848,8 @@ VERSION_CHECK_INTERVAL_HOURS=1
|
||||
# ===== ЛОГИРОВАНИЕ =====
|
||||
LOG_LEVEL=INFO
|
||||
LOG_FILE=logs/bot.log
|
||||
# ANSI-цвета в консоли (true — цветной вывод с Rich, false — plain-text)
|
||||
LOG_COLORS=true
|
||||
|
||||
# === Ротация логов ===
|
||||
# Включить новую систему ротации (по умолчанию старое поведение)
|
||||
@@ -801,7 +883,7 @@ WEBHOOK_MAX_QUEUE_SIZE=1024
|
||||
WEBHOOK_WORKERS=4
|
||||
WEBHOOK_ENQUEUE_TIMEOUT=0.1
|
||||
WEBHOOK_WORKER_SHUTDOWN_TIMEOUT=30.0
|
||||
BOT_RUN_MODE=polling # polling, webhook или both
|
||||
BOT_RUN_MODE=polling # polling или webhook
|
||||
|
||||
# ===== КОНКУРСНАЯ СИСТЕМА =====
|
||||
CONTESTS_ENABLED=false
|
||||
@@ -809,15 +891,9 @@ CONTESTS_BUTTON_VISIBLE=false
|
||||
# Реферальные конкурсы (турниры среди рефералов)
|
||||
REFERRAL_CONTESTS_ENABLED=false
|
||||
|
||||
# ===== АВТОАКТИВАЦИЯ ПОСЛЕ ПОПОЛНЕНИЯ =====
|
||||
# ===== АВТОПОКУПКА ПОСЛЕ ПОПОЛНЕНИЯ =====
|
||||
# Автоматическая покупка из сохранённой корзины после пополнения баланса
|
||||
AUTO_PURCHASE_AFTER_TOPUP_ENABLED=false
|
||||
# Умная автоактивация: система сама решает — продлить или создать подписку
|
||||
# Работает даже без сохранённой корзины. Выбирает максимальный период <= баланса
|
||||
AUTO_ACTIVATE_AFTER_TOPUP_ENABLED=false
|
||||
# Показывать предупреждение об активации подписки после пополнения баланса
|
||||
# Если true - после пополнения показывает сообщение с кнопками: "Активировать", "Продлить", "Добавить устройства"
|
||||
SHOW_ACTIVATION_PROMPT_AFTER_TOPUP=false
|
||||
|
||||
# ===== КНОПКА АКТИВАЦИИ =====
|
||||
ACTIVATE_BUTTON_VISIBLE=false
|
||||
|
||||
@@ -36,15 +36,15 @@ jobs:
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||
echo "🏷️ Собираем релизную версию: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/main ]]; then
|
||||
VERSION="v3.1.1-$(git rev-parse --short HEAD)"
|
||||
VERSION="v3.7.0-$(git rev-parse --short HEAD)" # x-release-please-version
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||
echo "🚀 Собираем версию из main: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
|
||||
VERSION="v3.1.1-dev-$(git rev-parse --short HEAD)"
|
||||
VERSION="v3.7.0-dev-$(git rev-parse --short HEAD)" # x-release-please-version
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:dev,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||
echo "🧪 Собираем dev версию: $VERSION"
|
||||
else
|
||||
VERSION="v3.1.1-pr-$(git rev-parse --short HEAD)"
|
||||
VERSION="v3.7.0-pr-$(git rev-parse --short HEAD)" # x-release-please-version
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:pr-$(git rev-parse --short HEAD)"
|
||||
echo "🔀 Собираем PR версию: $VERSION"
|
||||
fi
|
||||
|
||||
@@ -14,7 +14,7 @@ on:
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: fr1ngg/remnawave-bedolaga-telegram-bot
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
@@ -49,13 +49,13 @@ jobs:
|
||||
VERSION=${GITHUB_REF#refs/tags/}
|
||||
echo "🏷️ Building release version: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/main ]]; then
|
||||
VERSION="v3.1.1-$(git rev-parse --short HEAD)"
|
||||
VERSION="v3.7.0-$(git rev-parse --short HEAD)" # x-release-please-version
|
||||
echo "🚀 Building main version: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
|
||||
VERSION="v3.1.1-dev-$(git rev-parse --short HEAD)"
|
||||
VERSION="v3.7.0-dev-$(git rev-parse --short HEAD)" # x-release-please-version
|
||||
echo "🧪 Building dev version: $VERSION"
|
||||
else
|
||||
VERSION="v3.1.1-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
-24
@@ -1,39 +1,102 @@
|
||||
# Игнорируем все файлы и папки по умолчанию
|
||||
*
|
||||
docker-compose.override.yml
|
||||
|
||||
# Исключения: разрешаем только нужные файлы
|
||||
# ========== 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/**
|
||||
!migrations/
|
||||
!migrations/**
|
||||
!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.16.1"
|
||||
}
|
||||
+438
@@ -0,0 +1,438 @@
|
||||
# Changelog
|
||||
|
||||
## [3.16.1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.16.0...v3.16.1) (2026-02-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add migration for partner system tables and columns ([4645be5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4645be53cbb3799aa6b2b6a623af30460357a554))
|
||||
* add migration for partner system tables and columns ([79ea398](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/79ea398d1db436a7812a799bf01b2c1c3b1b73be))
|
||||
|
||||
## [3.16.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.15.1...v3.16.0) (2026-02-18)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* add admin notifications for partner applications and withdrawals ([cf7cc5a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/cf7cc5a84e295608009f255fcd0dcedb5a2a04a3))
|
||||
* add admin partner settings API (withdrawal toggle, requisites text, partner visibility) ([6881d97](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6881d97bbb1f6cd8ca3609c2d9286a6e4fb24fc3))
|
||||
* add campaign_id to ReferralEarning for campaign attribution ([0c07812](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0c07812ecc9502f54a7745a77b086fc52bdc0e34))
|
||||
* add partner system and withdrawal management to cabinet ([58bfaea](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/58bfaeaddbcbb98cb67dbd507847a0e5c8d07809))
|
||||
* attribute campaign registrations to partner for referral earnings ([767e965](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/767e9650285adc72b067b2c0b8a4d1ac5c5bba57))
|
||||
* blocked user detection during broadcasts, filter blocked from all notifications ([10e231e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/10e231e52e0dbabd9195a2df373b3c95129a5e4f))
|
||||
* enforce 1-to-1 partner-campaign binding with partner info in campaigns ([366df18](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/366df18c547047a7c69192c768970ebc6ee426fc))
|
||||
* expose traffic_reset_mode in subscription response ([59383bd](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/59383bdbd8c72428d151cb24d132452414b14fa3))
|
||||
* expose traffic_reset_mode in tariff API response ([5d4a94b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5d4a94b8cea8f16f0b4c31e24a4695bee4c67af7))
|
||||
* include partner campaigns in /partner/status response ([ea5d932](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ea5d932476553ad1750da3bebbd4b8f055478040))
|
||||
* link campaign registrations to partner for referral earnings ([c4dc43e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c4dc43e054e9faec2f9614fe51a64635f80c1796))
|
||||
* notify users on partner/withdrawal approve/reject ([327d4f4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/327d4f4d1559e37dc591adbfd0c839d986d1068d))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add blocked_count column migration to universal_migration.py ([b4b10c9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b4b10c998cadbb879540e56dbd0e362b5497ee57))
|
||||
* add missing payment providers to payment_utils and fix {total_amount} formatting ([bdb6161](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bdb61613de378efab4de6de98fde2de3b554c548))
|
||||
* add selectinload for subscription in campaign user list ([eb9dba3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/eb9dba3f4728b478f2206ff992700a9677f879c7))
|
||||
* campaign web link uses ?campaign= param, not ?start= ([28f524b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/28f524b7622ed975d2fece66edc94d9713354738))
|
||||
* correct subscription_service import in broadcast cleanup ([6c4e035](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6c4e035146934dffb576477cc75f7365b2f27b99))
|
||||
* critical security and data integrity fixes for partner system ([8899749](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/88997492c3534ea2f6e194c0382c77302557c2f3))
|
||||
* handle YooKassa NotFoundError gracefully in get_payment_info ([df5b1a0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/df5b1a072d99ff8aee0c94304b2a0214f0fcffe7))
|
||||
* medium-priority fixes for partner system ([7c20fde](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7c20fde4e887749d72280a8804467645e5bab416))
|
||||
* move PartnerStatus enum before User class to fix NameError ([acc1323](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/acc1323a542b8e92433cabf1334d2d98bfa21e21))
|
||||
* prevent fileConfig from destroying structlog handlers ([e78b104](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e78b1040a50ac14759bceab396d0c3e34dd79cdd))
|
||||
* reorder button_click_logs migration to nullify before ALTER TYPE ([df5415f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/df5415f30b2aae4412ff5fbd3cac8076128b818c))
|
||||
* resolve HIGH-priority performance and security issues in partner system ([fcf3a2c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fcf3a2c8062752b2b1dc06b5993ac2d8ae80ee85))
|
||||
* return zeroed stats dict when withdrawal is disabled ([7883efc](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7883efc3d6e6d8bedf8e4b7d72634cbab6e2f3d7))
|
||||
* unassign all campaigns when revoking partner status ([d39063b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d39063b22ffb6442e275db39704361cdb9251793))
|
||||
|
||||
|
||||
### Refactoring
|
||||
|
||||
* replace universal_migration.py with Alembic ([b6c7f91](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b6c7f91a7c79d108820c9f89c9070fde4843316c))
|
||||
* replace universal_migration.py with Alembic ([784616b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/784616b349ef12b35ee021dd7a7b2a2ef9fc57f6))
|
||||
|
||||
## [3.15.1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.15.0...v3.15.1) (2026-02-17)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add naive datetime guards to fromisoformat() in Redis cache readers ([1b3e6f2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1b3e6f2f11c20aa240da1beb11dd7dfb20dbe6e8))
|
||||
* add naive datetime guards to fromisoformat() in Redis cache readers ([6fa4948](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6fa49485d9f1cd678cb5f9fa7d0375fd47643239))
|
||||
|
||||
## [3.15.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.14.1...v3.15.0) (2026-02-17)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* add LOG_COLORS env setting to toggle console ANSI colors ([27309f5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/27309f53d9fa0ba9a2ca07a65feed96bf38f470c))
|
||||
* add web campaign links with bonus processing in auth flow ([d955279](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d9552799c17a76e2cc2118699528c5b591bd97fb))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* AttributeError in withdrawal admin notification (send_to_admins → send_admin_notification) ([c75ec0b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c75ec0b22a3f674d3e1a24b9d546eca1998701b3))
|
||||
* remove local UTC re-imports shadowing module-level import in purchase.py ([e68760c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e68760cc668016209f4f19a2e08af8680343d6ed))
|
||||
|
||||
## [3.14.1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.14.0...v3.14.1) (2026-02-17)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add naive datetime guards to parsers and fix test datetime literals ([0946090](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/094609005af7358bf5d34d252fc66685bd25751c))
|
||||
* address remaining abs() issues from review ([ff21b27](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ff21b27b98bb5a7517e06057eb319c9f3ebb74c7))
|
||||
* complete datetime.utcnow() → datetime.now(UTC) migration ([eb18994](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/eb18994b7d34d777ca39d3278d509e41359e2a85))
|
||||
* normalize transaction amount signs across all aggregations ([4247981](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4247981c98111af388c98628c1e61f0517c57417))
|
||||
* prevent negative amounts in spent display and balance history ([c30972f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c30972f6a7911a89a6c3f2080019ff465d11b597))
|
||||
|
||||
## [3.14.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.13.0...v3.14.0) (2026-02-16)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* show all active webhook endpoints in startup log ([9d71005](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9d710050ad40ba76a14aa6ace8e8a47f25cdde94))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* force basicConfig to replace pre-existing handlers ([7eb8d4e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7eb8d4e153bab640a5829f75bfa6f70df5763284))
|
||||
* NameError in set_user_devices_button — undefined action_text ([1b8ef69](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1b8ef69a1bbb7d8d86827cf7aaa4f05cbf480d75))
|
||||
* remove unused PaymentService from MonitoringService init ([491a7e1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/491a7e1c425a355e55b3020e2bcc7b96047bdf5e))
|
||||
* resolve MissingGreenlet error when accessing subscription.tariff ([a93a32f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a93a32f3a7d1b259a2e24954ae5d2b7c966c5639))
|
||||
* sync support mode from cabinet admin to SupportSettingsService ([516be6e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/516be6e600a08ad700d83b793dc64b2ca07bdf44))
|
||||
* sync SUPPORT_SYSTEM_MODE between SystemSettings and SupportSettings ([0807a9f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0807a9ff19d1eb4f1204f7cbeb1da1c1cfefe83a))
|
||||
|
||||
|
||||
### Refactoring
|
||||
|
||||
* improve log formatting — logger name prefix and table alignment ([f637204](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f63720467a935bdaaa58bb34d588d65e46698f26))
|
||||
|
||||
## [3.13.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.12.1...v3.13.0) (2026-02-16)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* colored console logs via structlog + rich + FORCE_COLOR ([bf64611](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bf646112df02aa7aa7918d0513cb6968ceb7f378))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* limit Rich traceback output to prevent console flood ([11ef714](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/11ef714e0dde25a08711c0daeee943b6e71e20b7))
|
||||
* resolve exc_info for admin notifications, clean log formatting ([11f8af0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/11f8af003fc60384abafa2b670b89d6ad3ac57a4))
|
||||
* suppress startup log noise (~350 lines → ~30) ([8a6650e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8a6650e57cd8ea396d9b057a7753469947f38d29))
|
||||
* traceback in Telegram notifications + reduce log padding ([909a403](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/909a4039c43b910761bd05c36e79c8e6773199db))
|
||||
* use sync context manager for structlog bound_contextvars ([25e8c9f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/25e8c9f8fc4d2c66d5a1407d3de5c7402dc596da))
|
||||
|
||||
|
||||
### Refactoring
|
||||
|
||||
* complete structlog migration with contextvars, kwargs, and logging hardening ([1f0fef1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1f0fef114bd979b2b0d2bd38dde6ce05e7bba07b))
|
||||
|
||||
## [3.12.1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.12.0...v3.12.1) (2026-02-16)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add /start burst rate-limit to prevent spam abuse ([61a9722](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/61a97220d30031816ab23e33a46717e4895c0758))
|
||||
* add promo code anti-abuse protections ([97ec39a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/97ec39aa803f0e3f03fdcd482df0cbcb86fd1efd))
|
||||
* handle TelegramBadRequest in ticket edit_message_text calls ([8e61fe4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8e61fe47746da2ac09c3ea8c4dbfc6be198e49e3))
|
||||
* replace deprecated Query(regex=) with pattern= ([871ceb8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/871ceb866ccf1f3a770c7ef33406e1a43d0a7ff7))
|
||||
|
||||
## [3.12.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.11.0...v3.12.0) (2026-02-15)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* add 'default' (no color) option for button styles ([10538e7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/10538e735149bf3f3f2029ff44b94d11d48c478e))
|
||||
* add button style and emoji support for cabinet mode (Bot API 9.4) ([bf2b2f1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bf2b2f1c5650e527fcac0fb3e72b4e6e19bef406))
|
||||
* add per-button enable/disable toggle and custom labels per locale ([68773b7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/68773b7e77aa344d18b0f304fa561c91d7631c05))
|
||||
* add per-section button style and emoji customization via admin API ([a968791](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a9687912dfe756e7d772d96cc253f78f2e97185c))
|
||||
* add web admin button for admins in cabinet mode ([9ac6da4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9ac6da490dffa03ce823009c6b4e5014b7d2bdfb))
|
||||
* rename MAIN_MENU_MODE=text to cabinet with deep-linking to frontend sections ([ad87c5f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ad87c5fb5e1a4dd0ef7691f12764d3df1530f643))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* daily tariff subscriptions stuck in expired/disabled with no resume path ([80914c1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/80914c1af739aa0ee1ea75b0e5871bf391b9020d))
|
||||
* filter out traffic packages with zero price from purchase options ([64a684c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/64a684cd2ff51e663a1f70e61c07ca6b4f6bfc91))
|
||||
* handle photo message in ticket creation flow ([e182280](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e1822800aba3ea5eee721846b1e0d8df0a9398d1))
|
||||
* handle tariff_extend callback without period (back button crash) ([ba0a5e9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ba0a5e9abd9bd582968d69a5c6e57f336094c782))
|
||||
* pre-validate CABINET_BUTTON_STYLE to prevent invalid values from suppressing per-section defaults ([46c1a69](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/46c1a69456036cb1be784b8d952f27110e9124eb))
|
||||
* remove redundant trial inactivity monitoring checks ([d712ab8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d712ab830166cab61ce38dd32498a8a9e3e602b0))
|
||||
* webhook notification 'My Subscription' button uses unregistered callback_data ([1e2a7e3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1e2a7e3096af11540184d60885b8c08d73506c4a))
|
||||
|
||||
## [3.11.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.10.3...v3.11.0) (2026-02-12)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* add cabinet admin API for pinned messages management ([1a476c4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1a476c49c19d1ec2ab2cda1c2ffb5fd242288bb6))
|
||||
* add startup warnings for missing HAPP_CRYPTOLINK_REDIRECT_TEMPLATE and MINIAPP_CUSTOM_URL ([476b89f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/476b89fe8e613c505acfc58a9554d31ccf92718a))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add passive_deletes to Subscription relationships to prevent NOT NULL violation on cascade delete ([bfd66c4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bfd66c42c1fba3763f41d641cea1bd101ec8c10c))
|
||||
* add startup warning for missing HAPP_CRYPTOLINK_REDIRECT_TEMPLATE in guide mode ([1d43ae5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1d43ae5e25ffcf0e4fe6fec13319d393717e1e50))
|
||||
* flood control handling in pinned messages and XSS hardening in HTML sanitizer ([454b831](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/454b83138e4db8dc4f07171ee6fe262d2cd6d311))
|
||||
* suppress expired callback query error in AuthMiddleware ([2de4384](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2de438426a647e2bcae9b4d99eef4093ff8b5429))
|
||||
* ticket creation crash and webhook PendingRollbackError ([760c833](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/760c833b7402541d3c7cf2ed7fc0418119e75042))
|
||||
|
||||
## [3.10.3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.10.2...v3.10.3) (2026-02-12)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* handle unique constraint conflicts during backup restore without clear_existing ([5893874](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/589387477624691e0026086800428e7e52e06128))
|
||||
* harden backup create/restore against serialization and constraint errors ([fc42916](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fc42916b10bb698895eb75c0e2568747647555d3))
|
||||
* resolve deadlock on server_squads counter updates and add webhook notification toggles ([57dc1ff](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/57dc1ff47f2f6183351db7594544a07ca6f27250))
|
||||
|
||||
## [3.10.2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.10.1...v3.10.2) (2026-02-12)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* allow email change for unverified emails ([93bb8e0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/93bb8e0eb492ca59e29da86594e84e9c486fea65))
|
||||
* clean stale squad UUIDs from tariffs during server sync ([fcaa9df](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fcaa9dfb27350ceda3765c6980ad67f671477caf))
|
||||
* delete subscription_servers before subscription to prevent FK violation ([7d9ced8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7d9ced8f4f71b43ed4ac798e6ff904a086e1ac4a))
|
||||
* handle StaleDataError in webhook user.deleted server counter decrement ([c30c2fe](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c30c2feee1db03f0a359b291117da88002dd0fe0))
|
||||
* handle time/date types in backup JSON serialization ([27365b3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/27365b3c7518c09229afcd928f505d0f3f66213f))
|
||||
* HTML parse fallback, email change race condition, username length limit ([d05ff67](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d05ff678abfacaa7e55ad3e55f226d706d32a7b7))
|
||||
* payment race conditions, balance atomicity, renewal rollback safety ([c5124b9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c5124b97b63eda59b52d2cbf9e2dcdaa6141ed6e))
|
||||
* remove DisplayNameRestrictionMiddleware ([640da34](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/640da3473662cfdcceaa4346729467600ac3b14f))
|
||||
* suppress bot-blocked-by-user error in AuthMiddleware ([fda9f3b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fda9f3beecbfcca4d7abc16cf661d5ad5e3b5141))
|
||||
* UnboundLocalError for get_logo_media in required_sub_channel_check ([d3c14ac](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d3c14ac30363839d1340129f279a7a7b4b021ed1))
|
||||
* use traffic topup config and add WATA 429 retry ([b5998ea](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b5998ea9d22644ed2914b0e829b3a76a32a69ddf))
|
||||
|
||||
|
||||
### Refactoring
|
||||
|
||||
* remove modem functionality from classic subscriptions ([ee2e79d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ee2e79db3114fe7a9852d2cd33c4b4fbbde311ea))
|
||||
|
||||
## [3.10.1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.10.0...v3.10.1) (2026-02-11)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* address review issues in backup, updates, and webhook handlers ([2094886](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/20948869902dc570681b05709ac8d51996330a6e))
|
||||
* allow purchase when recalculated price is lower than cached ([19dabf3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/19dabf38512ae0c2121108d0b92fc8f384292484))
|
||||
* change CryptoBot URL priority to bot_invoice_url for Telegram opening ([3193ffb](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3193ffbd1bee07cb79824d87cb0f77b473b22989))
|
||||
* clear subscription data when user deleted from Remnawave panel ([b0fd38d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b0fd38d60c22247a0086c570665b92c73a060f2f))
|
||||
* downgrade Telegram timeout errors to warning in monitoring service ([e43a8d6](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e43a8d6ce4c40a7212bf90644f82da109717bdcb))
|
||||
* expand backup coverage to all 68 models and harden restore ([02e40bd](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/02e40bd6f7ef8e653cae53ccd127f2f79009e0d4))
|
||||
* handle nullable traffic_limit_gb and end_date in subscription model ([e94b93d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e94b93d0c10b4e61d7750ca47e1b2f888f5873ed))
|
||||
* handle StaleDataError in webhook when user already deleted ([d58a80f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d58a80f3eaa64a6fc899e10b3b14584fb7fc18a9))
|
||||
* ignore 'message is not modified' on privacy policy decline ([be1da97](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/be1da976e14a35e6cca01a7fca7529c55c1a208b))
|
||||
* preserve purchased traffic when extending same tariff ([b167ed3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b167ed3dd1c6e6239db2bdbb8424bcb1fb7715d9))
|
||||
* prevent cascading greenlet errors after sync rollback ([a1ffd5b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a1ffd5bda6b63145104ce750835d8e6492d781dc))
|
||||
* protect server counter callers and fix tariff change detection ([bee4aa4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bee4aa42842b8b6611c7c268bcfced408a227bc0))
|
||||
* suppress 'message is not modified' error in updates panel ([3a680b4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3a680b41b0124848572809d187cab720e1db8506))
|
||||
* use callback fallback when MINIAPP_CUSTOM_URL is not set ([eaf3a07](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/eaf3a07579729031030308d77f61a5227b796c02))
|
||||
* use flush instead of commit in server counter functions ([6cec024](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6cec024e46ef9177cb59aa81590953c9a75d81bb))
|
||||
|
||||
## [3.10.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.9.1...v3.10.0) (2026-02-10)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* add all remaining RemnaWave webhook events (node, service, crm, device) ([1e37fd9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1e37fd9dd271814e644af591343cada6ab12d612))
|
||||
* add close button to all webhook notifications ([d9de15a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d9de15a5a06aec3901415bdfd25b55d2ca01d28c))
|
||||
* add MULENPAY_WEBSITE_URL setting for post-payment redirect ([fe5f5de](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fe5f5ded965e36300e1c73f25f16de22f84651ad))
|
||||
* add RemnaWave incoming webhooks for real-time subscription events ([6d67cad](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6d67cad3e7aa07b8490d88b73c38c4aca6b9e315))
|
||||
* handle errors.bandwidth_usage_threshold_reached_max_notifications webhook ([8e85e24](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8e85e244cb786fb4c06162f2b98d01202e893315))
|
||||
* handle service.subpage_config_changed webhook event ([43a326a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/43a326a98ccc3351de04d9b2d660d3e7e0cb0efc))
|
||||
* unified notification delivery for webhook events (email + WS support) ([26637f0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/26637f0ae5c7264c0430487d942744fd034e78e8))
|
||||
* webhook protection — prevent sync/monitoring from overwriting webhook data ([184c52d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/184c52d4ea3ce02d40cf8a5ab42be855c7c7ae23))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add action buttons to webhook notifications and fix empty device names ([7091eb9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7091eb9c148aaf913c4699fc86fef5b548002668))
|
||||
* add missing placeholders to Arabic SUBSCRIPTION_INFO template ([fe54640](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fe546408857128649930de9473c7cde1f7cc450a))
|
||||
* allow non-HTTP deep links in crypto link webhook updates ([f779225](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f77922522a85b3017be44b5fc71da9c95ec16379))
|
||||
* build composite device name from platform + hwid short suffix ([17ce640](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/17ce64037f198837c8f2aa7bf863871f60bdf547))
|
||||
* downgrade transient API errors (502/503/504) to warning level ([ec8eaf5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ec8eaf52bfdc2bde612e4fc0324575ba7dc6b2e1))
|
||||
* extract device name from nested hwidUserDevice object ([79793c4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/79793c47bbbdae8b0f285448d5f70e90c9d4f4b0))
|
||||
* preserve payment initiation time in transaction created_at ([90d9df8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/90d9df8f0e949913f09c4ebed8fe5280453ab3ab))
|
||||
* security and architecture fixes for webhook handlers ([dc1e96b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/dc1e96bbe9b4496e91e9dea591c7fc0ef4cc245b))
|
||||
* stop CryptoBot webhook retry loop and save cabinet payments to DB ([2cb6d73](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2cb6d731e96cbfc305b098d8424b84bfd6826fb4))
|
||||
* sync subscription status from panel in user.modified webhook ([5156d63](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5156d635f0b5bc0493e8f18ce9710cca6ff4ffc8))
|
||||
* use event field directly as event_name (already includes scope prefix) ([9aa22af](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9aa22af3390a249d1b500d75a7d7189daaed265e))
|
||||
* webhook:close button not working due to channel check timeout ([019fbc1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/019fbc12b6cf61d374bbed4bce3823afc60445c9))
|
||||
|
||||
## [3.9.1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.9.0...v3.9.1) (2026-02-10)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* don't delete Heleket invoice message on status check ([9943253](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/994325360ca7665800177bfad8f831154f4d733f))
|
||||
* safe HTML preview truncation and lazy-load subscription fallback ([40d8a6d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/40d8a6dc8baf3f0f7c30b0883898b4655a907eb5))
|
||||
* use actual DB columns for subscription fallback query ([f0e7f8e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f0e7f8e3bec27d97a3f22445948b8dde37a92438))
|
||||
|
||||
## [3.9.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.8.0...v3.9.0) (2026-02-09)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* add lite mode functionality with endpoints for retrieval and update ([7b0403a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7b0403a307702c24efefc5c14af8cb2fb7525671))
|
||||
* add Persian (fa) locale with complete translations ([29a3b39](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/29a3b395b6e67e4ce2437b75120b78c76b69ff4f))
|
||||
* allow tariff deletion with active subscriptions ([ebd6bee](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ebd6bee05ed7d9187de9394c64dfd745bb06b65a))
|
||||
* **localization:** add Persian (fa) locale support and wire it across app flows ([cc54a7a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/cc54a7ad2fb98fe6e662e1923027f4989ae72868))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* nullify payment FK references before deleting transactions in user restoration ([0b86f37](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0b86f379b4e55e499ca3d189137e2aed865774b5))
|
||||
* prevent sync from overwriting end_date for non-ACTIVE panel users ([49871f8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/49871f82f37d84979ea9ec91055e3f046d5854be))
|
||||
* promo code max_uses=0 conversion and trial UX after promo activation ([1cae713](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1cae7130bc87493ab8c7691b3c22ead8189dab55))
|
||||
* skip users with active subscriptions in admin inactive cleanup ([e79f598](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e79f598d17ffa76372e6f88d2a498accf8175c76))
|
||||
* use selection.period.days instead of selection.period_days ([4541016](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/45410168afe683675003a1c41c17074a54ce04f1))
|
||||
|
||||
|
||||
### Performance
|
||||
|
||||
* cache logo file_id to avoid re-uploading on every message ([142ff14](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/142ff14a502e629446be7d67fab880d12bee149d))
|
||||
|
||||
|
||||
### Refactoring
|
||||
|
||||
* remove "both" mode from BOT_RUN_MODE, keep only polling and webhook ([efa3a5d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/efa3a5d4579f24dabeeba01a4f2e981144dd6022))
|
||||
* remove Flask, use FastAPI exclusively for all webhooks ([119f463](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/119f463c36a95685c3bc6cdf704e746b0ba20d56))
|
||||
* remove smart auto-activation & activation prompt, fix production bugs ([a3903a2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a3903a252efdd0db4b42ca3fd6771f1627050a7f))
|
||||
|
||||
## [3.8.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.7.2...v3.8.0) (2026-02-08)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* add admin device management endpoints ([c57de10](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c57de1081a9e905ba191f64c37221c36713c82a6))
|
||||
* add admin traffic packages and device limit management ([2f90f91](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2f90f9134df58b8c0a329c20060efcf07d5d92f9))
|
||||
* add admin updates endpoint for bot and cabinet releases ([11b8ab1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/11b8ab1959e83fafe405be0b76dfa3dd1580a68b))
|
||||
* add endpoint for updating user referral commission percent ([da6f746](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/da6f746b093be8cdbf4e2889c50b35087fbc90de))
|
||||
* add enrichment data to CSV export ([f2dbab6](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f2dbab617155cdc41573d885f0e55222e5b9825b))
|
||||
* add server-side sorting for enrichment columns ([15c7cc2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/15c7cc2a58e1f1935d10712a981466629db251d1))
|
||||
* add system info endpoint for admin dashboard ([02c30f8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/02c30f8e7eb6ba90ed8983cfd82199a22b473bbf))
|
||||
* add traffic usage enrichment endpoint with devices, spending, dates, last node ([5cf3f2f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5cf3f2f76eb2cd93282f845ea0850f6707bfcc09))
|
||||
* admin panel enhancements & bug fixes ([e6ebf81](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e6ebf81752499df8eb0a710072785e3d603dba33))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add debug logging for bulk device response structure ([46da31d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/46da31d89c55c225dec9136d225f2db967cf8961))
|
||||
* add email field to traffic table for OAuth/email users ([94fcf20](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/94fcf20d17c54efd67fa7bd47eff1afdd1507e08))
|
||||
* add email/UUID fallback for OAuth user panel sync ([165965d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/165965d8ea60a002c061fd75f88b759f2da66d7d))
|
||||
* add enrichment device mapping debug logs ([5be82f2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5be82f2d78aed9b54d74e86f261baa5655e5dcd9))
|
||||
* include additional devices in tariff renewal price and display ([17e9259](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/17e9259eb1d41dbf1d313b6a7d500f6458359393))
|
||||
* paginate bulk device endpoint to fetch all HWID devices ([4648a82](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4648a82da959410603c92055bcde7f96131e0c29))
|
||||
* read bot version from pyproject.toml when VERSION env is not set ([9828ff0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9828ff0845ec1d199a6fa63fe490ad3570cf9c8f))
|
||||
* revert device pagination, add raw user data field discovery ([8f7fa76](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8f7fa76e6ab34a3ad2f61f4e1f06026fd3fbf4e3))
|
||||
* use bulk device endpoint instead of per-user calls ([5f219c3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5f219c33e6d49b0e3e4405a57f8344a4237f1002))
|
||||
* use correct pagination params (start/size) for bulk HWID devices ([17af51c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/17af51ce0bdfa45197384988d56960a1918ab709))
|
||||
* use per-user panel endpoints for reliable device counts and last node data ([9d39901](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9d39901f78ece55c740a5df2603601e5d0b1caca))
|
||||
|
||||
## [3.7.2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.7.1...v3.7.2) (2026-02-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* handle FK violation in create_yookassa_payment when user is deleted ([55d281b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/55d281b0e37a6e8977ceff792cccb8669560945b))
|
||||
* remove dots from Remnawave username sanitization ([d6fa86b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d6fa86b870eccbf22327cd205539dd2084f0014e))
|
||||
|
||||
## [3.7.1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.7.0...v3.7.1) (2026-02-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* release-please config — remove blocked workflow files ([d88ca98](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d88ca980ec67e303e37f0094a2912471929b4cef))
|
||||
* remove workflow files and pyproject.toml from release-please extra-files ([5070bb3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5070bb34e8a09b2641783f5e818bb624469ad610))
|
||||
* resolve HWID reset and webhook FK violation ([5f3e426](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5f3e426750c2adcb097b92f1a9e7725b1c5c5eba))
|
||||
* resolve HWID reset context manager bug and webhook FK violation ([a9eee19](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a9eee19c95efdc38ecf5fa28f7402a2bbba7dd07))
|
||||
* resolve merge conflict in release-please config ([0ef4f55](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0ef4f55304751571754f2027105af3e507f75dfd))
|
||||
* resolve multiple production errors and performance issues ([071c23d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/071c23dd5297c20527442cb5d348d498ebf20af4))
|
||||
|
||||
## [3.7.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.6.0...v3.7.0) (2026-02-07)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add admin traffic usage API ([aa1cd38](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/aa1cd3829c5c3671e220d49dd7ec2d83563e2cf9))
|
||||
* add admin traffic usage API with per-node statistics ([6c2c25d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6c2c25d2ccb27446c822e4ed94d9351bfeaf4549))
|
||||
* add node/status filters and custom date range to traffic page ([ad260d9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ad260d9fe0b232c9d65176502476212902909660))
|
||||
* add node/status filters, custom date range, connected devices to traffic page ([9ea533a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9ea533a864e345647754f316bd27971fba1420af))
|
||||
* add node/status filters, date range, devices to traffic page ([ad6522f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ad6522f547e68ef5965e70d395ca381b0a032093))
|
||||
* add risk columns to traffic CSV export ([7c1a142](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7c1a1426537e43d14eff0a1c3faeca484611b58b))
|
||||
* add tariff filter, fix traffic data aggregation ([fa01819](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fa01819674b2d2abb0d05b470559b09eb43abef8))
|
||||
* node/status filters + custom date range for traffic page ([a161e2f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a161e2f904732b459fef98a67abfaae1214ecfd4))
|
||||
* tariff filter + fix traffic data aggregation ([1021c2c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1021c2cdcd07cf2194e59af7b59491108339e61f))
|
||||
* traffic filters, date range & risk columns in CSV export ([4c40b5b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4c40b5b370616a9ab40cbf0cccdbc0ac4a3f8278))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* close unclosed HTML tags in version notification ([0b61c7f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0b61c7fe482e7bbfbb3421307a96d54addfd91ee))
|
||||
* close unclosed HTML tags when truncating version notification ([b674550](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b6745508da861af9b2ff05d89b4ac9a3933da510))
|
||||
* correct response parsing for non-legacy node-users endpoint ([a076dfb](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a076dfb5503a349450b5aa8aac3c6f40070b715d))
|
||||
* correct response parsing for non-legacy node-users endpoint ([91ac90c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/91ac90c2aecfb990679b3d0c835314dde448886a))
|
||||
* handle mixed types in traffic sort ([eeed2d6](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/eeed2d6369b07860505c59bcff391e7b17e0ffb7))
|
||||
* handle mixed types in traffic sort for string fields ([a194be0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a194be0843856b3376167d9ba8a8ef737280998c))
|
||||
* resolve 429 rate limiting on traffic page ([b12544d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b12544d3ea8f4bbd2d8c941f83ee3ac412157adb))
|
||||
* resolve 429 rate limiting on traffic page ([924d6bc](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/924d6bc09c815c1d188ea1d0e7974f7e803c1d3f))
|
||||
* use legacy per-node endpoint for traffic aggregation ([cc1c8ba](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/cc1c8bacb42a9089021b7ae0fecd1f2717953efb))
|
||||
* use legacy per-node endpoint with correct response format ([b707b79](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b707b7995b90c6465910a35e9a4403e1408c6568))
|
||||
* use PaymentService for cabinet YooKassa payments ([61bb8fc](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/61bb8fcafd94509568f134ccdba7769b66cc7d5d))
|
||||
* use PaymentService for cabinet YooKassa payments to save local DB record ([ff5bba3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ff5bba3fc5d1e1b08d008b64215e487a9eb70960))
|
||||
|
||||
## [3.6.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.5.0...v3.6.0) (2026-02-07)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add OAuth 2.0 authorization (Google, Yandex, Discord, VK) ([97be4af](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/97be4afbffd809fe2786a6d248fc4d3f770cb8cf))
|
||||
* add panel info, node usage endpoints and campaign to user detail ([287a43b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/287a43ba6527ff3464a527821d746a68e5371bbe))
|
||||
* add panel info, node usage endpoints and campaign to user detail ([0703212](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/070321230bcb868e4bc7a39c287ed3431a4aef4a))
|
||||
* add TRIAL_DISABLED_FOR setting to disable trial by user type ([c4794db](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c4794db1dd78f7c48b5da896bdb2f000e493e079))
|
||||
* add user_id filter to admin tickets endpoint ([8886d0d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8886d0dea20aa5a31c6b6f0c3391b3c012b4b34d))
|
||||
* add user_id filter to admin tickets endpoint ([d3819c4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d3819c492f88794e4466c2da986fd3a928d7f3df))
|
||||
* block registration with disposable email addresses ([9ca24ef](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9ca24efe434278925c0c1f8d2f2d644a67985c89))
|
||||
* block registration with disposable email addresses ([116c845](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/116c8453bb371b5eacf5c9d07f497eb449a355cc))
|
||||
* disable trial by user type (email/telegram/all) ([4e7438b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4e7438b9f9c01e30c48fcf2bbe191e9b11598185))
|
||||
* migrate OAuth state storage from in-memory to Redis ([e9b98b8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e9b98b837a8552360ef4c41f6cd7a5779aa8b0a7))
|
||||
* OAuth 2.0 authorization (Google, Yandex, Discord, VK) ([3cbb9ef](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3cbb9ef024695352959ef9a82bf8b81f0ba1d940))
|
||||
* return 30-day daily breakdown for node usage ([7102c50](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7102c50f52d583add863331e96f3a9de189f581a))
|
||||
* return 30-day daily breakdown for node usage ([e4c65ca](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e4c65ca220994cf08ed3510f51d9e2808bb2d154))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* increase OAuth HTTP timeout to 30s ([333a3c5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/333a3c590120a64f6b2963efab1edd861274840c))
|
||||
* parse bandwidth stats series format for node usage ([557dbf3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/557dbf3ebe777d2137e0e28303dc2a803b15c1c6))
|
||||
* parse bandwidth stats series format for node usage ([462f7a9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/462f7a99b9d5c0b7436dbc3d6ab5db6c6cfa3118))
|
||||
* pass tariff object instead of tariff_id to set_tariff_promo_groups ([1ffb8a5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1ffb8a5b85455396006e1fcddd48f4c9a2ca2700))
|
||||
* query per-node legacy endpoint for user traffic breakdown ([b94e3ed](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b94e3edf80e747077992c03882119c7559ad1c31))
|
||||
* query per-node legacy endpoint for user traffic breakdown ([51ca3e4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/51ca3e42b75c1870c76a1b25f667629855cfe886))
|
||||
* reduce node usage to 2 API calls to avoid 429 rate limit ([c68c4e5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c68c4e59846abba9c7c78ae91ec18e2e0e329e3c))
|
||||
* reduce node usage to 2 API calls to avoid 429 rate limit ([f00a051](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f00a051bb323e5ba94a3c38939870986726ed58e))
|
||||
* use accessible nodes API and fix date format for node usage ([943e9a8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/943e9a86aaa449cd3154b0919cfdc52d2a35b509))
|
||||
* use accessible nodes API and fix date format for node usage ([c4da591](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c4da59173155e2eeb69eca21416f816fcbd1fa9c))
|
||||
|
||||
## [3.5.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.4.0...v3.5.0) (2026-02-06)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add tariff reorder API endpoint ([4c2e11e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4c2e11e64bed41592f5a12061dcca74ce43e0806))
|
||||
* pass platform-level fields from RemnaWave config to frontend ([095bc00](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/095bc00b33d7082558a8b7252906db2850dce9da))
|
||||
* serve original RemnaWave config from app-config endpoint ([43762ce](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/43762ce8f4fa7142a1ca62a92b97a027dab2564d))
|
||||
* tariff reorder API endpoint ([085a617](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/085a61721a8175b3f4fd744614c446d73346f2b7))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* enforce blacklist via middleware ([561708b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/561708b7772ec5b84d6ee049aeba26dc70675583))
|
||||
* enforce blacklist via middleware instead of per-handler checks ([966a599](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/966a599c2c778dce9eea3c61adf6067fb33119f6))
|
||||
* exclude signature field from Telegram initData HMAC validation ([5b64046](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5b6404613772610c595e55bde1249cdf6ec3269d))
|
||||
* improve button URL resolution and pass uiConfig to frontend ([0ed98c3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0ed98c39b6c95911a38a26a32d0ffbcf9cfd7c80))
|
||||
* restore unquote for user data parsing in telegram auth ([c2cabbe](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c2cabbee097a41a95d16c34d43ab7e70d076c4dc))
|
||||
|
||||
|
||||
### Reverts
|
||||
|
||||
* remove signature pop from HMAC validation ([4234769](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4234769e92104a6c4f8f1d522e1fca25bc7b20d0))
|
||||
+1
-1
@@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
|
||||
|
||||
FROM python:3.13-slim
|
||||
|
||||
ARG VERSION="v3.1.1"
|
||||
ARG VERSION="v3.16.1" # x-release-please-version
|
||||
ARG BUILD_DATE
|
||||
ARG VCS_REF
|
||||
|
||||
|
||||
@@ -25,15 +25,41 @@ reload-follow: ## Перезапустить контейнеры с логам
|
||||
|
||||
.PHONY: test
|
||||
test: ## Запустить тесты
|
||||
@echo "🧪 Запускаем тесты..."
|
||||
pytest -v
|
||||
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: migrate
|
||||
migrate: ## Применить миграции (alembic upgrade head)
|
||||
uv run alembic upgrade head
|
||||
|
||||
.PHONY: migration
|
||||
migration: ## Создать миграцию (usage: make migration m="description")
|
||||
uv run alembic revision --autogenerate -m "$(m)"
|
||||
|
||||
.PHONY: migrate-stamp
|
||||
migrate-stamp: ## Пометить БД как актуальную (для существующих БД)
|
||||
uv run alembic stamp head
|
||||
|
||||
.PHONY: migrate-history
|
||||
migrate-history: ## Показать историю миграций
|
||||
uv run alembic history --verbose
|
||||
|
||||
.PHONY: help
|
||||
help: ## Показать список доступных команд
|
||||
@echo ""
|
||||
@echo "📘 Команды Makefile:"
|
||||
@echo ""
|
||||
@grep -E '^[a-zA-Z0-9_-]+:.*?##' $(MAKEFILE_LIST) | \
|
||||
sed -E 's/:.*?## /| /' | \
|
||||
awk -F'|' '{printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}'
|
||||
@awk -F':.*## ' '/^[a-zA-Z0-9_-]+:.*## / {printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)
|
||||
@echo ""
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 🚀 Remnawave Bedolaga Bot
|
||||
|
||||
> **🆕 Новый веб-кабинет (Cabinet WebApp)**
|
||||
> **🆕 Новый веб-кабинет (Cabinet WebApp) https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/**
|
||||
>
|
||||
> Вышла новая версия личного кабинета пользователя — веб-интерфейс для управления подписками!
|
||||
>
|
||||
@@ -37,7 +37,7 @@ _Полнофункциональное решение с управлением
|
||||
|
||||
---
|
||||
|
||||
## 🧪 [Тестирование бота](https://t.me/FringVPN_bot)
|
||||
## 🧪 [Тестирование бота](https://t.me/zero_ping_vpn_bot?start=Git)
|
||||
|
||||
## 💬 **[Bedolaga Chat](https://t.me/+wTdMtSWq8YdmZmVi)** - Для общения, вопросов, предложений
|
||||
|
||||
@@ -160,7 +160,6 @@ docker compose logs
|
||||
| -------------- | --------------------------------------------------------------------------- | ------------------------------------------------ |
|
||||
| `polling` | Бот опрашивает Telegram через long polling. HTTP-сервер можно не поднимать. | Локальная отладка или отсутствие внешнего HTTPS. |
|
||||
| `webhook` | Aiogram получает апдейты только через вебхук. | Продакшн и серверы за HTTPS-прокси. |
|
||||
| `both` | Одновременно работают polling и webhook. | Тестирование или повышенная отказоустойчивость. |
|
||||
|
||||
### 2. Минимальные настройки для webhook
|
||||
|
||||
@@ -611,6 +610,16 @@ hooks.domain.com {
|
||||
}
|
||||
}
|
||||
|
||||
handle /remnawave-webhook {
|
||||
reverse_proxy remnawave_bot:8080 {
|
||||
header_up Host {host}
|
||||
header_up X-Real-IP {remote_host}
|
||||
transport http {
|
||||
read_buffer 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# app-config.json с CORS
|
||||
handle /app-config.json {
|
||||
header Access-Control-Allow-Origin "*"
|
||||
@@ -819,6 +828,18 @@ http {
|
||||
proxy_request_buffering off;
|
||||
}
|
||||
|
||||
location = /remnawave-webhook {
|
||||
proxy_pass http://remnawave_bot_unified;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 120s;
|
||||
proxy_send_timeout 120s;
|
||||
proxy_buffering off;
|
||||
proxy_request_buffering off;
|
||||
}
|
||||
|
||||
# app-config.json с CORS
|
||||
location = /app-config.json {
|
||||
add_header Access-Control-Allow-Origin "*";
|
||||
@@ -1012,7 +1033,7 @@ curl -I https://miniapp.domain.com
|
||||
| ---------------- | ----------------------------------------------------------------------------------------------------------------- | ------------------------ |
|
||||
| 🤖 **BOT_TOKEN** | [@BotFather](https://t.me/BotFather) | `1234567890:AABBCCdd...` |
|
||||
| 👑 **ADMIN_IDS** | Твой Telegram ID | `123456789,987654321` |
|
||||
| **BOT_RUN_MODE** | определяет способ приёма обновлений: `polling`, `webhook` или `both`, чтобы одновременно использовать оба режима. |
|
||||
| **BOT_RUN_MODE** | определяет способ приёма обновлений: `polling` или `webhook`. |
|
||||
|
||||
[Полный список доступных параметров:](.env.example)
|
||||
|
||||
@@ -1022,7 +1043,7 @@ curl -I https://miniapp.domain.com
|
||||
|
||||
### 🤖 Режимы запуска бота
|
||||
|
||||
- `BOT_RUN_MODE` — определяет способ приёма обновлений: `polling`, `webhook` или `both`, чтобы одновременно использовать оба режима.
|
||||
- `BOT_RUN_MODE` — определяет способ приёма обновлений: `polling` или `webhook`.
|
||||
- `WEBHOOK_SECRET_TOKEN` — секрет для проверки заголовка `X-Telegram-Bot-Api-Secret-Token` при работе через вебхуки.
|
||||
- `WEBHOOK_DROP_PENDING_UPDATES` — управляет очисткой очереди сообщений при установке вебхука.
|
||||
- `WEBHOOK_MAX_QUEUE_SIZE` — ограничивает длину очереди входящих обновлений, чтобы защащаться от перегрузок.
|
||||
@@ -1056,6 +1077,102 @@ REMNAWAVE_SECRET_KEY=XXXXXXX:DDDDDDDD
|
||||
REMNAWAVE_SECRET_KEY=secret_key_name
|
||||
```
|
||||
|
||||
### 📡 Вебхуки Remnawave (real-time события)
|
||||
|
||||
Бот может принимать входящие вебхуки от панели Remnawave для мгновенной реакции на события подписок. Это значительно улучшает скорость обновления данных по сравнению с периодической синхронизацией.
|
||||
|
||||
#### Поддерживаемые события
|
||||
|
||||
| Событие | Описание |
|
||||
|---------|----------|
|
||||
| `user.expired` | Подписка истекла |
|
||||
| `user.disabled` | Подписка деактивирована |
|
||||
| `user.enabled` | Подписка активирована |
|
||||
| `user.limited` | Превышен лимит трафика |
|
||||
| `user.traffic_reset` | Трафик сброшен |
|
||||
| `user.modified` | Данные подписки изменены (трафик, дата, URL) |
|
||||
| `user.deleted` | Пользователь удалён |
|
||||
| `user.revoked` | Ключи подписки отозваны |
|
||||
| `user.created` | Пользователь создан |
|
||||
| `user.expires_in_*` | Предупреждения об истечении (72ч, 48ч, 24ч) |
|
||||
| `user.first_connected` | Первое подключение |
|
||||
| `user.bandwidth_usage_threshold_reached` | Порог трафика достигнут |
|
||||
| `user_hwid_devices.*` | Устройство добавлено/удалено |
|
||||
| `node.*`, `service.*` | Административные события (ноды, сервис) |
|
||||
|
||||
#### Настройка
|
||||
|
||||
**1. Переменные окружения в `.env`:**
|
||||
|
||||
```env
|
||||
REMNAWAVE_WEBHOOK_ENABLED=true
|
||||
REMNAWAVE_WEBHOOK_PATH=/remnawave-webhook
|
||||
REMNAWAVE_WEBHOOK_SECRET=your_secret_min_32_chars_here
|
||||
```
|
||||
|
||||
Сгенерируйте секрет:
|
||||
|
||||
```bash
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
**2. Настройка в панели Remnawave:**
|
||||
|
||||
В env панели Remnawave заполните:
|
||||
|
||||
- **URL**: `https://hooks.domain.com/remnawave-webhook`
|
||||
- **Secret**: тот же секрет, что и в `REMNAWAVE_WEBHOOK_SECRET`
|
||||
|
||||
**3. Настройка прокси:**
|
||||
|
||||
Добавьте путь `/remnawave-webhook` в конфигурацию обратного прокси.
|
||||
|
||||
**Caddy:**
|
||||
|
||||
```caddy
|
||||
handle /remnawave-webhook {
|
||||
reverse_proxy remnawave_bot:8080 {
|
||||
header_up Host {host}
|
||||
header_up X-Real-IP {remote_host}
|
||||
transport http {
|
||||
read_buffer 0
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Nginx:**
|
||||
|
||||
```nginx
|
||||
location = /remnawave-webhook {
|
||||
proxy_pass http://remnawave_bot_unified;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 120s;
|
||||
proxy_send_timeout 120s;
|
||||
proxy_buffering off;
|
||||
proxy_request_buffering off;
|
||||
}
|
||||
```
|
||||
|
||||
**4. Проверка работоспособности:**
|
||||
|
||||
```bash
|
||||
# Health-check (GET запрос)
|
||||
curl -s https://hooks.domain.com/remnawave-webhook | jq
|
||||
|
||||
# Ожидаемый ответ:
|
||||
# {"status": "ok", "service": "remnawave_webhook", "enabled": true}
|
||||
```
|
||||
|
||||
**Важно:**
|
||||
- Секрет должен быть не менее 32 символов
|
||||
- Бот верифицирует подпись `X-Remnawave-Signature` (HMAC-SHA256) для каждого запроса
|
||||
- При включённых вебхуках бот автоматически защищает подписки от перезаписи данными из периодической синхронизации в течение 60 секунд после получения события
|
||||
- Если бот и панель на одном сервере, URL вебхука может быть `http://remnawave_bot:8080/remnawave-webhook` (внутри Docker-сети)
|
||||
|
||||
### 💳 Freekassa
|
||||
|
||||
Платёжный провайдер [Freekassa](https://freekassa.ru) поддерживает NSPK СБП и банковские карты.
|
||||
@@ -1343,7 +1460,6 @@ CONTEST_BUTTON_VISIBLE=true
|
||||
- 🔄 Автоплатёж с настройкой дня списания
|
||||
- 🎁 Реферальные и промо-бонусы
|
||||
- ⚡ **Быстрое пополнение** с кнопками быстрых сумм
|
||||
- 🔄 **Умная автоактивация** подписки после пополнения баланса
|
||||
|
||||
📱 **Управление подписками**
|
||||
|
||||
@@ -1530,7 +1646,6 @@ CONTEST_BUTTON_VISIBLE=true
|
||||
- 🔄 **Миграция сквадов** - массовый перенос пользователей между сквадами
|
||||
- 🧾 **История операций** - хранение всех транзакций и действий для аудита
|
||||
- 💸 **Сервис автопроверки транзакций** - автоматическая проверка транзакций в статусе "В ожидании оплаты" за последние 24ч
|
||||
- 🔄 **Умная автоактивация** - автоматическая активация подписки после пополнения баланса
|
||||
- 📝 **Ротация логов** - автоматическая очистка и архивация старых логов
|
||||
- 🎮 **Система конкурсов** - ежедневные игры и реферальные конкурсы с призами
|
||||
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
script_location = migrations/alembic
|
||||
prepend_sys_path = .
|
||||
version_path_separator = os
|
||||
sqlalchemy.url = postgresql+asyncpg://vpn_user:your_password@localhost:5432/vpn_bot
|
||||
sqlalchemy.url = driver://user:pass@localhost/dbname
|
||||
|
||||
[post_write_hooks]
|
||||
|
||||
|
||||
+133
-96
@@ -1,121 +1,120 @@
|
||||
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
|
||||
import structlog
|
||||
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.middlewares.button_stats import ButtonStatsMiddleware
|
||||
from app.services.maintenance_service import maintenance_service
|
||||
from app.utils.cache import cache
|
||||
|
||||
from app.handlers import (
|
||||
start,
|
||||
menu,
|
||||
subscription,
|
||||
balance,
|
||||
common,
|
||||
contests as user_contests,
|
||||
menu,
|
||||
polls as user_polls,
|
||||
promocode,
|
||||
referral,
|
||||
support,
|
||||
server_status,
|
||||
common,
|
||||
simple_subscription,
|
||||
start,
|
||||
subscription,
|
||||
support,
|
||||
tickets,
|
||||
)
|
||||
from app.handlers import polls as user_polls
|
||||
from app.handlers import simple_subscription
|
||||
from app.handlers.admin import (
|
||||
main as admin_main,
|
||||
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,
|
||||
users as admin_users,
|
||||
subscriptions as admin_subscriptions,
|
||||
promocodes as admin_promocodes,
|
||||
messages as admin_messages,
|
||||
monitoring as admin_monitoring,
|
||||
referrals as admin_referrals,
|
||||
rules as admin_rules,
|
||||
remnawave as admin_remnawave,
|
||||
statistics as admin_statistics,
|
||||
polls as admin_polls,
|
||||
servers as admin_servers,
|
||||
maintenance as admin_maintenance,
|
||||
promo_groups as admin_promo_groups,
|
||||
campaigns as admin_campaigns,
|
||||
contests as admin_contests,
|
||||
daily_contests as admin_daily_contests,
|
||||
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,
|
||||
faq as admin_faq,
|
||||
main as admin_main,
|
||||
maintenance as admin_maintenance,
|
||||
messages as admin_messages,
|
||||
monitoring as admin_monitoring,
|
||||
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,
|
||||
payments as admin_payments,
|
||||
trials as admin_trials,
|
||||
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 import contests as user_contests
|
||||
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.context_binding import ContextVarsMiddleware
|
||||
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__)
|
||||
logger = structlog.get_logger(__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('Data', callback_data=callback.data)
|
||||
logger.info('User', from_user_id=callback.from_user.id)
|
||||
logger.info('Username', username=callback.from_user.username)
|
||||
|
||||
|
||||
async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
|
||||
try:
|
||||
await cache.connect()
|
||||
logger.info("Кеш инициализирован")
|
||||
logger.info('Кеш инициализирован')
|
||||
except Exception as e:
|
||||
logger.warning(f"Кеш не инициализирован: {e}")
|
||||
|
||||
logger.warning('Кеш не инициализирован', error=e)
|
||||
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
|
||||
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('Не удалось подключиться к Redis', error=e)
|
||||
logger.info('Используется MemoryStorage для FSM')
|
||||
storage = MemoryStorage()
|
||||
|
||||
|
||||
|
||||
dp = Dispatcher(storage=storage)
|
||||
|
||||
dp.message.middleware(ContextVarsMiddleware())
|
||||
dp.callback_query.middleware(ContextVarsMiddleware())
|
||||
dp.pre_checkout_query.middleware(ContextVarsMiddleware())
|
||||
dp.message.middleware(GlobalErrorMiddleware())
|
||||
dp.callback_query.middleware(GlobalErrorMiddleware())
|
||||
dp.pre_checkout_query.middleware(GlobalErrorMiddleware())
|
||||
@@ -123,18 +122,18 @@ 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 активирован")
|
||||
logger.info('📊 ButtonStatsMiddleware активирован')
|
||||
|
||||
if settings.CHANNEL_IS_REQUIRED_SUB:
|
||||
from app.middlewares.channel_checker import ChannelCheckerMiddleware
|
||||
@@ -142,9 +141,9 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
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())
|
||||
@@ -162,7 +161,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)
|
||||
@@ -194,39 +193,77 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
admin_tariffs.register_handlers(dp)
|
||||
admin_bulk_ban.register_bulk_ban_handlers(dp)
|
||||
admin_blacklist.register_blacklist_handlers(dp)
|
||||
admin_blocked_users.register_handlers(dp)
|
||||
common.register_handlers(dp)
|
||||
register_stars_handlers(dp)
|
||||
user_contests.register_handlers(dp)
|
||||
user_polls.register_handlers(dp)
|
||||
simple_subscription.register_simple_subscription_handlers(dp)
|
||||
logger.info("⭐ Зарегистрированы обработчики Telegram Stars платежей")
|
||||
logger.info("⚡ Зарегистрированы обработчики простой покупки")
|
||||
logger.info("⚡ Зарегистрированы обработчики простой подписки")
|
||||
|
||||
logger.info('⭐ Зарегистрированы обработчики Telegram Stars платежей')
|
||||
logger.info('⚡ Зарегистрированы обработчики простой покупки')
|
||||
logger.info('⚡ Зарегистрированы обработчики простой подписки')
|
||||
|
||||
if settings.is_maintenance_monitoring_enabled():
|
||||
try:
|
||||
await maintenance_service.start_monitoring()
|
||||
logger.info("Мониторинг техработ запущен")
|
||||
logger.info('Мониторинг техработ запущен')
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка запуска мониторинга техработ: {e}")
|
||||
logger.error('Ошибка запуска мониторинга техработ', error=e)
|
||||
else:
|
||||
logger.info("Мониторинг техработ отключен настройками")
|
||||
|
||||
logger.info("🛡️ GlobalErrorMiddleware активирован - бот защищен от устаревших callback queries")
|
||||
logger.info("Бот успешно настроен")
|
||||
|
||||
logger.info('Мониторинг техработ отключен настройками')
|
||||
|
||||
logger.info('🛡️ GlobalErrorMiddleware активирован - бот защищен от устаревших callback queries')
|
||||
|
||||
# Validate CONNECT_BUTTON_MODE dependencies
|
||||
if not settings.get_happ_cryptolink_redirect_template():
|
||||
if settings.CONNECT_BUTTON_MODE == 'happ_cryptolink':
|
||||
logger.warning(
|
||||
'⚠️ CONNECT_BUTTON_MODE=happ_cryptolink, но HAPP_CRYPTOLINK_REDIRECT_TEMPLATE не задан! '
|
||||
'Кнопка "Подключиться" не будет отображаться.'
|
||||
)
|
||||
elif settings.CONNECT_BUTTON_MODE == 'guide':
|
||||
logger.warning(
|
||||
'⚠️ CONNECT_BUTTON_MODE=guide, но HAPP_CRYPTOLINK_REDIRECT_TEMPLATE не задан! '
|
||||
'Кнопка "Подключиться" в гайдах не будет работать — Telegram не поддерживает '
|
||||
'кастомные схемы (happ://, v2ray://) в inline-кнопках без HTTPS-редиректа.'
|
||||
)
|
||||
if settings.CONNECT_BUTTON_MODE == 'miniapp_custom' and not settings.MINIAPP_CUSTOM_URL:
|
||||
logger.warning(
|
||||
'⚠️ CONNECT_BUTTON_MODE=miniapp_custom, но MINIAPP_CUSTOM_URL не задан! '
|
||||
'Кнопка "Подключиться" не будет работать.'
|
||||
)
|
||||
if settings.is_cabinet_mode() and not settings.MINIAPP_CUSTOM_URL:
|
||||
logger.warning(
|
||||
'⚠️ MAIN_MENU_MODE=cabinet, но MINIAPP_CUSTOM_URL не задан! '
|
||||
'Кнопки кабинета не смогут открывать разделы MiniApp. '
|
||||
'Установите MINIAPP_CUSTOM_URL.'
|
||||
)
|
||||
elif settings.is_cabinet_mode():
|
||||
logger.info('🏠 Режим Cabinet активен, базовый URL', MINIAPP_CUSTOM_URL=settings.MINIAPP_CUSTOM_URL)
|
||||
|
||||
# Load per-section button styles cache
|
||||
if settings.is_cabinet_mode():
|
||||
try:
|
||||
from app.utils.button_styles_cache import load_button_styles_cache
|
||||
|
||||
await load_button_styles_cache()
|
||||
except Exception as e:
|
||||
logger.warning('Failed to load button styles cache', error=e)
|
||||
|
||||
logger.info('Бот успешно настроен')
|
||||
|
||||
return bot, dp
|
||||
|
||||
|
||||
async def shutdown_bot():
|
||||
try:
|
||||
await maintenance_service.stop_monitoring()
|
||||
logger.info("Мониторинг техработ остановлен")
|
||||
logger.info('Мониторинг техработ остановлен')
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка остановки мониторинга: {e}")
|
||||
|
||||
logger.error('Ошибка остановки мониторинга', error=e)
|
||||
|
||||
try:
|
||||
await cache.close()
|
||||
logger.info("Соединения с кешем закрыты")
|
||||
logger.info('Соединения с кешем закрыты')
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка закрытия кеша: {e}")
|
||||
logger.error('Ошибка закрытия кеша', error=e)
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
"""Cabinet authentication module."""
|
||||
|
||||
from .password_utils import hash_password, verify_password
|
||||
from .jwt_handler import (
|
||||
create_access_token,
|
||||
create_refresh_token,
|
||||
decode_token,
|
||||
get_token_payload,
|
||||
)
|
||||
from .telegram_auth import validate_telegram_login_widget, validate_telegram_init_data
|
||||
from .password_utils import hash_password, verify_password
|
||||
from .telegram_auth import validate_telegram_init_data, validate_telegram_login_widget
|
||||
|
||||
|
||||
__all__ = [
|
||||
"hash_password",
|
||||
"verify_password",
|
||||
"create_access_token",
|
||||
"create_refresh_token",
|
||||
"decode_token",
|
||||
"get_token_payload",
|
||||
"validate_telegram_login_widget",
|
||||
"validate_telegram_init_data",
|
||||
'create_access_token',
|
||||
'create_refresh_token',
|
||||
'decode_token',
|
||||
'get_token_payload',
|
||||
'hash_password',
|
||||
'validate_telegram_init_data',
|
||||
'validate_telegram_login_widget',
|
||||
'verify_password',
|
||||
]
|
||||
|
||||
@@ -1,12 +1,32 @@
|
||||
"""Email verification token generation and validation."""
|
||||
|
||||
import secrets
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
def generate_email_change_code() -> str:
|
||||
"""
|
||||
Generate a 6-digit verification code for email change.
|
||||
|
||||
Returns:
|
||||
6-digit numeric string
|
||||
"""
|
||||
return str(secrets.randbelow(900000) + 100000)
|
||||
|
||||
|
||||
def get_email_change_expires_at() -> datetime:
|
||||
"""
|
||||
Get the expiration datetime for an email change code.
|
||||
|
||||
Returns:
|
||||
Datetime when the email change code expires
|
||||
"""
|
||||
minutes = settings.get_cabinet_email_change_code_expire_minutes()
|
||||
return datetime.now(UTC) + timedelta(minutes=minutes)
|
||||
|
||||
|
||||
def generate_verification_token() -> str:
|
||||
"""
|
||||
Generate a secure random verification token.
|
||||
@@ -35,7 +55,7 @@ def get_verification_expires_at() -> datetime:
|
||||
Datetime when the verification token expires
|
||||
"""
|
||||
hours = settings.get_cabinet_email_verification_expire_hours()
|
||||
return datetime.utcnow() + timedelta(hours=hours)
|
||||
return datetime.now(UTC) + timedelta(hours=hours)
|
||||
|
||||
|
||||
def get_password_reset_expires_at() -> datetime:
|
||||
@@ -46,10 +66,10 @@ def get_password_reset_expires_at() -> datetime:
|
||||
Datetime when the password reset token expires
|
||||
"""
|
||||
hours = settings.get_cabinet_password_reset_expire_hours()
|
||||
return datetime.utcnow() + timedelta(hours=hours)
|
||||
return datetime.now(UTC) + timedelta(hours=hours)
|
||||
|
||||
|
||||
def is_token_expired(expires_at: Optional[datetime]) -> bool:
|
||||
def is_token_expired(expires_at: datetime | None) -> bool:
|
||||
"""
|
||||
Check if a token has expired.
|
||||
|
||||
@@ -61,4 +81,4 @@ def is_token_expired(expires_at: Optional[datetime]) -> bool:
|
||||
"""
|
||||
if expires_at is None:
|
||||
return True
|
||||
return datetime.utcnow() > expires_at
|
||||
return datetime.now(UTC) > expires_at
|
||||
|
||||
@@ -1,36 +1,41 @@
|
||||
"""JWT token handling for cabinet authentication."""
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import jwt
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
from app.config import settings
|
||||
|
||||
JWT_ALGORITHM = "HS256"
|
||||
|
||||
JWT_ALGORITHM = 'HS256'
|
||||
|
||||
|
||||
def create_access_token(user_id: int, telegram_id: int) -> str:
|
||||
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
|
||||
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)
|
||||
expires = datetime.now(UTC) + timedelta(minutes=expire_minutes)
|
||||
|
||||
payload = {
|
||||
"sub": str(user_id),
|
||||
"telegram_id": telegram_id,
|
||||
"type": "access",
|
||||
"exp": expires,
|
||||
"iat": datetime.utcnow(),
|
||||
'sub': str(user_id),
|
||||
'type': 'access',
|
||||
'exp': expires,
|
||||
'iat': datetime.now(UTC),
|
||||
}
|
||||
|
||||
# Добавляем 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)
|
||||
|
||||
@@ -46,20 +51,20 @@ def create_refresh_token(user_id: int) -> str:
|
||||
Encoded JWT refresh token
|
||||
"""
|
||||
expire_days = settings.get_cabinet_refresh_token_expire_days()
|
||||
expires = datetime.utcnow() + timedelta(days=expire_days)
|
||||
expires = datetime.now(UTC) + timedelta(days=expire_days)
|
||||
|
||||
payload = {
|
||||
"sub": str(user_id),
|
||||
"type": "refresh",
|
||||
"exp": expires,
|
||||
"iat": datetime.utcnow(),
|
||||
'sub': str(user_id),
|
||||
'type': 'refresh',
|
||||
'exp': expires,
|
||||
'iat': datetime.now(UTC),
|
||||
}
|
||||
|
||||
secret = settings.get_cabinet_jwt_secret()
|
||||
return jwt.encode(payload, secret, algorithm=JWT_ALGORITHM)
|
||||
|
||||
|
||||
def decode_token(token: str) -> Optional[Dict[str, Any]]:
|
||||
def decode_token(token: str) -> dict[str, Any] | None:
|
||||
"""
|
||||
Decode and validate a JWT token.
|
||||
|
||||
@@ -78,7 +83,7 @@ def decode_token(token: str) -> Optional[Dict[str, Any]]:
|
||||
return None
|
||||
|
||||
|
||||
def get_token_payload(token: str, expected_type: str = "access") -> Optional[Dict[str, Any]]:
|
||||
def get_token_payload(token: str, expected_type: str = 'access') -> dict[str, Any] | None:
|
||||
"""
|
||||
Decode token and verify its type.
|
||||
|
||||
@@ -94,7 +99,7 @@ def get_token_payload(token: str, expected_type: str = "access") -> Optional[Dic
|
||||
if not payload:
|
||||
return None
|
||||
|
||||
if payload.get("type") != expected_type:
|
||||
if payload.get('type') != expected_type:
|
||||
return None
|
||||
|
||||
return payload
|
||||
@@ -103,4 +108,4 @@ def get_token_payload(token: str, expected_type: str = "access") -> Optional[Dic
|
||||
def get_refresh_token_expires_at() -> datetime:
|
||||
"""Get the expiration datetime for a new refresh token."""
|
||||
expire_days = settings.get_cabinet_refresh_token_expire_days()
|
||||
return datetime.utcnow() + timedelta(days=expire_days)
|
||||
return datetime.now(UTC) + timedelta(days=expire_days)
|
||||
|
||||
@@ -0,0 +1,429 @@
|
||||
"""OAuth 2.0 provider implementations for cabinet authentication."""
|
||||
|
||||
import secrets
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any, TypedDict
|
||||
|
||||
import httpx
|
||||
import structlog
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.config import settings
|
||||
from app.utils.cache import cache, cache_key
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
STATE_TTL_SECONDS = 600 # 10 minutes
|
||||
|
||||
|
||||
# --- Typed dicts for provider API responses ---
|
||||
|
||||
|
||||
class OAuthProviderConfig(TypedDict):
|
||||
client_id: str
|
||||
client_secret: str
|
||||
enabled: bool
|
||||
display_name: str
|
||||
|
||||
|
||||
class OAuthTokenResponse(TypedDict, total=False):
|
||||
access_token: str
|
||||
token_type: str
|
||||
expires_in: int
|
||||
refresh_token: str
|
||||
scope: str
|
||||
# VK-specific: email and user_id come in token response
|
||||
email: str
|
||||
user_id: int
|
||||
|
||||
|
||||
class GoogleUserInfoResponse(TypedDict, total=False):
|
||||
sub: str
|
||||
email: str
|
||||
email_verified: bool
|
||||
given_name: str
|
||||
family_name: str
|
||||
picture: str
|
||||
name: str
|
||||
|
||||
|
||||
class YandexUserInfoResponse(TypedDict, total=False):
|
||||
id: str
|
||||
login: str
|
||||
default_email: str
|
||||
emails: list[str]
|
||||
first_name: str
|
||||
last_name: str
|
||||
default_avatar_id: str
|
||||
|
||||
|
||||
class DiscordUserInfoResponse(TypedDict, total=False):
|
||||
id: str
|
||||
username: str
|
||||
global_name: str
|
||||
email: str
|
||||
verified: bool
|
||||
avatar: str
|
||||
|
||||
|
||||
class VKUserInfoItem(TypedDict, total=False):
|
||||
id: int
|
||||
first_name: str
|
||||
last_name: str
|
||||
photo_200: str
|
||||
|
||||
|
||||
class VKUserInfoResponse(TypedDict, total=False):
|
||||
response: list[VKUserInfoItem]
|
||||
|
||||
|
||||
# --- Models ---
|
||||
|
||||
|
||||
class OAuthUserInfo(BaseModel):
|
||||
"""Normalized user info from OAuth provider."""
|
||||
|
||||
provider: str
|
||||
provider_id: str
|
||||
email: str | None = None
|
||||
email_verified: bool = False
|
||||
first_name: str | None = None
|
||||
last_name: str | None = None
|
||||
username: str | None = None
|
||||
avatar_url: str | None = None
|
||||
|
||||
|
||||
# --- CSRF state management (Redis) ---
|
||||
|
||||
|
||||
async def generate_oauth_state(provider: str) -> str:
|
||||
"""Generate a CSRF state token for OAuth flow. Stored in Redis with TTL."""
|
||||
state = secrets.token_urlsafe(32)
|
||||
await cache.set(cache_key('oauth_state', state), provider, expire=STATE_TTL_SECONDS)
|
||||
return state
|
||||
|
||||
|
||||
async def validate_oauth_state(state: str, provider: str) -> bool:
|
||||
"""Validate and consume a CSRF state token from Redis."""
|
||||
key = cache_key('oauth_state', state)
|
||||
stored_provider: str | None = await cache.get(key)
|
||||
if stored_provider is None:
|
||||
return False
|
||||
await cache.delete(key)
|
||||
if stored_provider != provider:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
# --- Provider implementations ---
|
||||
|
||||
|
||||
class OAuthProvider(ABC):
|
||||
"""Base class for OAuth 2.0 providers."""
|
||||
|
||||
name: str
|
||||
display_name: str
|
||||
|
||||
def __init__(self, client_id: str, client_secret: str, redirect_uri: str) -> None:
|
||||
self.client_id = client_id
|
||||
self.client_secret = client_secret
|
||||
self.redirect_uri = redirect_uri
|
||||
|
||||
@abstractmethod
|
||||
def get_authorization_url(self, state: str) -> str:
|
||||
"""Build the authorization URL for the provider."""
|
||||
|
||||
@abstractmethod
|
||||
async def exchange_code(self, code: str) -> OAuthTokenResponse:
|
||||
"""Exchange authorization code for tokens."""
|
||||
|
||||
@abstractmethod
|
||||
async def get_user_info(self, token_data: OAuthTokenResponse) -> OAuthUserInfo:
|
||||
"""Fetch user info from the provider."""
|
||||
|
||||
|
||||
class GoogleProvider(OAuthProvider):
|
||||
name = 'google'
|
||||
display_name = 'Google'
|
||||
|
||||
AUTHORIZE_URL = 'https://accounts.google.com/o/oauth2/v2/auth'
|
||||
TOKEN_URL = 'https://oauth2.googleapis.com/token'
|
||||
USERINFO_URL = 'https://www.googleapis.com/oauth2/v3/userinfo'
|
||||
|
||||
def get_authorization_url(self, state: str) -> str:
|
||||
params: dict[str, str] = {
|
||||
'client_id': self.client_id,
|
||||
'redirect_uri': self.redirect_uri,
|
||||
'response_type': 'code',
|
||||
'scope': 'openid email profile',
|
||||
'state': state,
|
||||
'access_type': 'offline',
|
||||
'prompt': 'select_account',
|
||||
}
|
||||
request = httpx.Request('GET', self.AUTHORIZE_URL, params=params)
|
||||
return str(request.url)
|
||||
|
||||
async def exchange_code(self, code: str) -> OAuthTokenResponse:
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
response = await client.post(
|
||||
self.TOKEN_URL,
|
||||
json={
|
||||
'client_id': self.client_id,
|
||||
'client_secret': self.client_secret,
|
||||
'code': code,
|
||||
'grant_type': 'authorization_code',
|
||||
'redirect_uri': self.redirect_uri,
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data: OAuthTokenResponse = response.json()
|
||||
return data
|
||||
|
||||
async def get_user_info(self, token_data: OAuthTokenResponse) -> OAuthUserInfo:
|
||||
access_token = token_data['access_token']
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
response = await client.get(
|
||||
self.USERINFO_URL,
|
||||
headers={'Authorization': f'Bearer {access_token}'},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data: GoogleUserInfoResponse = response.json()
|
||||
|
||||
return OAuthUserInfo(
|
||||
provider='google',
|
||||
provider_id=str(data['sub']),
|
||||
email=data.get('email'),
|
||||
email_verified=data.get('email_verified', False),
|
||||
first_name=data.get('given_name'),
|
||||
last_name=data.get('family_name'),
|
||||
avatar_url=data.get('picture'),
|
||||
)
|
||||
|
||||
|
||||
class YandexProvider(OAuthProvider):
|
||||
name = 'yandex'
|
||||
display_name = 'Yandex'
|
||||
|
||||
AUTHORIZE_URL = 'https://oauth.yandex.com/authorize'
|
||||
TOKEN_URL = 'https://oauth.yandex.com/token'
|
||||
USERINFO_URL = 'https://login.yandex.ru/info'
|
||||
|
||||
def get_authorization_url(self, state: str) -> str:
|
||||
params: dict[str, str] = {
|
||||
'client_id': self.client_id,
|
||||
'redirect_uri': self.redirect_uri,
|
||||
'response_type': 'code',
|
||||
'scope': 'login:info login:email',
|
||||
'state': state,
|
||||
'force_confirm': 'yes',
|
||||
}
|
||||
request = httpx.Request('GET', self.AUTHORIZE_URL, params=params)
|
||||
return str(request.url)
|
||||
|
||||
async def exchange_code(self, code: str) -> OAuthTokenResponse:
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
response = await client.post(
|
||||
self.TOKEN_URL,
|
||||
data={
|
||||
'client_id': self.client_id,
|
||||
'client_secret': self.client_secret,
|
||||
'code': code,
|
||||
'grant_type': 'authorization_code',
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data: OAuthTokenResponse = response.json()
|
||||
return data
|
||||
|
||||
async def get_user_info(self, token_data: OAuthTokenResponse) -> OAuthUserInfo:
|
||||
access_token = token_data['access_token']
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
response = await client.get(
|
||||
self.USERINFO_URL,
|
||||
params={'format': 'json'},
|
||||
headers={'Authorization': f'OAuth {access_token}'},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data: YandexUserInfoResponse = response.json()
|
||||
|
||||
default_email = data.get('default_email')
|
||||
emails = data.get('emails', [])
|
||||
email = default_email or (emails[0] if emails else None)
|
||||
|
||||
return OAuthUserInfo(
|
||||
provider='yandex',
|
||||
provider_id=str(data['id']),
|
||||
email=email,
|
||||
email_verified=bool(email),
|
||||
first_name=data.get('first_name'),
|
||||
last_name=data.get('last_name'),
|
||||
username=data.get('login'),
|
||||
avatar_url=(
|
||||
f'https://avatars.yandex.net/get-yapic/{data["default_avatar_id"]}/islands-200'
|
||||
if data.get('default_avatar_id')
|
||||
else None
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class DiscordProvider(OAuthProvider):
|
||||
name = 'discord'
|
||||
display_name = 'Discord'
|
||||
|
||||
AUTHORIZE_URL = 'https://discord.com/api/oauth2/authorize'
|
||||
TOKEN_URL = 'https://discord.com/api/oauth2/token'
|
||||
USERINFO_URL = 'https://discord.com/api/v10/users/@me'
|
||||
|
||||
def get_authorization_url(self, state: str) -> str:
|
||||
params: dict[str, str] = {
|
||||
'client_id': self.client_id,
|
||||
'redirect_uri': self.redirect_uri,
|
||||
'response_type': 'code',
|
||||
'scope': 'identify email',
|
||||
'state': state,
|
||||
'prompt': 'consent',
|
||||
}
|
||||
request = httpx.Request('GET', self.AUTHORIZE_URL, params=params)
|
||||
return str(request.url)
|
||||
|
||||
async def exchange_code(self, code: str) -> OAuthTokenResponse:
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
response = await client.post(
|
||||
self.TOKEN_URL,
|
||||
data={
|
||||
'client_id': self.client_id,
|
||||
'client_secret': self.client_secret,
|
||||
'code': code,
|
||||
'grant_type': 'authorization_code',
|
||||
'redirect_uri': self.redirect_uri,
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data: OAuthTokenResponse = response.json()
|
||||
return data
|
||||
|
||||
async def get_user_info(self, token_data: OAuthTokenResponse) -> OAuthUserInfo:
|
||||
access_token = token_data['access_token']
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
response = await client.get(
|
||||
self.USERINFO_URL,
|
||||
headers={'Authorization': f'Bearer {access_token}'},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data: DiscordUserInfoResponse = response.json()
|
||||
|
||||
avatar_url: str | None = None
|
||||
if data.get('avatar'):
|
||||
avatar_url = f'https://cdn.discordapp.com/avatars/{data["id"]}/{data["avatar"]}.png'
|
||||
|
||||
return OAuthUserInfo(
|
||||
provider='discord',
|
||||
provider_id=str(data['id']),
|
||||
email=data.get('email'),
|
||||
email_verified=data.get('verified', False),
|
||||
first_name=data.get('global_name') or data.get('username'),
|
||||
username=data.get('username'),
|
||||
avatar_url=avatar_url,
|
||||
)
|
||||
|
||||
|
||||
class VKProvider(OAuthProvider):
|
||||
name = 'vk'
|
||||
display_name = 'VK'
|
||||
|
||||
AUTHORIZE_URL = 'https://oauth.vk.com/authorize'
|
||||
TOKEN_URL = 'https://oauth.vk.com/access_token'
|
||||
USERINFO_URL = 'https://api.vk.com/method/users.get'
|
||||
API_VERSION = '5.131'
|
||||
|
||||
def get_authorization_url(self, state: str) -> str:
|
||||
params: dict[str, str] = {
|
||||
'client_id': self.client_id,
|
||||
'redirect_uri': self.redirect_uri,
|
||||
'response_type': 'code',
|
||||
'scope': 'email',
|
||||
'state': state,
|
||||
'v': self.API_VERSION,
|
||||
}
|
||||
request = httpx.Request('GET', self.AUTHORIZE_URL, params=params)
|
||||
return str(request.url)
|
||||
|
||||
async def exchange_code(self, code: str) -> OAuthTokenResponse:
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
response = await client.get(
|
||||
self.TOKEN_URL,
|
||||
params={
|
||||
'client_id': self.client_id,
|
||||
'client_secret': self.client_secret,
|
||||
'code': code,
|
||||
'redirect_uri': self.redirect_uri,
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data: OAuthTokenResponse = response.json()
|
||||
return data
|
||||
|
||||
async def get_user_info(self, token_data: OAuthTokenResponse) -> OAuthUserInfo:
|
||||
access_token = token_data['access_token']
|
||||
user_id: int | None = token_data.get('user_id')
|
||||
# VK returns email in token response, not in userinfo
|
||||
email: str | None = token_data.get('email')
|
||||
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
response = await client.get(
|
||||
self.USERINFO_URL,
|
||||
params={
|
||||
'access_token': access_token,
|
||||
'fields': 'photo_200',
|
||||
'v': self.API_VERSION,
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data: VKUserInfoResponse = response.json()
|
||||
|
||||
users: list[Any] = data.get('response', [])
|
||||
user_data: VKUserInfoItem = users[0] if users else {} # type: ignore[assignment]
|
||||
|
||||
return OAuthUserInfo(
|
||||
provider='vk',
|
||||
provider_id=str(user_id or user_data.get('id', '')),
|
||||
email=email,
|
||||
email_verified=bool(email),
|
||||
first_name=user_data.get('first_name'),
|
||||
last_name=user_data.get('last_name'),
|
||||
avatar_url=user_data.get('photo_200'),
|
||||
)
|
||||
|
||||
|
||||
# --- Provider factory ---
|
||||
|
||||
_PROVIDERS: dict[str, type[OAuthProvider]] = {
|
||||
'google': GoogleProvider,
|
||||
'yandex': YandexProvider,
|
||||
'discord': DiscordProvider,
|
||||
'vk': VKProvider,
|
||||
}
|
||||
|
||||
|
||||
def get_provider(name: str) -> OAuthProvider | None:
|
||||
"""Get an OAuth provider instance if enabled.
|
||||
|
||||
Returns None if the provider is not enabled or not found.
|
||||
"""
|
||||
providers_config: dict[str, OAuthProviderConfig] = settings.get_oauth_providers_config()
|
||||
config = providers_config.get(name)
|
||||
if not config or not config['enabled']:
|
||||
return None
|
||||
|
||||
provider_class = _PROVIDERS.get(name)
|
||||
if not provider_class:
|
||||
return None
|
||||
|
||||
redirect_uri = f'{settings.CABINET_URL}/auth/oauth/callback'
|
||||
|
||||
return provider_class(
|
||||
client_id=config['client_id'],
|
||||
client_secret=config['client_secret'],
|
||||
redirect_uri=redirect_uri,
|
||||
)
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import bcrypt
|
||||
|
||||
|
||||
BCRYPT_ROUNDS = 12
|
||||
|
||||
|
||||
@@ -15,10 +16,10 @@ def hash_password(password: str) -> str:
|
||||
Returns:
|
||||
Hashed password string
|
||||
"""
|
||||
password_bytes = password.encode("utf-8")
|
||||
password_bytes = password.encode('utf-8')
|
||||
salt = bcrypt.gensalt(rounds=BCRYPT_ROUNDS)
|
||||
hashed = bcrypt.hashpw(password_bytes, salt)
|
||||
return hashed.decode("utf-8")
|
||||
return hashed.decode('utf-8')
|
||||
|
||||
|
||||
def verify_password(password: str, password_hash: str) -> bool:
|
||||
@@ -33,8 +34,8 @@ def verify_password(password: str, password_hash: str) -> bool:
|
||||
True if password matches, False otherwise
|
||||
"""
|
||||
try:
|
||||
password_bytes = password.encode("utf-8")
|
||||
hash_bytes = password_hash.encode("utf-8")
|
||||
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
|
||||
|
||||
@@ -3,14 +3,14 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any, Optional
|
||||
from datetime import UTC, 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:
|
||||
def validate_telegram_login_widget(data: dict[str, Any], max_age_seconds: int = 86400) -> bool:
|
||||
"""
|
||||
Validate Telegram Login Widget data.
|
||||
|
||||
@@ -24,42 +24,38 @@ def validate_telegram_login_widget(data: Dict[str, Any], max_age_seconds: int =
|
||||
True if data is valid, False otherwise
|
||||
"""
|
||||
auth_data = data.copy()
|
||||
check_hash = auth_data.pop("hash", None)
|
||||
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")
|
||||
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()
|
||||
auth_time = datetime.fromtimestamp(int(auth_date), tz=UTC)
|
||||
age = (datetime.now(UTC) - auth_time).total_seconds()
|
||||
if age > max_age_seconds:
|
||||
return False
|
||||
except (ValueError, TypeError, OSError):
|
||||
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)
|
||||
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()
|
||||
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) -> Optional[Dict[str, Any]]:
|
||||
def validate_telegram_init_data(init_data: str, max_age_seconds: int = 86400) -> dict[str, Any] | None:
|
||||
"""
|
||||
Validate Telegram WebApp initData.
|
||||
|
||||
@@ -76,46 +72,38 @@ def validate_telegram_init_data(init_data: str, max_age_seconds: int = 86400) ->
|
||||
# Parse the init_data string
|
||||
parsed = dict(parse_qsl(init_data, keep_blank_values=True))
|
||||
|
||||
received_hash = parsed.pop("hash", None)
|
||||
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")
|
||||
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()
|
||||
auth_time = datetime.fromtimestamp(int(auth_date), tz=UTC)
|
||||
age = (datetime.now(UTC) - auth_time).total_seconds()
|
||||
if age > max_age_seconds:
|
||||
return None
|
||||
except (ValueError, TypeError, OSError):
|
||||
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)
|
||||
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()
|
||||
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()
|
||||
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")
|
||||
user_data_str = parsed.get('user')
|
||||
if user_data_str:
|
||||
user_data = json.loads(unquote(user_data_str))
|
||||
return user_data
|
||||
@@ -126,7 +114,7 @@ def validate_telegram_init_data(init_data: str, max_age_seconds: int = 86400) ->
|
||||
return None
|
||||
|
||||
|
||||
def extract_telegram_user_from_init_data(init_data: str) -> Optional[Dict[str, Any]]:
|
||||
def extract_telegram_user_from_init_data(init_data: str) -> dict[str, Any] | None:
|
||||
"""
|
||||
Extract and validate user info from Telegram WebApp initData.
|
||||
|
||||
|
||||
+116
-26
@@ -1,18 +1,38 @@
|
||||
"""FastAPI dependencies for cabinet module."""
|
||||
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from typing import Optional
|
||||
import asyncio
|
||||
|
||||
import structlog
|
||||
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.database.crud.user import get_user_by_id
|
||||
from app.config import settings
|
||||
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 = structlog.get_logger(__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."""
|
||||
@@ -24,7 +44,7 @@ async def get_cabinet_db() -> AsyncSession:
|
||||
|
||||
|
||||
async def get_current_cabinet_user(
|
||||
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(security),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> User:
|
||||
"""
|
||||
@@ -40,30 +60,35 @@ async def get_current_cabinet_user(
|
||||
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"},
|
||||
detail='Authentication required',
|
||||
headers={'WWW-Authenticate': 'Bearer'},
|
||||
)
|
||||
|
||||
token = credentials.credentials
|
||||
payload = get_token_payload(token, expected_type="access")
|
||||
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"},
|
||||
detail='Invalid or expired token',
|
||||
headers={'WWW-Authenticate': 'Bearer'},
|
||||
)
|
||||
|
||||
try:
|
||||
user_id = int(payload.get("sub"))
|
||||
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"},
|
||||
detail='Invalid token payload',
|
||||
headers={'WWW-Authenticate': 'Bearer'},
|
||||
)
|
||||
|
||||
user = await get_user_by_id(db, user_id)
|
||||
@@ -71,22 +96,86 @@ async def get_current_cabinet_user(
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="User not found",
|
||||
detail='User not found',
|
||||
)
|
||||
|
||||
if user.status != "active":
|
||||
if user.status != 'active':
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="User account is not active",
|
||||
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('Timeout checking channel subscription for user', telegram_id=user.telegram_id)
|
||||
# Don't block user if check times out
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
'Failed to check channel subscription for user', telegram_id=user.telegram_id, error=e
|
||||
)
|
||||
# Don't block user if check fails
|
||||
|
||||
return user
|
||||
|
||||
|
||||
async def get_optional_cabinet_user(
|
||||
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(security),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> Optional[User]:
|
||||
) -> User | None:
|
||||
"""
|
||||
Optionally get current authenticated cabinet user.
|
||||
|
||||
@@ -96,19 +185,19 @@ async def get_optional_cabinet_user(
|
||||
return None
|
||||
|
||||
token = credentials.credentials
|
||||
payload = get_token_payload(token, expected_type="access")
|
||||
payload = get_token_payload(token, expected_type='access')
|
||||
|
||||
if not payload:
|
||||
return None
|
||||
|
||||
try:
|
||||
user_id = int(payload.get("sub"))
|
||||
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":
|
||||
if not user or user.status != 'active':
|
||||
return None
|
||||
|
||||
return user
|
||||
@@ -120,7 +209,7 @@ async def get_current_admin_user(
|
||||
"""
|
||||
Get current authenticated admin user.
|
||||
|
||||
Checks if the user's telegram_id is in ADMIN_IDS from settings.
|
||||
Checks if the user is admin by telegram_id or email.
|
||||
|
||||
Args:
|
||||
user: Authenticated User object
|
||||
@@ -131,10 +220,11 @@ async def get_current_admin_user(
|
||||
Raises:
|
||||
HTTPException: If user is not an admin
|
||||
"""
|
||||
if not settings.is_admin(user.telegram_id):
|
||||
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",
|
||||
detail='Admin access required',
|
||||
)
|
||||
|
||||
return user
|
||||
|
||||
@@ -2,48 +2,64 @@
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from .auth import router as auth_router
|
||||
from .subscription import router as subscription_router
|
||||
from .balance import router as balance_router
|
||||
from .referral import router as referral_router
|
||||
from .tickets import router as tickets_router
|
||||
from .ticket_notifications import router as ticket_notifications_router
|
||||
from .ticket_notifications import admin_router as admin_ticket_notifications_router
|
||||
from .admin_tickets import router as admin_tickets_router
|
||||
from .admin_settings import router as admin_settings_router
|
||||
from .admin_apps import router as admin_apps_router
|
||||
from .promocode import router as promocode_router
|
||||
from .contests import router as contests_router
|
||||
from .polls import router as polls_router
|
||||
from .promo import router as promo_router
|
||||
from .notifications import router as notifications_router
|
||||
from .info import router as info_router
|
||||
from .branding import router as branding_router
|
||||
from .wheel import router as wheel_router
|
||||
from .admin_wheel import router as admin_wheel_router
|
||||
from .admin_tariffs import router as admin_tariffs_router
|
||||
from .admin_servers import router as admin_servers_router
|
||||
from .admin_stats import router as admin_stats_router
|
||||
from .admin_ban_system import router as admin_ban_system_router
|
||||
from .admin_broadcasts import router as admin_broadcasts_router
|
||||
from .admin_promocodes import router as admin_promocodes_router
|
||||
from .admin_promocodes import promo_groups_router as admin_promo_groups_router
|
||||
from .admin_button_styles import router as admin_button_styles_router
|
||||
from .admin_campaigns import router as admin_campaigns_router
|
||||
from .admin_users import router as admin_users_router
|
||||
from .admin_email_templates import router as admin_email_templates_router
|
||||
from .admin_partners import router as admin_partners_router
|
||||
from .admin_payment_methods import router as admin_payment_methods_router
|
||||
from .admin_payments import router as admin_payments_router
|
||||
from .admin_pinned_messages import router as admin_pinned_messages_router
|
||||
from .admin_promo_offers import router as admin_promo_offers_router
|
||||
from .admin_promocodes import promo_groups_router as admin_promo_groups_router, router as admin_promocodes_router
|
||||
from .admin_remnawave import router as admin_remnawave_router
|
||||
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 .admin_withdrawals import router as admin_withdrawals_router
|
||||
from .auth import router as auth_router
|
||||
from .balance import router as balance_router
|
||||
from .branding import router as branding_router
|
||||
from .contests import router as contests_router
|
||||
from .info import router as info_router
|
||||
from .media import router as media_router
|
||||
from .notifications import router as notifications_router
|
||||
from .oauth import router as oauth_router
|
||||
from .partner_application import router as partner_application_router
|
||||
from .polls import router as polls_router
|
||||
from .promo import router as promo_router
|
||||
from .promocode import router as promocode_router
|
||||
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
|
||||
from .withdrawal import router as withdrawal_router
|
||||
|
||||
|
||||
# Main cabinet router
|
||||
router = APIRouter(prefix="/cabinet", tags=["Cabinet"])
|
||||
router = APIRouter(prefix='/cabinet', tags=['Cabinet'])
|
||||
|
||||
# Include all sub-routers
|
||||
router.include_router(auth_router)
|
||||
router.include_router(oauth_router)
|
||||
router.include_router(subscription_router)
|
||||
router.include_router(balance_router)
|
||||
router.include_router(referral_router)
|
||||
router.include_router(partner_application_router)
|
||||
router.include_router(withdrawal_router)
|
||||
# Notifications router MUST be before tickets router to avoid route conflict
|
||||
router.include_router(ticket_notifications_router)
|
||||
router.include_router(tickets_router)
|
||||
@@ -73,12 +89,20 @@ router.include_router(admin_broadcasts_router)
|
||||
router.include_router(admin_promocodes_router)
|
||||
router.include_router(admin_promo_groups_router)
|
||||
router.include_router(admin_campaigns_router)
|
||||
router.include_router(admin_partners_router)
|
||||
router.include_router(admin_withdrawals_router)
|
||||
router.include_router(admin_users_router)
|
||||
router.include_router(admin_payment_methods_router)
|
||||
router.include_router(admin_payments_router)
|
||||
router.include_router(admin_promo_offers_router)
|
||||
router.include_router(admin_remnawave_router)
|
||||
router.include_router(admin_email_templates_router)
|
||||
router.include_router(admin_updates_router)
|
||||
router.include_router(admin_traffic_router)
|
||||
router.include_router(admin_pinned_messages_router)
|
||||
router.include_router(admin_button_styles_router)
|
||||
|
||||
# WebSocket route
|
||||
router.include_router(websocket_router)
|
||||
|
||||
__all__ = ["router"]
|
||||
__all__ = ['router']
|
||||
|
||||
+126
-101
@@ -1,71 +1,78 @@
|
||||
"""Admin routes for managing VPN applications in app-config.json."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import List, Optional, Dict, Any
|
||||
from pathlib import Path
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import User
|
||||
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"])
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/apps', tags=['Cabinet Admin Apps'])
|
||||
|
||||
|
||||
# ============ Schemas ============
|
||||
|
||||
|
||||
class LocalizedText(BaseModel):
|
||||
"""Localized text for multiple languages."""
|
||||
en: str = ""
|
||||
ru: str = ""
|
||||
zh: Optional[str] = ""
|
||||
fa: Optional[str] = ""
|
||||
|
||||
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: Optional[List[AppButton]] = None
|
||||
title: Optional[LocalizedText] = None
|
||||
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: Optional[bool] = None
|
||||
isNeedBase64Encoding: bool | None = None
|
||||
installationStep: AppStep
|
||||
addSubscriptionStep: AppStep
|
||||
connectAndUseStep: AppStep
|
||||
additionalBeforeAddSubscriptionStep: Optional[AppStep] = None
|
||||
additionalAfterAddSubscriptionStep: Optional[AppStep] = None
|
||||
additionalBeforeAddSubscriptionStep: AppStep | None = None
|
||||
additionalAfterAddSubscriptionStep: AppStep | None = None
|
||||
|
||||
|
||||
class PlatformApps(BaseModel):
|
||||
"""Apps for a specific platform."""
|
||||
|
||||
platform: str
|
||||
apps: List[AppDefinition]
|
||||
apps: list[AppDefinition]
|
||||
|
||||
|
||||
class AppConfigBranding(BaseModel):
|
||||
"""Branding configuration."""
|
||||
|
||||
name: str
|
||||
logoUrl: str
|
||||
supportUrl: str
|
||||
@@ -73,39 +80,46 @@ class AppConfigBranding(BaseModel):
|
||||
|
||||
class AppConfigConfig(BaseModel):
|
||||
"""Top-level config section."""
|
||||
additionalLocales: List[str]
|
||||
|
||||
additionalLocales: list[str]
|
||||
branding: AppConfigBranding
|
||||
|
||||
|
||||
class AppConfigResponse(BaseModel):
|
||||
"""Full app config response."""
|
||||
|
||||
config: AppConfigConfig
|
||||
platforms: Dict[str, List[AppDefinition]]
|
||||
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]
|
||||
|
||||
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())
|
||||
@@ -117,16 +131,16 @@ def _load_config() -> dict:
|
||||
if not config_path.exists():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"App config file not found: {config_path}",
|
||||
detail=f'App config file not found: {config_path}',
|
||||
)
|
||||
|
||||
try:
|
||||
with open(config_path, "r", encoding="utf-8") as f:
|
||||
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}",
|
||||
detail=f'Failed to parse app config: {e}',
|
||||
)
|
||||
|
||||
|
||||
@@ -135,21 +149,22 @@ def _save_config(config: dict) -> None:
|
||||
config_path = _get_config_path()
|
||||
|
||||
try:
|
||||
with open(config_path, "w", encoding="utf-8") as f:
|
||||
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}",
|
||||
detail=f'Failed to save app config: {e}',
|
||||
)
|
||||
|
||||
|
||||
VALID_PLATFORMS = ["ios", "android", "macos", "windows", "linux", "androidTV", "appleTV"]
|
||||
VALID_PLATFORMS = ['ios', 'android', 'macos', 'windows', 'linux', 'androidTV', 'appleTV']
|
||||
|
||||
|
||||
# ============ Routes ============
|
||||
|
||||
@router.get("", response_model=AppConfigResponse)
|
||||
|
||||
@router.get('', response_model=AppConfigResponse)
|
||||
async def get_app_config(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
):
|
||||
@@ -158,7 +173,7 @@ async def get_app_config(
|
||||
return config
|
||||
|
||||
|
||||
@router.get("/platforms", response_model=List[str])
|
||||
@router.get('/platforms', response_model=list[str])
|
||||
async def get_platforms(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
):
|
||||
@@ -166,7 +181,7 @@ async def get_platforms(
|
||||
return VALID_PLATFORMS
|
||||
|
||||
|
||||
@router.get("/platforms/{platform}", response_model=List[AppDefinition])
|
||||
@router.get('/platforms/{platform}', response_model=list[AppDefinition])
|
||||
async def get_platform_apps(
|
||||
platform: str,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
@@ -175,15 +190,15 @@ async def get_platform_apps(
|
||||
if platform not in VALID_PLATFORMS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid platform: {platform}. Valid platforms: {VALID_PLATFORMS}",
|
||||
detail=f'Invalid platform: {platform}. Valid platforms: {VALID_PLATFORMS}',
|
||||
)
|
||||
|
||||
config = _load_config()
|
||||
platforms = config.get("platforms", {})
|
||||
platforms = config.get('platforms', {})
|
||||
return platforms.get(platform, [])
|
||||
|
||||
|
||||
@router.post("/platforms/{platform}", response_model=AppDefinition)
|
||||
@router.post('/platforms/{platform}', response_model=AppDefinition)
|
||||
async def create_app(
|
||||
platform: str,
|
||||
request: CreateAppRequest,
|
||||
@@ -193,17 +208,17 @@ async def create_app(
|
||||
if platform not in VALID_PLATFORMS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid platform: {platform}",
|
||||
detail=f'Invalid platform: {platform}',
|
||||
)
|
||||
|
||||
config = _load_config()
|
||||
platforms = config.get("platforms", {})
|
||||
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]]
|
||||
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,
|
||||
@@ -213,15 +228,15 @@ async def create_app(
|
||||
# Add new app
|
||||
app_dict = request.app.model_dump(exclude_none=True)
|
||||
platforms[platform].append(app_dict)
|
||||
config["platforms"] = platforms
|
||||
config['platforms'] = platforms
|
||||
|
||||
_save_config(config)
|
||||
logger.info(f"Admin {admin.id} created app '{request.app.id}' for platform '{platform}'")
|
||||
logger.info('Admin created app for platform', admin_id=admin.id, app_id=request.app.id, platform=platform)
|
||||
|
||||
return request.app
|
||||
|
||||
|
||||
@router.put("/platforms/{platform}/{app_id}", response_model=AppDefinition)
|
||||
@router.put('/platforms/{platform}/{app_id}', response_model=AppDefinition)
|
||||
async def update_app(
|
||||
platform: str,
|
||||
app_id: str,
|
||||
@@ -232,17 +247,17 @@ async def update_app(
|
||||
if platform not in VALID_PLATFORMS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid platform: {platform}",
|
||||
detail=f'Invalid platform: {platform}',
|
||||
)
|
||||
|
||||
config = _load_config()
|
||||
platforms = config.get("platforms", {})
|
||||
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:
|
||||
if app.get('id') == app_id:
|
||||
app_index = i
|
||||
break
|
||||
|
||||
@@ -256,15 +271,15 @@ async def update_app(
|
||||
app_dict = request.app.model_dump(exclude_none=True)
|
||||
apps[app_index] = app_dict
|
||||
platforms[platform] = apps
|
||||
config["platforms"] = platforms
|
||||
config['platforms'] = platforms
|
||||
|
||||
_save_config(config)
|
||||
logger.info(f"Admin {admin.id} updated app '{app_id}' in platform '{platform}'")
|
||||
logger.info('Admin updated app in platform', admin_id=admin.id, app_id=app_id, platform=platform)
|
||||
|
||||
return request.app
|
||||
|
||||
|
||||
@router.delete("/platforms/{platform}/{app_id}")
|
||||
@router.delete('/platforms/{platform}/{app_id}')
|
||||
async def delete_app(
|
||||
platform: str,
|
||||
app_id: str,
|
||||
@@ -274,16 +289,16 @@ async def delete_app(
|
||||
if platform not in VALID_PLATFORMS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid platform: {platform}",
|
||||
detail=f'Invalid platform: {platform}',
|
||||
)
|
||||
|
||||
config = _load_config()
|
||||
platforms = config.get("platforms", {})
|
||||
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]
|
||||
apps = [app for app in apps if app.get('id') != app_id]
|
||||
|
||||
if len(apps) == original_length:
|
||||
raise HTTPException(
|
||||
@@ -292,15 +307,15 @@ async def delete_app(
|
||||
)
|
||||
|
||||
platforms[platform] = apps
|
||||
config["platforms"] = platforms
|
||||
config['platforms'] = platforms
|
||||
|
||||
_save_config(config)
|
||||
logger.info(f"Admin {admin.id} deleted app '{app_id}' from platform '{platform}'")
|
||||
logger.info('Admin deleted app from platform', admin_id=admin.id, app_id=app_id, platform=platform)
|
||||
|
||||
return {"status": "deleted", "app_id": app_id}
|
||||
return {'status': 'deleted', 'app_id': app_id}
|
||||
|
||||
|
||||
@router.post("/platforms/{platform}/reorder")
|
||||
@router.post('/platforms/{platform}/reorder')
|
||||
async def reorder_apps(
|
||||
platform: str,
|
||||
request: ReorderAppsRequest,
|
||||
@@ -310,15 +325,15 @@ async def reorder_apps(
|
||||
if platform not in VALID_PLATFORMS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid platform: {platform}",
|
||||
detail=f'Invalid platform: {platform}',
|
||||
)
|
||||
|
||||
config = _load_config()
|
||||
platforms = config.get("platforms", {})
|
||||
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}
|
||||
apps_map = {app.get('id'): app for app in apps}
|
||||
|
||||
# Verify all IDs exist
|
||||
for app_id in request.app_ids:
|
||||
@@ -333,19 +348,19 @@ async def reorder_apps(
|
||||
|
||||
# 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:
|
||||
if app.get('id') not in request.app_ids:
|
||||
reordered_apps.append(app)
|
||||
|
||||
platforms[platform] = reordered_apps
|
||||
config["platforms"] = platforms
|
||||
config['platforms'] = platforms
|
||||
|
||||
_save_config(config)
|
||||
logger.info(f"Admin {admin.id} reordered apps in platform '{platform}'")
|
||||
logger.info('Admin reordered apps in platform', admin_id=admin.id, platform=platform)
|
||||
|
||||
return {"status": "reordered", "order": request.app_ids}
|
||||
return {'status': 'reordered', 'order': request.app_ids}
|
||||
|
||||
|
||||
@router.put("/branding", response_model=AppConfigBranding)
|
||||
@router.put('/branding', response_model=AppConfigBranding)
|
||||
async def update_branding(
|
||||
request: UpdateBrandingRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
@@ -353,28 +368,28 @@ async def update_branding(
|
||||
"""Update branding configuration."""
|
||||
config = _load_config()
|
||||
|
||||
if "config" not in config:
|
||||
config["config"] = {}
|
||||
if 'config' not in config:
|
||||
config['config'] = {}
|
||||
|
||||
config["config"]["branding"] = request.branding.model_dump()
|
||||
config['config']['branding'] = request.branding.model_dump()
|
||||
|
||||
_save_config(config)
|
||||
logger.info(f"Admin {admin.id} updated branding")
|
||||
logger.info('Admin updated branding', admin_id=admin.id)
|
||||
|
||||
return request.branding
|
||||
|
||||
|
||||
@router.get("/branding", response_model=AppConfigBranding)
|
||||
@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", {})
|
||||
branding = config.get('config', {}).get('branding', {})
|
||||
return branding
|
||||
|
||||
|
||||
@router.post("/platforms/{platform}/copy/{app_id}")
|
||||
@router.post('/platforms/{platform}/copy/{app_id}')
|
||||
async def copy_app_to_platform(
|
||||
platform: str,
|
||||
app_id: str,
|
||||
@@ -385,17 +400,17 @@ async def copy_app_to_platform(
|
||||
if platform not in VALID_PLATFORMS or target_platform not in VALID_PLATFORMS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid platform(s)",
|
||||
detail='Invalid platform(s)',
|
||||
)
|
||||
|
||||
config = _load_config()
|
||||
platforms = config.get("platforms", {})
|
||||
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:
|
||||
if app.get('id') == app_id:
|
||||
source_app = app.copy()
|
||||
break
|
||||
|
||||
@@ -407,44 +422,55 @@ async def copy_app_to_platform(
|
||||
|
||||
# Generate new ID for copied app
|
||||
import time
|
||||
new_id = f"{app_id}-copy-{int(time.time())}"
|
||||
source_app["id"] = new_id
|
||||
|
||||
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
|
||||
config['platforms'] = platforms
|
||||
|
||||
_save_config(config)
|
||||
logger.info(f"Admin {admin.id} copied app '{app_id}' from '{platform}' to '{target_platform}' as '{new_id}'")
|
||||
logger.info(
|
||||
'Admin copied app from to as',
|
||||
admin_id=admin.id,
|
||||
app_id=app_id,
|
||||
platform=platform,
|
||||
target_platform=target_platform,
|
||||
new_id=new_id,
|
||||
)
|
||||
|
||||
return {"status": "copied", "new_id": new_id, "target_platform": target_platform}
|
||||
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: Optional[str] = None
|
||||
config_uuid: str | None = None
|
||||
|
||||
|
||||
class UpdateRemnaWaveUuidRequest(BaseModel):
|
||||
"""Request to update RemnaWave config UUID."""
|
||||
uuid: Optional[str] = None
|
||||
|
||||
uuid: str | None = None
|
||||
|
||||
|
||||
def _get_remnawave_config_uuid() -> Optional[str]:
|
||||
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")
|
||||
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)
|
||||
@router.get('/remnawave/status', response_model=RemnaWaveConfigStatus)
|
||||
async def get_remnawave_config_status(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
):
|
||||
@@ -456,7 +482,7 @@ async def get_remnawave_config_status(
|
||||
)
|
||||
|
||||
|
||||
@router.put("/remnawave/uuid", response_model=RemnaWaveConfigStatus)
|
||||
@router.put('/remnawave/uuid', response_model=RemnaWaveConfigStatus)
|
||||
async def set_remnawave_config_uuid(
|
||||
request: UpdateRemnaWaveUuidRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
@@ -468,24 +494,23 @@ async def set_remnawave_config_uuid(
|
||||
# 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}$'
|
||||
)
|
||||
|
||||
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",
|
||||
detail='Invalid UUID format',
|
||||
)
|
||||
|
||||
try:
|
||||
await bot_configuration_service.set_value(db, "CABINET_REMNA_SUB_CONFIG", uuid_value)
|
||||
await bot_configuration_service.set_value(db, 'CABINET_REMNA_SUB_CONFIG', uuid_value)
|
||||
await db.commit()
|
||||
logger.info(f"Admin {admin.id} updated CABINET_REMNA_SUB_CONFIG to '{uuid_value}'")
|
||||
logger.info('Admin updated CABINET_REMNA_SUB_CONFIG to', admin_id=admin.id, uuid_value=uuid_value)
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving RemnaWave config UUID: {e}")
|
||||
logger.error('Error saving RemnaWave config UUID', error=e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to save configuration",
|
||||
detail='Failed to save configuration',
|
||||
)
|
||||
|
||||
return RemnaWaveConfigStatus(
|
||||
@@ -494,7 +519,7 @@ async def set_remnawave_config_uuid(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/remnawave/config")
|
||||
@router.get('/remnawave/config')
|
||||
async def get_remnawave_subscription_config(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
):
|
||||
@@ -506,7 +531,7 @@ async def get_remnawave_subscription_config(
|
||||
if not config_uuid:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="CABINET_REMNA_SUB_CONFIG is not configured",
|
||||
detail='CABINET_REMNA_SUB_CONFIG is not configured',
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -521,22 +546,22 @@ async def get_remnawave_subscription_config(
|
||||
|
||||
# Return the raw config data from RemnaWave
|
||||
return {
|
||||
"uuid": config.uuid,
|
||||
"name": config.name,
|
||||
"view_position": config.view_position,
|
||||
"config": config.config,
|
||||
'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}")
|
||||
logger.error('Error fetching RemnaWave config', error=e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to fetch config from RemnaWave: {str(e)}",
|
||||
detail=f'Failed to fetch config from RemnaWave: {e!s}',
|
||||
)
|
||||
|
||||
|
||||
@router.get("/remnawave/configs")
|
||||
@router.get('/remnawave/configs')
|
||||
async def list_remnawave_subscription_configs(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
):
|
||||
@@ -547,15 +572,15 @@ async def list_remnawave_subscription_configs(
|
||||
configs = await api.get_subscription_page_configs()
|
||||
return [
|
||||
{
|
||||
"uuid": c.uuid,
|
||||
"name": c.name,
|
||||
"view_position": c.view_position,
|
||||
'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}")
|
||||
logger.error('Error listing RemnaWave configs', error=e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to fetch configs from RemnaWave: {str(e)}",
|
||||
detail=f'Failed to fetch configs from RemnaWave: {e!s}',
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,21 +1,22 @@
|
||||
"""Admin routes for broadcasts in cabinet."""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy import distinct, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import BroadcastHistory, Tariff, User, Subscription, SubscriptionStatus
|
||||
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 app.handlers.admin.messages import get_target_users_count
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
from ..schemas.broadcasts import (
|
||||
@@ -29,71 +30,98 @@ from ..schemas.broadcasts import (
|
||||
BroadcastPreviewResponse,
|
||||
BroadcastResponse,
|
||||
BroadcastTariffsResponse,
|
||||
CombinedBroadcastCreateRequest,
|
||||
EmailFilterItem,
|
||||
EmailFiltersResponse,
|
||||
EmailPreviewRequest,
|
||||
EmailPreviewResponse,
|
||||
TariffFilter,
|
||||
TariffForBroadcast,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/admin/broadcasts", tags=["Cabinet Admin Broadcasts"])
|
||||
logger = structlog.get_logger(__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": "Триальные с нулевым трафиком",
|
||||
'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",
|
||||
'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_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",
|
||||
'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."""
|
||||
blocked = broadcast.blocked_count or 0
|
||||
progress = 0.0
|
||||
if broadcast.total_count > 0:
|
||||
progress = round((broadcast.sent_count + broadcast.failed_count) / broadcast.total_count * 100, 1)
|
||||
progress = round((broadcast.sent_count + broadcast.failed_count + blocked) / broadcast.total_count * 100, 1)
|
||||
|
||||
return BroadcastResponse(
|
||||
id=broadcast.id,
|
||||
@@ -106,25 +134,87 @@ def _serialize_broadcast(broadcast: BroadcastHistory) -> BroadcastResponse:
|
||||
total_count=broadcast.total_count,
|
||||
sent_count=broadcast.sent_count,
|
||||
failed_count=broadcast.failed_count,
|
||||
blocked_count=blocked,
|
||||
status=broadcast.status,
|
||||
admin_id=broadcast.admin_id,
|
||||
admin_name=broadcast.admin_name,
|
||||
created_at=broadcast.created_at,
|
||||
completed_at=broadcast.completed_at,
|
||||
progress_percent=progress,
|
||||
channel=getattr(broadcast, 'channel', 'telegram') or 'telegram',
|
||||
email_subject=getattr(broadcast, 'email_subject', None),
|
||||
email_html_content=getattr(broadcast, 'email_html_content', None),
|
||||
)
|
||||
|
||||
|
||||
async def _get_email_filter_count(db: AsyncSession, target: str) -> int:
|
||||
"""Get count of email users matching the filter."""
|
||||
base_conditions = [
|
||||
User.email.isnot(None),
|
||||
User.email_verified == True,
|
||||
User.status == 'active',
|
||||
]
|
||||
|
||||
if target == 'all_email':
|
||||
query = select(func.count(User.id)).where(*base_conditions)
|
||||
|
||||
elif target == 'email_only':
|
||||
query = select(func.count(User.id)).where(
|
||||
*base_conditions,
|
||||
User.auth_type == 'email',
|
||||
)
|
||||
|
||||
elif target == 'telegram_with_email':
|
||||
query = select(func.count(User.id)).where(
|
||||
*base_conditions,
|
||||
User.auth_type == 'telegram',
|
||||
User.telegram_id.isnot(None),
|
||||
)
|
||||
|
||||
elif target == 'active_email':
|
||||
query = (
|
||||
select(func.count(distinct(User.id)))
|
||||
.join(Subscription, User.id == Subscription.user_id)
|
||||
.where(
|
||||
*base_conditions,
|
||||
Subscription.status == SubscriptionStatus.ACTIVE.value,
|
||||
)
|
||||
)
|
||||
|
||||
elif target == 'expired_email':
|
||||
query = (
|
||||
select(func.count(distinct(User.id)))
|
||||
.join(Subscription, User.id == Subscription.user_id)
|
||||
.where(
|
||||
*base_conditions,
|
||||
Subscription.status.in_(
|
||||
[
|
||||
SubscriptionStatus.EXPIRED.value,
|
||||
SubscriptionStatus.DISABLED.value,
|
||||
]
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
else:
|
||||
return 0
|
||||
|
||||
result = await db.execute(query)
|
||||
return result.scalar() or 0
|
||||
|
||||
|
||||
def _validate_email_target(target: str) -> bool:
|
||||
"""Validate email target filter."""
|
||||
return target in EMAIL_FILTER_LABELS
|
||||
|
||||
|
||||
async def _get_tariff_user_counts(db: AsyncSession) -> dict:
|
||||
"""Get count of active users per tariff."""
|
||||
result = await db.execute(
|
||||
select(
|
||||
Subscription.tariff_id,
|
||||
func.count(func.distinct(Subscription.user_id)).label("count")
|
||||
)
|
||||
select(Subscription.tariff_id, func.count(func.distinct(Subscription.user_id)).label('count'))
|
||||
.join(User, User.id == Subscription.user_id)
|
||||
.where(
|
||||
User.status == "active",
|
||||
User.status == 'active',
|
||||
Subscription.status == SubscriptionStatus.ACTIVE.value,
|
||||
)
|
||||
.group_by(Subscription.tariff_id)
|
||||
@@ -138,26 +228,24 @@ def _validate_target(target: str, tariff_ids: set) -> bool:
|
||||
return True
|
||||
if target in CUSTOM_FILTER_LABELS:
|
||||
return True
|
||||
if target.startswith("tariff_"):
|
||||
if target.startswith('tariff_'):
|
||||
try:
|
||||
tariff_id = int(target.split("_")[1])
|
||||
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:
|
||||
def _validate_buttons(buttons: list[str]) -> bool:
|
||||
"""Validate button keys."""
|
||||
for button in buttons:
|
||||
if button not in BROADCAST_BUTTONS:
|
||||
return False
|
||||
return True
|
||||
return all(button in BROADCAST_BUTTONS for button in buttons)
|
||||
|
||||
|
||||
# ============ Endpoints ============
|
||||
|
||||
@router.get("/filters", response_model=BroadcastFiltersResponse)
|
||||
|
||||
@router.get('/filters', response_model=BroadcastFiltersResponse)
|
||||
async def get_filters(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
@@ -169,14 +257,16 @@ async def get_filters(
|
||||
try:
|
||||
count = await get_target_users_count(db, key)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to get count for filter {key}: {e}")
|
||||
logger.warning('Failed to get count for filter', key=key, error=e)
|
||||
count = 0
|
||||
filters.append(BroadcastFilter(
|
||||
key=key,
|
||||
label=label,
|
||||
count=count,
|
||||
group=FILTER_GROUPS.get(key),
|
||||
))
|
||||
filters.append(
|
||||
BroadcastFilter(
|
||||
key=key,
|
||||
label=label,
|
||||
count=count,
|
||||
group=FILTER_GROUPS.get(key),
|
||||
)
|
||||
)
|
||||
|
||||
# Custom filters
|
||||
custom_filters = []
|
||||
@@ -184,30 +274,32 @@ async def get_filters(
|
||||
try:
|
||||
count = await get_target_users_count(db, key)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to get count for custom filter {key}: {e}")
|
||||
logger.warning('Failed to get count for custom filter', key=key, error=e)
|
||||
count = 0
|
||||
custom_filters.append(BroadcastFilter(
|
||||
key=key,
|
||||
label=label,
|
||||
count=count,
|
||||
group=CUSTOM_FILTER_GROUPS.get(key),
|
||||
))
|
||||
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)
|
||||
)
|
||||
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),
|
||||
))
|
||||
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,
|
||||
@@ -216,16 +308,14 @@ async def get_filters(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/tariffs", response_model=BroadcastTariffsResponse)
|
||||
@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)
|
||||
)
|
||||
result = await db.execute(select(Tariff).where(Tariff.is_active == True).order_by(Tariff.name))
|
||||
tariffs = result.scalars().all()
|
||||
|
||||
return BroadcastTariffsResponse(
|
||||
@@ -233,7 +323,7 @@ async def get_tariffs(
|
||||
TariffForBroadcast(
|
||||
id=t.id,
|
||||
name=t.name,
|
||||
filter_key=f"tariff_{t.id}",
|
||||
filter_key=f'tariff_{t.id}',
|
||||
active_users_count=tariff_counts.get(t.id, 0),
|
||||
)
|
||||
for t in tariffs
|
||||
@@ -241,7 +331,7 @@ async def get_tariffs(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/buttons", response_model=BroadcastButtonsResponse)
|
||||
@router.get('/buttons', response_model=BroadcastButtonsResponse)
|
||||
async def get_buttons(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
) -> BroadcastButtonsResponse:
|
||||
@@ -249,15 +339,17 @@ async def get_buttons(
|
||||
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,
|
||||
))
|
||||
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)
|
||||
@router.post('/preview', response_model=BroadcastPreviewResponse)
|
||||
async def preview_broadcast(
|
||||
request: BroadcastPreviewRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
@@ -271,22 +363,22 @@ async def preview_broadcast(
|
||||
if not _validate_target(request.target, tariff_ids):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid target: {request.target}",
|
||||
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}")
|
||||
logger.error('Failed to get count for target', target=request.target, error=e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to count recipients",
|
||||
detail='Failed to count recipients',
|
||||
)
|
||||
|
||||
return BroadcastPreviewResponse(target=request.target, count=count)
|
||||
|
||||
|
||||
@router.post("", response_model=BroadcastResponse, status_code=status.HTTP_201_CREATED)
|
||||
@router.post('', response_model=BroadcastResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_broadcast(
|
||||
request: BroadcastCreateRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
@@ -300,21 +392,21 @@ async def create_broadcast(
|
||||
if not _validate_target(request.target, tariff_ids):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid target: {request.target}",
|
||||
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",
|
||||
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",
|
||||
detail='Message text must not be empty',
|
||||
)
|
||||
|
||||
media_payload = request.media
|
||||
@@ -330,9 +422,9 @@ async def create_broadcast(
|
||||
total_count=0,
|
||||
sent_count=0,
|
||||
failed_count=0,
|
||||
status="queued",
|
||||
status='queued',
|
||||
admin_id=admin.id,
|
||||
admin_name=admin.username or f"Admin #{admin.id}",
|
||||
admin_name=admin.username or f'Admin #{admin.id}',
|
||||
)
|
||||
db.add(broadcast)
|
||||
await db.commit()
|
||||
@@ -353,19 +445,21 @@ async def create_broadcast(
|
||||
message_text=message_text,
|
||||
selected_buttons=request.selected_buttons,
|
||||
media=media_config,
|
||||
initiator_name=admin.username or f"Admin #{admin.id}",
|
||||
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}'")
|
||||
logger.info(
|
||||
'Admin created broadcast for target', admin_id=admin.id, broadcast_id=broadcast.id, target=request.target
|
||||
)
|
||||
|
||||
return _serialize_broadcast(broadcast)
|
||||
|
||||
|
||||
@router.get("", response_model=BroadcastListResponse)
|
||||
@router.get('', response_model=BroadcastListResponse)
|
||||
async def list_broadcasts(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
@@ -376,10 +470,7 @@ async def list_broadcasts(
|
||||
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)
|
||||
select(BroadcastHistory).order_by(BroadcastHistory.created_at.desc()).offset(offset).limit(limit)
|
||||
)
|
||||
broadcasts = result.scalars().all()
|
||||
|
||||
@@ -391,7 +482,201 @@ async def list_broadcasts(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{broadcast_id}", response_model=BroadcastResponse)
|
||||
# ============ Email Broadcast Endpoints ============
|
||||
|
||||
|
||||
@router.get('/email-filters', response_model=EmailFiltersResponse)
|
||||
async def get_email_filters(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> EmailFiltersResponse:
|
||||
"""Get all available email filters with user counts."""
|
||||
filters = []
|
||||
total_with_email = 0
|
||||
|
||||
for key, label in EMAIL_FILTER_LABELS.items():
|
||||
try:
|
||||
count = await _get_email_filter_count(db, key)
|
||||
except Exception as e:
|
||||
logger.warning('Failed to get count for email filter', key=key, error=e)
|
||||
count = 0
|
||||
|
||||
filters.append(
|
||||
EmailFilterItem(
|
||||
key=key,
|
||||
label=label,
|
||||
count=count,
|
||||
group=EMAIL_FILTER_GROUPS.get(key),
|
||||
)
|
||||
)
|
||||
|
||||
# Track total with email (all_email filter)
|
||||
if key == 'all_email':
|
||||
total_with_email = count
|
||||
|
||||
return EmailFiltersResponse(
|
||||
filters=filters,
|
||||
total_with_email=total_with_email,
|
||||
)
|
||||
|
||||
|
||||
@router.post('/email-preview', response_model=EmailPreviewResponse)
|
||||
async def preview_email_broadcast(
|
||||
request: EmailPreviewRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> EmailPreviewResponse:
|
||||
"""Preview email broadcast recipients count."""
|
||||
if not _validate_email_target(request.target):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Invalid email target: {request.target}',
|
||||
)
|
||||
|
||||
try:
|
||||
count = await _get_email_filter_count(db, request.target)
|
||||
except Exception as e:
|
||||
logger.error('Failed to get email count for target', target=request.target, error=e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to count email recipients',
|
||||
)
|
||||
|
||||
return EmailPreviewResponse(target=request.target, count=count)
|
||||
|
||||
|
||||
@router.post('/send', response_model=BroadcastResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_combined_broadcast(
|
||||
request: CombinedBroadcastCreateRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> BroadcastResponse:
|
||||
"""Create and start a combined broadcast (telegram/email/both)."""
|
||||
# Get tariff IDs for target validation
|
||||
result = await db.execute(select(Tariff.id))
|
||||
tariff_ids = {row[0] for row in result.all()}
|
||||
|
||||
admin_name = admin.username or f'Admin #{admin.id}'
|
||||
|
||||
# Validate based on channel
|
||||
if request.channel in ('telegram', 'both'):
|
||||
# Validate telegram target
|
||||
if not _validate_target(request.target, tariff_ids):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Invalid target: {request.target}',
|
||||
)
|
||||
|
||||
# Validate telegram message
|
||||
if not request.message_text or not request.message_text.strip():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Message text is required for Telegram broadcast',
|
||||
)
|
||||
|
||||
# Validate buttons
|
||||
if not _validate_buttons(request.selected_buttons):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Invalid button key',
|
||||
)
|
||||
|
||||
if request.channel in ('email', 'both'):
|
||||
# For email channel, target must be email filter or we use telegram target for 'both'
|
||||
if request.channel == 'email' and not _validate_email_target(request.target):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Invalid email target: {request.target}',
|
||||
)
|
||||
|
||||
# Validate email fields
|
||||
if not request.email_subject or not request.email_subject.strip():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Email subject is required for email broadcast',
|
||||
)
|
||||
|
||||
if not request.email_html_content or not request.email_html_content.strip():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Email HTML content is required for email broadcast',
|
||||
)
|
||||
|
||||
media_payload = request.media
|
||||
|
||||
# Create broadcast record
|
||||
broadcast = BroadcastHistory(
|
||||
target_type=request.target,
|
||||
message_text=request.message_text.strip() if request.message_text else None,
|
||||
has_media=media_payload is not None,
|
||||
media_type=media_payload.type if media_payload else None,
|
||||
media_file_id=media_payload.file_id if media_payload else None,
|
||||
media_caption=media_payload.caption if media_payload else None,
|
||||
total_count=0,
|
||||
sent_count=0,
|
||||
failed_count=0,
|
||||
status='queued',
|
||||
admin_id=admin.id,
|
||||
admin_name=admin_name,
|
||||
channel=request.channel,
|
||||
email_subject=request.email_subject.strip() if request.email_subject else None,
|
||||
email_html_content=request.email_html_content.strip() if request.email_html_content else None,
|
||||
)
|
||||
db.add(broadcast)
|
||||
await db.commit()
|
||||
await db.refresh(broadcast)
|
||||
|
||||
# Start broadcasts based on channel
|
||||
if request.channel in ('telegram', 'both'):
|
||||
# Prepare media config
|
||||
media_config = None
|
||||
if media_payload:
|
||||
media_config = BroadcastMediaConfig(
|
||||
type=media_payload.type,
|
||||
file_id=media_payload.file_id,
|
||||
caption=media_payload.caption or request.message_text,
|
||||
)
|
||||
|
||||
# Create telegram broadcast config
|
||||
telegram_config = BroadcastConfig(
|
||||
target=request.target,
|
||||
message_text=request.message_text.strip(),
|
||||
selected_buttons=request.selected_buttons,
|
||||
media=media_config,
|
||||
initiator_name=admin_name,
|
||||
)
|
||||
|
||||
await broadcast_service.start_broadcast(broadcast.id, telegram_config)
|
||||
|
||||
if request.channel in ('email', 'both'):
|
||||
# For 'both' channel, we use 'all_email' as default email target
|
||||
# since telegram target won't match email filters
|
||||
email_target = request.target if request.channel == 'email' else 'all_email'
|
||||
|
||||
# Create email broadcast config
|
||||
email_config = EmailBroadcastConfig(
|
||||
target=email_target,
|
||||
email_subject=request.email_subject.strip(),
|
||||
email_html_content=request.email_html_content.strip(),
|
||||
initiator_name=admin_name,
|
||||
)
|
||||
|
||||
await email_broadcast_service.start_broadcast(broadcast.id, email_config)
|
||||
|
||||
await db.refresh(broadcast)
|
||||
|
||||
logger.info(
|
||||
'Admin created broadcast for target',
|
||||
admin_id=admin.id,
|
||||
channel=request.channel,
|
||||
broadcast_id=broadcast.id,
|
||||
target=request.target,
|
||||
)
|
||||
|
||||
return _serialize_broadcast(broadcast)
|
||||
|
||||
|
||||
@router.get('/{broadcast_id}', response_model=BroadcastResponse)
|
||||
async def get_broadcast(
|
||||
broadcast_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
@@ -402,42 +687,50 @@ async def get_broadcast(
|
||||
if not broadcast:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Broadcast not found",
|
||||
detail='Broadcast not found',
|
||||
)
|
||||
return _serialize_broadcast(broadcast)
|
||||
|
||||
|
||||
@router.post("/{broadcast_id}/stop", response_model=BroadcastResponse)
|
||||
@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."""
|
||||
"""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",
|
||||
detail='Broadcast not found',
|
||||
)
|
||||
|
||||
if broadcast.status not in {"queued", "in_progress", "cancelling"}:
|
||||
if broadcast.status not in {'queued', 'in_progress', 'cancelling'}:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Broadcast is not running",
|
||||
detail='Broadcast is not running',
|
||||
)
|
||||
|
||||
is_running = await broadcast_service.request_stop(broadcast_id)
|
||||
# Try to stop both telegram and email broadcasts (one or both may be running)
|
||||
channel = getattr(broadcast, 'channel', 'telegram') or 'telegram'
|
||||
|
||||
is_running = False
|
||||
if channel in ('telegram', 'both'):
|
||||
is_running = await broadcast_service.request_stop(broadcast_id) or is_running
|
||||
|
||||
if channel in ('email', 'both'):
|
||||
is_running = await email_broadcast_service.request_stop(broadcast_id) or is_running
|
||||
|
||||
if is_running:
|
||||
broadcast.status = "cancelling"
|
||||
broadcast.status = 'cancelling'
|
||||
else:
|
||||
broadcast.status = "cancelled"
|
||||
broadcast.completed_at = datetime.utcnow()
|
||||
broadcast.status = 'cancelled'
|
||||
broadcast.completed_at = datetime.now(UTC)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(broadcast)
|
||||
|
||||
logger.info(f"Admin {admin.id} stopped broadcast {broadcast_id}")
|
||||
logger.info('Admin stopped broadcast', admin_id=admin.id, broadcast_id=broadcast_id)
|
||||
|
||||
return _serialize_broadcast(broadcast)
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
"""Admin routes for per-section cabinet button style configuration."""
|
||||
|
||||
import json
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import User
|
||||
from app.utils.button_styles_cache import (
|
||||
ALLOWED_STYLE_VALUES,
|
||||
BOT_LOCALES,
|
||||
BUTTON_STYLES_KEY,
|
||||
DEFAULT_BUTTON_STYLES,
|
||||
SECTIONS,
|
||||
load_button_styles_cache,
|
||||
)
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/button-styles', tags=['Admin Button Styles'])
|
||||
|
||||
|
||||
# ---- Schemas ---------------------------------------------------------------
|
||||
|
||||
|
||||
class ButtonSectionConfig(BaseModel):
|
||||
"""Configuration for a single button section."""
|
||||
|
||||
style: str = 'primary'
|
||||
icon_custom_emoji_id: str = ''
|
||||
enabled: bool = True
|
||||
labels: dict[str, str] = {}
|
||||
|
||||
|
||||
class ButtonStylesResponse(BaseModel):
|
||||
"""Full button styles configuration (all 7 sections)."""
|
||||
|
||||
home: ButtonSectionConfig = ButtonSectionConfig()
|
||||
subscription: ButtonSectionConfig = ButtonSectionConfig()
|
||||
balance: ButtonSectionConfig = ButtonSectionConfig()
|
||||
referral: ButtonSectionConfig = ButtonSectionConfig()
|
||||
support: ButtonSectionConfig = ButtonSectionConfig()
|
||||
info: ButtonSectionConfig = ButtonSectionConfig()
|
||||
admin: ButtonSectionConfig = ButtonSectionConfig()
|
||||
|
||||
|
||||
MAX_LABEL_LENGTH = 100
|
||||
|
||||
|
||||
class ButtonSectionUpdate(BaseModel):
|
||||
"""Partial update for a single section (None = keep current)."""
|
||||
|
||||
style: str | None = None
|
||||
icon_custom_emoji_id: str | None = None
|
||||
enabled: bool | None = None
|
||||
labels: dict[str, str] | None = None
|
||||
|
||||
|
||||
class ButtonStylesUpdate(BaseModel):
|
||||
"""Partial update — only include sections you want to change."""
|
||||
|
||||
home: ButtonSectionUpdate | None = None
|
||||
subscription: ButtonSectionUpdate | None = None
|
||||
balance: ButtonSectionUpdate | None = None
|
||||
referral: ButtonSectionUpdate | None = None
|
||||
support: ButtonSectionUpdate | None = None
|
||||
info: ButtonSectionUpdate | None = None
|
||||
admin: ButtonSectionUpdate | None = None
|
||||
|
||||
|
||||
# ---- Helpers ---------------------------------------------------------------
|
||||
|
||||
|
||||
async def _get_setting_value(db: AsyncSession, key: str) -> str | None:
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.database.models import SystemSetting
|
||||
|
||||
result = await db.execute(select(SystemSetting).where(SystemSetting.key == key))
|
||||
setting = result.scalar_one_or_none()
|
||||
return setting.value if setting else None
|
||||
|
||||
|
||||
async def _set_setting_value(db: AsyncSession, key: str, value: str) -> None:
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.database.models import SystemSetting
|
||||
|
||||
result = await db.execute(select(SystemSetting).where(SystemSetting.key == key))
|
||||
setting = result.scalar_one_or_none()
|
||||
if setting:
|
||||
setting.value = value
|
||||
else:
|
||||
setting = SystemSetting(key=key, value=value)
|
||||
db.add(setting)
|
||||
await db.commit()
|
||||
|
||||
|
||||
def _build_response(styles: dict[str, dict]) -> ButtonStylesResponse:
|
||||
return ButtonStylesResponse(
|
||||
**{section: ButtonSectionConfig(**cfg) for section, cfg in styles.items() if section in SECTIONS},
|
||||
)
|
||||
|
||||
|
||||
# ---- Routes ----------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get('', response_model=ButtonStylesResponse)
|
||||
async def get_button_styles(
|
||||
_admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Return current per-section button styles. Admin only."""
|
||||
raw = await _get_setting_value(db, BUTTON_STYLES_KEY)
|
||||
merged = {section: {**cfg, 'labels': dict(cfg.get('labels', {}))} for section, cfg in DEFAULT_BUTTON_STYLES.items()}
|
||||
|
||||
if raw:
|
||||
try:
|
||||
db_data = json.loads(raw)
|
||||
for section, overrides in db_data.items():
|
||||
if section in merged and isinstance(overrides, dict):
|
||||
if overrides.get('style') in ALLOWED_STYLE_VALUES:
|
||||
merged[section]['style'] = overrides['style']
|
||||
if isinstance(overrides.get('icon_custom_emoji_id'), str):
|
||||
merged[section]['icon_custom_emoji_id'] = overrides['icon_custom_emoji_id']
|
||||
if isinstance(overrides.get('enabled'), bool):
|
||||
merged[section]['enabled'] = overrides['enabled']
|
||||
if isinstance(overrides.get('labels'), dict):
|
||||
merged[section]['labels'] = {
|
||||
k: v
|
||||
for k, v in overrides['labels'].items()
|
||||
if isinstance(k, str) and isinstance(v, str) and k in BOT_LOCALES
|
||||
}
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
return _build_response(merged)
|
||||
|
||||
|
||||
@router.patch('', response_model=ButtonStylesResponse)
|
||||
async def update_button_styles(
|
||||
payload: ButtonStylesUpdate,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Partially update per-section button styles. Admin only."""
|
||||
# Load current state
|
||||
raw = await _get_setting_value(db, BUTTON_STYLES_KEY)
|
||||
current: dict[str, dict] = {
|
||||
section: {**cfg, 'labels': dict(cfg.get('labels', {}))} for section, cfg in DEFAULT_BUTTON_STYLES.items()
|
||||
}
|
||||
|
||||
if raw:
|
||||
try:
|
||||
db_data = json.loads(raw)
|
||||
for section, overrides in db_data.items():
|
||||
if section in current and isinstance(overrides, dict):
|
||||
if overrides.get('style') in ALLOWED_STYLE_VALUES:
|
||||
current[section]['style'] = overrides['style']
|
||||
if isinstance(overrides.get('icon_custom_emoji_id'), str):
|
||||
current[section]['icon_custom_emoji_id'] = overrides['icon_custom_emoji_id']
|
||||
if isinstance(overrides.get('enabled'), bool):
|
||||
current[section]['enabled'] = overrides['enabled']
|
||||
if isinstance(overrides.get('labels'), dict):
|
||||
current[section]['labels'] = {
|
||||
k: v
|
||||
for k, v in overrides['labels'].items()
|
||||
if isinstance(k, str) and isinstance(v, str) and k in BOT_LOCALES
|
||||
}
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
# Apply updates
|
||||
update_data = payload.model_dump(exclude_none=True)
|
||||
changed_sections: list[str] = []
|
||||
|
||||
for section, updates in update_data.items():
|
||||
if section not in current or not isinstance(updates, dict):
|
||||
continue
|
||||
|
||||
if 'style' in updates:
|
||||
style_val = updates['style']
|
||||
if style_val not in ALLOWED_STYLE_VALUES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Invalid style "{style_val}" for section "{section}". '
|
||||
f'Allowed: {", ".join(sorted(ALLOWED_STYLE_VALUES))}',
|
||||
)
|
||||
current[section]['style'] = style_val
|
||||
|
||||
if 'icon_custom_emoji_id' in updates:
|
||||
emoji_val = (updates['icon_custom_emoji_id'] or '').strip()
|
||||
current[section]['icon_custom_emoji_id'] = emoji_val
|
||||
|
||||
if 'enabled' in updates:
|
||||
current[section]['enabled'] = updates['enabled']
|
||||
|
||||
if 'labels' in updates:
|
||||
raw_labels = updates['labels'] or {}
|
||||
sanitized: dict[str, str] = {}
|
||||
for locale_key, label_val in raw_labels.items():
|
||||
if locale_key not in BOT_LOCALES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Invalid locale "{locale_key}" for section "{section}". '
|
||||
f'Allowed: {", ".join(BOT_LOCALES)}',
|
||||
)
|
||||
if not isinstance(label_val, str):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Label value for locale "{locale_key}" must be a string.',
|
||||
)
|
||||
stripped = label_val.strip()
|
||||
if len(stripped) > MAX_LABEL_LENGTH:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Label for locale "{locale_key}" exceeds {MAX_LABEL_LENGTH} characters.',
|
||||
)
|
||||
# Empty string = remove custom label (use default)
|
||||
if stripped:
|
||||
sanitized[locale_key] = stripped
|
||||
current[section]['labels'] = sanitized
|
||||
|
||||
changed_sections.append(section)
|
||||
|
||||
# Persist
|
||||
await _set_setting_value(db, BUTTON_STYLES_KEY, json.dumps(current))
|
||||
|
||||
# Refresh in-process cache
|
||||
await load_button_styles_cache()
|
||||
|
||||
logger.info(
|
||||
'Admin updated button styles for sections', telegram_id=admin.telegram_id, changed_sections=changed_sections
|
||||
)
|
||||
|
||||
return _build_response(current)
|
||||
|
||||
|
||||
@router.post('/reset', response_model=ButtonStylesResponse)
|
||||
async def reset_button_styles(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Reset all button styles to defaults. Admin only."""
|
||||
await _set_setting_value(db, BUTTON_STYLES_KEY, json.dumps(DEFAULT_BUTTON_STYLES))
|
||||
await load_button_styles_cache()
|
||||
|
||||
logger.info('Admin reset button styles to defaults', telegram_id=admin.telegram_id)
|
||||
|
||||
return _build_response(DEFAULT_BUTTON_STYLES)
|
||||
@@ -1,20 +1,13 @@
|
||||
"""Admin routes for managing advertising campaigns in cabinet."""
|
||||
|
||||
import logging
|
||||
from typing import List, Optional
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import (
|
||||
User,
|
||||
AdvertisingCampaign,
|
||||
AdvertisingCampaignRegistration,
|
||||
Subscription,
|
||||
Tariff,
|
||||
)
|
||||
from app.database.crud.campaign import (
|
||||
create_campaign,
|
||||
delete_campaign,
|
||||
@@ -28,38 +21,64 @@ from app.database.crud.campaign import (
|
||||
)
|
||||
from app.database.crud.server_squad import get_all_server_squads
|
||||
from app.database.crud.tariff import get_all_tariffs
|
||||
from app.database.models import (
|
||||
AdvertisingCampaign,
|
||||
AdvertisingCampaignRegistration,
|
||||
PartnerStatus,
|
||||
Subscription,
|
||||
Tariff,
|
||||
User,
|
||||
)
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
from ..schemas.campaigns import (
|
||||
CampaignListResponse,
|
||||
CampaignListItem,
|
||||
CampaignDetailResponse,
|
||||
AvailablePartnerItem,
|
||||
CampaignCreateRequest,
|
||||
CampaignUpdateRequest,
|
||||
CampaignToggleResponse,
|
||||
CampaignStatisticsResponse,
|
||||
CampaignDetailResponse,
|
||||
CampaignListItem,
|
||||
CampaignListResponse,
|
||||
CampaignRegistrationItem,
|
||||
CampaignRegistrationsResponse,
|
||||
CampaignsOverviewResponse,
|
||||
TariffInfo,
|
||||
CampaignStatisticsResponse,
|
||||
CampaignToggleResponse,
|
||||
CampaignUpdateRequest,
|
||||
ServerSquadInfo,
|
||||
TariffInfo,
|
||||
)
|
||||
from ..schemas.tariffs import TariffListItem
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/admin/campaigns", tags=["Cabinet Admin Campaigns"])
|
||||
logger = structlog.get_logger(__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}"
|
||||
return f'https://t.me/{bot_username}?start={start_parameter}'
|
||||
return f'?start={start_parameter}'
|
||||
|
||||
|
||||
@router.get("/overview", response_model=CampaignsOverviewResponse)
|
||||
def _get_web_link(start_parameter: str) -> str | None:
|
||||
"""Generate web link for campaign."""
|
||||
base_url = (settings.MINIAPP_CUSTOM_URL or '').rstrip('/')
|
||||
if base_url:
|
||||
return f'{base_url}/?campaign={start_parameter}'
|
||||
return None
|
||||
|
||||
|
||||
def _get_partner_name(campaign: AdvertisingCampaign) -> str | None:
|
||||
"""Get partner display name from campaign."""
|
||||
if not campaign.partner_user_id or not campaign.partner:
|
||||
return None
|
||||
partner = campaign.partner
|
||||
return partner.first_name or partner.username or f'#{partner.id}'
|
||||
|
||||
|
||||
@router.get('/overview', response_model=CampaignsOverviewResponse)
|
||||
async def get_overview(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
@@ -70,24 +89,24 @@ async def get_overview(
|
||||
# Count tariff bonuses
|
||||
tariff_result = await db.execute(
|
||||
select(func.count(AdvertisingCampaignRegistration.id)).where(
|
||||
AdvertisingCampaignRegistration.bonus_type == "tariff"
|
||||
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=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])
|
||||
@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),
|
||||
@@ -105,7 +124,7 @@ async def get_available_servers(
|
||||
]
|
||||
|
||||
|
||||
@router.get("/available-tariffs", response_model=List[TariffListItem])
|
||||
@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),
|
||||
@@ -134,7 +153,27 @@ async def get_available_tariffs(
|
||||
]
|
||||
|
||||
|
||||
@router.get("", response_model=CampaignListResponse)
|
||||
@router.get('/available-partners', response_model=list[AvailablePartnerItem])
|
||||
async def get_available_partners(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get list of approved partners for campaign partner selector."""
|
||||
result = await db.execute(
|
||||
select(User).where(User.partner_status == PartnerStatus.APPROVED.value).order_by(User.first_name, User.username)
|
||||
)
|
||||
partners = result.scalars().all()
|
||||
return [
|
||||
AvailablePartnerItem(
|
||||
user_id=p.id,
|
||||
username=p.username,
|
||||
first_name=p.first_name,
|
||||
)
|
||||
for p in partners
|
||||
]
|
||||
|
||||
|
||||
@router.get('', response_model=CampaignListResponse)
|
||||
async def list_campaigns(
|
||||
include_inactive: bool = True,
|
||||
offset: int = Query(0, ge=0),
|
||||
@@ -143,31 +182,33 @@ async def list_campaigns(
|
||||
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
|
||||
)
|
||||
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,
|
||||
))
|
||||
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'],
|
||||
partner_user_id=campaign.partner_user_id,
|
||||
partner_name=_get_partner_name(campaign),
|
||||
created_at=campaign.created_at,
|
||||
)
|
||||
)
|
||||
|
||||
return CampaignListResponse(campaigns=items, total=total)
|
||||
|
||||
|
||||
@router.get("/{campaign_id}", response_model=CampaignDetailResponse)
|
||||
@router.get('/{campaign_id}', response_model=CampaignDetailResponse)
|
||||
async def get_campaign(
|
||||
campaign_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
@@ -178,7 +219,7 @@ async def get_campaign(
|
||||
if not campaign:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Campaign not found",
|
||||
detail='Campaign not found',
|
||||
)
|
||||
|
||||
tariff_info = None
|
||||
@@ -203,14 +244,17 @@ async def get_campaign(
|
||||
tariff_id=campaign.tariff_id,
|
||||
tariff_duration_days=campaign.tariff_duration_days,
|
||||
tariff=tariff_info,
|
||||
partner_user_id=campaign.partner_user_id,
|
||||
partner_name=_get_partner_name(campaign),
|
||||
created_by=campaign.created_by,
|
||||
created_at=campaign.created_at,
|
||||
updated_at=campaign.updated_at,
|
||||
deep_link=_get_deep_link(campaign.start_parameter),
|
||||
web_link=_get_web_link(campaign.start_parameter),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{campaign_id}/stats", response_model=CampaignStatisticsResponse)
|
||||
@router.get('/{campaign_id}/stats', response_model=CampaignStatisticsResponse)
|
||||
async def get_campaign_stats(
|
||||
campaign_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
@@ -221,7 +265,7 @@ async def get_campaign_stats(
|
||||
if not campaign:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Campaign not found",
|
||||
detail='Campaign not found',
|
||||
)
|
||||
|
||||
stats = await get_campaign_statistics(db, campaign_id)
|
||||
@@ -232,28 +276,29 @@ async def get_campaign_stats(
|
||||
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"],
|
||||
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),
|
||||
web_link=_get_web_link(campaign.start_parameter),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{campaign_id}/registrations", response_model=CampaignRegistrationsResponse)
|
||||
@router.get('/{campaign_id}/registrations', response_model=CampaignRegistrationsResponse)
|
||||
async def get_campaign_registrations(
|
||||
campaign_id: int,
|
||||
page: int = Query(1, ge=1),
|
||||
@@ -266,7 +311,7 @@ async def get_campaign_registrations(
|
||||
if not campaign:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Campaign not found",
|
||||
detail='Campaign not found',
|
||||
)
|
||||
|
||||
offset = (page - 1) * per_page
|
||||
@@ -284,40 +329,46 @@ async def get_campaign_registrations(
|
||||
|
||||
# Count total
|
||||
count_result = await db.execute(
|
||||
select(func.count(AdvertisingCampaignRegistration.id))
|
||||
.where(AdvertisingCampaignRegistration.campaign_id == campaign_id)
|
||||
select(func.count(AdvertisingCampaignRegistration.id)).where(
|
||||
AdvertisingCampaignRegistration.campaign_id == campaign_id
|
||||
)
|
||||
)
|
||||
total = count_result.scalar() or 0
|
||||
|
||||
# Batch query: find which users have active subscriptions (avoids N+1)
|
||||
user_ids = [user.id for _reg, user in rows]
|
||||
active_sub_user_ids: set[int] = set()
|
||||
if user_ids:
|
||||
sub_result = await db.execute(
|
||||
select(Subscription.user_id)
|
||||
.where(
|
||||
Subscription.user_id.in_(user_ids),
|
||||
Subscription.status == 'active',
|
||||
)
|
||||
.distinct()
|
||||
)
|
||||
active_sub_user_ids = set(sub_result.scalars().all())
|
||||
|
||||
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",
|
||||
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=user.id in active_sub_user_ids,
|
||||
has_paid=user.has_had_paid_subscription or False,
|
||||
)
|
||||
.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,
|
||||
@@ -327,7 +378,7 @@ async def get_campaign_registrations(
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=CampaignDetailResponse)
|
||||
@router.post('', response_model=CampaignDetailResponse)
|
||||
async def create_new_campaign(
|
||||
request: CampaignCreateRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
@@ -343,20 +394,27 @@ async def create_new_campaign(
|
||||
)
|
||||
|
||||
# Validate tariff exists if tariff bonus type
|
||||
if request.bonus_type == "tariff":
|
||||
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",
|
||||
detail='Tariff ID is required for tariff bonus type',
|
||||
)
|
||||
tariff_result = await db.execute(
|
||||
select(Tariff).where(Tariff.id == request.tariff_id)
|
||||
)
|
||||
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",
|
||||
detail='Tariff not found',
|
||||
)
|
||||
|
||||
# Validate partner exists and is approved
|
||||
if request.partner_user_id is not None:
|
||||
partner_user = await db.get(User, request.partner_user_id)
|
||||
if not partner_user or partner_user.partner_status != 'approved':
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Partner not found or not approved',
|
||||
)
|
||||
|
||||
campaign = await create_campaign(
|
||||
@@ -373,17 +431,18 @@ async def create_new_campaign(
|
||||
tariff_id=request.tariff_id,
|
||||
tariff_duration_days=request.tariff_duration_days,
|
||||
is_active=request.is_active,
|
||||
partner_user_id=request.partner_user_id,
|
||||
)
|
||||
|
||||
# Reload to get tariff relationship
|
||||
campaign = await get_campaign_by_id(db, campaign.id)
|
||||
|
||||
logger.info(f"Admin {admin.id} created campaign {campaign.id}: {campaign.name}")
|
||||
logger.info('Admin created campaign', admin_id=admin.id, campaign_id=campaign.id, campaign_name=campaign.name)
|
||||
|
||||
return await get_campaign(campaign.id, admin, db)
|
||||
|
||||
|
||||
@router.put("/{campaign_id}", response_model=CampaignDetailResponse)
|
||||
@router.put('/{campaign_id}', response_model=CampaignDetailResponse)
|
||||
async def update_existing_campaign(
|
||||
campaign_id: int,
|
||||
request: CampaignUpdateRequest,
|
||||
@@ -395,7 +454,7 @@ async def update_existing_campaign(
|
||||
if not campaign:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Campaign not found",
|
||||
detail='Campaign not found',
|
||||
)
|
||||
|
||||
# Check if start_parameter is unique (if changing)
|
||||
@@ -408,53 +467,69 @@ async def update_existing_campaign(
|
||||
)
|
||||
|
||||
# Validate tariff if changing to tariff bonus type
|
||||
if request.bonus_type == "tariff" or (campaign.bonus_type == "tariff" and request.tariff_id):
|
||||
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_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",
|
||||
detail='Tariff not found',
|
||||
)
|
||||
|
||||
# Build updates
|
||||
updates = {}
|
||||
if request.name is not None:
|
||||
updates["name"] = request.name
|
||||
updates['name'] = request.name
|
||||
if request.start_parameter is not None:
|
||||
updates["start_parameter"] = request.start_parameter
|
||||
updates['start_parameter'] = request.start_parameter
|
||||
if request.bonus_type is not None:
|
||||
updates["bonus_type"] = request.bonus_type
|
||||
updates['bonus_type'] = request.bonus_type
|
||||
if request.is_active is not None:
|
||||
updates["is_active"] = request.is_active
|
||||
updates['is_active'] = request.is_active
|
||||
if request.balance_bonus_kopeks is not None:
|
||||
updates["balance_bonus_kopeks"] = request.balance_bonus_kopeks
|
||||
updates['balance_bonus_kopeks'] = request.balance_bonus_kopeks
|
||||
if request.subscription_duration_days is not None:
|
||||
updates["subscription_duration_days"] = request.subscription_duration_days
|
||||
updates['subscription_duration_days'] = request.subscription_duration_days
|
||||
if request.subscription_traffic_gb is not None:
|
||||
updates["subscription_traffic_gb"] = request.subscription_traffic_gb
|
||||
updates['subscription_traffic_gb'] = request.subscription_traffic_gb
|
||||
if request.subscription_device_limit is not None:
|
||||
updates["subscription_device_limit"] = request.subscription_device_limit
|
||||
updates['subscription_device_limit'] = request.subscription_device_limit
|
||||
if request.subscription_squads is not None:
|
||||
updates["subscription_squads"] = request.subscription_squads
|
||||
updates['subscription_squads'] = request.subscription_squads
|
||||
if request.tariff_id is not None:
|
||||
updates["tariff_id"] = request.tariff_id
|
||||
updates['tariff_id'] = request.tariff_id
|
||||
if request.tariff_duration_days is not None:
|
||||
updates["tariff_duration_days"] = request.tariff_duration_days
|
||||
updates['tariff_duration_days'] = request.tariff_duration_days
|
||||
|
||||
# Handle partner_user_id separately (allows explicit None to unassign)
|
||||
partner_changed = False
|
||||
if 'partner_user_id' in request.model_fields_set:
|
||||
new_partner_id = request.partner_user_id
|
||||
if new_partner_id is not None:
|
||||
partner_user = await db.get(User, new_partner_id)
|
||||
if not partner_user or partner_user.partner_status != 'approved':
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Partner not found or not approved',
|
||||
)
|
||||
campaign.partner_user_id = new_partner_id
|
||||
campaign.updated_at = datetime.now(UTC)
|
||||
partner_changed = True
|
||||
|
||||
if updates:
|
||||
await update_campaign(db, campaign, **updates)
|
||||
elif partner_changed:
|
||||
await db.commit()
|
||||
await db.refresh(campaign)
|
||||
|
||||
logger.info(f"Admin {admin.id} updated campaign {campaign_id}")
|
||||
logger.info('Admin updated campaign', admin_id=admin.id, campaign_id=campaign_id)
|
||||
|
||||
return await get_campaign(campaign_id, admin, db)
|
||||
|
||||
|
||||
@router.delete("/{campaign_id}")
|
||||
@router.delete('/{campaign_id}')
|
||||
async def delete_existing_campaign(
|
||||
campaign_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
@@ -465,7 +540,7 @@ async def delete_existing_campaign(
|
||||
if not campaign:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Campaign not found",
|
||||
detail='Campaign not found',
|
||||
)
|
||||
|
||||
# Check if campaign has registrations
|
||||
@@ -473,16 +548,16 @@ async def delete_existing_campaign(
|
||||
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.",
|
||||
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}")
|
||||
logger.info('Admin deleted campaign', admin_id=admin.id, campaign_id=campaign_id, campaign_name=campaign.name)
|
||||
|
||||
return {"message": "Campaign deleted successfully"}
|
||||
return {'message': 'Campaign deleted successfully'}
|
||||
|
||||
|
||||
@router.post("/{campaign_id}/toggle", response_model=CampaignToggleResponse)
|
||||
@router.post('/{campaign_id}/toggle', response_model=CampaignToggleResponse)
|
||||
async def toggle_campaign(
|
||||
campaign_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
@@ -493,17 +568,17 @@ async def toggle_campaign(
|
||||
if not campaign:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Campaign 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}")
|
||||
status_text = 'activated' if new_status else 'deactivated'
|
||||
logger.info('Admin campaign', admin_id=admin.id, status_text=status_text, campaign_id=campaign_id)
|
||||
|
||||
return CampaignToggleResponse(
|
||||
id=campaign_id,
|
||||
is_active=new_status,
|
||||
message=f"Campaign {status_text}",
|
||||
message=f'Campaign {status_text}',
|
||||
)
|
||||
|
||||
@@ -0,0 +1,677 @@
|
||||
"""Admin routes for managing email notification templates."""
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
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 = structlog.get_logger(__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(
|
||||
'Админ обновил email шаблон /', admin_id=admin.id, notification_type=notification_type, language=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(
|
||||
'Админ сбросил email шаблон / к дефолту',
|
||||
admin_id=admin.id,
|
||||
notification_type=notification_type,
|
||||
language=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', e=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(
|
||||
'Админ отправил тестовый email / на',
|
||||
admin_id=admin.id,
|
||||
notification_type=notification_type,
|
||||
language=language,
|
||||
to_email=to_email,
|
||||
)
|
||||
|
||||
return {'status': 'ok', 'sent_to': to_email}
|
||||
@@ -0,0 +1,582 @@
|
||||
"""Admin routes for managing partners in cabinet."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Literal
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import desc, func, or_, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import (
|
||||
AdvertisingCampaign,
|
||||
PartnerApplication,
|
||||
PartnerStatus,
|
||||
ReferralEarning,
|
||||
User,
|
||||
)
|
||||
from app.services.partner_application_service import partner_application_service
|
||||
from app.services.partner_stats_service import PartnerStatsService
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
from ..schemas.partners import (
|
||||
AdminApproveRequest,
|
||||
AdminPartnerApplicationItem,
|
||||
AdminPartnerApplicationsResponse,
|
||||
AdminPartnerDetailResponse,
|
||||
AdminPartnerItem,
|
||||
AdminPartnerListResponse,
|
||||
AdminRejectRequest,
|
||||
AdminUpdateCommissionRequest,
|
||||
CampaignSummary,
|
||||
)
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/partners', tags=['Cabinet Admin Partners'])
|
||||
|
||||
|
||||
# ==================== Settings ====================
|
||||
|
||||
|
||||
class PartnerSettingsResponse(BaseModel):
|
||||
withdrawal_enabled: bool
|
||||
withdrawal_min_amount_kopeks: int
|
||||
withdrawal_cooldown_days: int
|
||||
withdrawal_requisites_text: str
|
||||
partner_section_visible: bool
|
||||
referral_program_enabled: bool
|
||||
|
||||
|
||||
class PartnerSettingsUpdateRequest(BaseModel):
|
||||
withdrawal_enabled: bool | None = None
|
||||
withdrawal_min_amount_kopeks: int | None = Field(None, ge=0, le=100_000_000)
|
||||
withdrawal_cooldown_days: int | None = Field(None, ge=0, le=365)
|
||||
withdrawal_requisites_text: str | None = Field(None, max_length=2000)
|
||||
partner_section_visible: bool | None = None
|
||||
referral_program_enabled: bool | None = None
|
||||
|
||||
|
||||
def _build_partner_settings_response() -> PartnerSettingsResponse:
|
||||
return PartnerSettingsResponse(
|
||||
withdrawal_enabled=settings.REFERRAL_WITHDRAWAL_ENABLED,
|
||||
withdrawal_min_amount_kopeks=settings.REFERRAL_WITHDRAWAL_MIN_AMOUNT_KOPEKS,
|
||||
withdrawal_cooldown_days=settings.REFERRAL_WITHDRAWAL_COOLDOWN_DAYS,
|
||||
withdrawal_requisites_text=settings.REFERRAL_WITHDRAWAL_REQUISITES_TEXT,
|
||||
partner_section_visible=settings.REFERRAL_PARTNER_SECTION_VISIBLE,
|
||||
referral_program_enabled=settings.REFERRAL_PROGRAM_ENABLED,
|
||||
)
|
||||
|
||||
|
||||
@router.get('/settings', response_model=PartnerSettingsResponse)
|
||||
async def get_partner_settings(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""Get partner system settings."""
|
||||
return _build_partner_settings_response()
|
||||
|
||||
|
||||
@router.patch('/settings', response_model=PartnerSettingsResponse)
|
||||
async def update_partner_settings(
|
||||
request: PartnerSettingsUpdateRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""Update partner system settings."""
|
||||
from pathlib import Path
|
||||
|
||||
# Update in-memory settings
|
||||
if request.withdrawal_enabled is not None:
|
||||
settings.REFERRAL_WITHDRAWAL_ENABLED = request.withdrawal_enabled
|
||||
if request.withdrawal_min_amount_kopeks is not None:
|
||||
settings.REFERRAL_WITHDRAWAL_MIN_AMOUNT_KOPEKS = request.withdrawal_min_amount_kopeks
|
||||
if request.withdrawal_cooldown_days is not None:
|
||||
settings.REFERRAL_WITHDRAWAL_COOLDOWN_DAYS = request.withdrawal_cooldown_days
|
||||
if request.withdrawal_requisites_text is not None:
|
||||
settings.REFERRAL_WITHDRAWAL_REQUISITES_TEXT = request.withdrawal_requisites_text
|
||||
if request.partner_section_visible is not None:
|
||||
settings.REFERRAL_PARTNER_SECTION_VISIBLE = request.partner_section_visible
|
||||
if request.referral_program_enabled is not None:
|
||||
settings.REFERRAL_PROGRAM_ENABLED = request.referral_program_enabled
|
||||
|
||||
# Persist to .env file
|
||||
try:
|
||||
env_file = Path('.env')
|
||||
if env_file.exists():
|
||||
lines = env_file.read_text().splitlines()
|
||||
updates: dict[str, str] = {}
|
||||
|
||||
if request.withdrawal_enabled is not None:
|
||||
updates['REFERRAL_WITHDRAWAL_ENABLED'] = str(request.withdrawal_enabled).lower()
|
||||
if request.withdrawal_min_amount_kopeks is not None:
|
||||
updates['REFERRAL_WITHDRAWAL_MIN_AMOUNT_KOPEKS'] = str(request.withdrawal_min_amount_kopeks)
|
||||
if request.withdrawal_cooldown_days is not None:
|
||||
updates['REFERRAL_WITHDRAWAL_COOLDOWN_DAYS'] = str(request.withdrawal_cooldown_days)
|
||||
if request.withdrawal_requisites_text is not None:
|
||||
# Sanitize: replace newlines to prevent .env injection
|
||||
sanitized = (
|
||||
request.withdrawal_requisites_text.replace('\r\n', ' ').replace('\n', ' ').replace('\r', ' ')
|
||||
)
|
||||
updates['REFERRAL_WITHDRAWAL_REQUISITES_TEXT'] = sanitized
|
||||
if request.partner_section_visible is not None:
|
||||
updates['REFERRAL_PARTNER_SECTION_VISIBLE'] = str(request.partner_section_visible).lower()
|
||||
if request.referral_program_enabled is not None:
|
||||
updates['REFERRAL_PROGRAM_ENABLED'] = str(request.referral_program_enabled).lower()
|
||||
|
||||
new_lines = []
|
||||
updated_keys: set[str] = set()
|
||||
|
||||
for line in lines:
|
||||
updated = False
|
||||
for key, value in updates.items():
|
||||
if line.startswith(f'{key}='):
|
||||
new_lines.append(f'{key}={value}')
|
||||
updated_keys.add(key)
|
||||
updated = True
|
||||
break
|
||||
if not updated:
|
||||
new_lines.append(line)
|
||||
|
||||
for key, value in updates.items():
|
||||
if key not in updated_keys:
|
||||
new_lines.append(f'{key}={value}')
|
||||
|
||||
env_file.write_text('\n'.join(new_lines) + '\n')
|
||||
logger.info('Updated partner settings in .env file', admin_id=admin.id)
|
||||
except Exception as e:
|
||||
logger.warning('Failed to update .env file', error=e)
|
||||
|
||||
return _build_partner_settings_response()
|
||||
|
||||
|
||||
# ==================== Applications (static paths first) ====================
|
||||
|
||||
|
||||
@router.get('/applications', response_model=AdminPartnerApplicationsResponse)
|
||||
async def list_applications(
|
||||
application_status: Literal['pending', 'approved', 'rejected', 'none'] | None = Query(None, alias='status'),
|
||||
offset: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""List partner applications."""
|
||||
applications, total = await partner_application_service.get_all_applications(
|
||||
db, status=application_status, limit=limit, offset=offset
|
||||
)
|
||||
|
||||
# Batch-fetch users to avoid N+1
|
||||
user_ids = list({app.user_id for app in applications})
|
||||
if user_ids:
|
||||
users_result = await db.execute(select(User).where(User.id.in_(user_ids)))
|
||||
users_map = {u.id: u for u in users_result.scalars().all()}
|
||||
else:
|
||||
users_map = {}
|
||||
|
||||
items = []
|
||||
for app in applications:
|
||||
user = users_map.get(app.user_id)
|
||||
items.append(
|
||||
AdminPartnerApplicationItem(
|
||||
id=app.id,
|
||||
user_id=app.user_id,
|
||||
username=user.username if user else None,
|
||||
first_name=user.first_name if user else None,
|
||||
telegram_id=user.telegram_id if user else None,
|
||||
company_name=app.company_name,
|
||||
website_url=app.website_url,
|
||||
telegram_channel=app.telegram_channel,
|
||||
description=app.description,
|
||||
expected_monthly_referrals=app.expected_monthly_referrals,
|
||||
status=app.status,
|
||||
admin_comment=app.admin_comment,
|
||||
approved_commission_percent=app.approved_commission_percent,
|
||||
created_at=app.created_at,
|
||||
processed_at=app.processed_at,
|
||||
)
|
||||
)
|
||||
|
||||
return AdminPartnerApplicationsResponse(items=items, total=total)
|
||||
|
||||
|
||||
@router.post('/applications/{application_id}/approve')
|
||||
async def approve_application(
|
||||
application_id: int,
|
||||
request: AdminApproveRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Approve a partner application."""
|
||||
success, error = await partner_application_service.approve_application(
|
||||
db,
|
||||
application_id=application_id,
|
||||
admin_id=admin.id,
|
||||
commission_percent=request.commission_percent,
|
||||
comment=request.comment,
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=error,
|
||||
)
|
||||
|
||||
# Notify user about approval
|
||||
try:
|
||||
from aiogram import Bot
|
||||
|
||||
from app.config import settings
|
||||
from app.services.notification_delivery_service import notification_delivery_service
|
||||
|
||||
if settings.BOT_TOKEN:
|
||||
application = await db.get(PartnerApplication, application_id)
|
||||
user = await db.get(User, application.user_id) if application else None
|
||||
if user:
|
||||
comment_text = f'\n{request.comment}' if request.comment else ''
|
||||
tg_message = (
|
||||
f'✅ Ваша заявка на партнёрство одобрена!\nКомиссия: {request.commission_percent}%{comment_text}'
|
||||
)
|
||||
bot = Bot(token=settings.BOT_TOKEN)
|
||||
try:
|
||||
await notification_delivery_service.notify_partner_approved(
|
||||
user=user,
|
||||
commission_percent=request.commission_percent,
|
||||
comment=request.comment,
|
||||
bot=bot,
|
||||
telegram_message=tg_message,
|
||||
)
|
||||
finally:
|
||||
await bot.session.close()
|
||||
except Exception as e:
|
||||
logger.error('Failed to send partner approval notification', error=e)
|
||||
|
||||
return {'success': True}
|
||||
|
||||
|
||||
@router.post('/applications/{application_id}/reject')
|
||||
async def reject_application(
|
||||
application_id: int,
|
||||
request: AdminRejectRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Reject a partner application."""
|
||||
success, error = await partner_application_service.reject_application(
|
||||
db,
|
||||
application_id=application_id,
|
||||
admin_id=admin.id,
|
||||
comment=request.comment,
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=error,
|
||||
)
|
||||
|
||||
# Notify user about rejection
|
||||
try:
|
||||
from aiogram import Bot
|
||||
|
||||
from app.config import settings
|
||||
from app.services.notification_delivery_service import notification_delivery_service
|
||||
|
||||
if settings.BOT_TOKEN:
|
||||
application = await db.get(PartnerApplication, application_id)
|
||||
user = await db.get(User, application.user_id) if application else None
|
||||
if user:
|
||||
comment_text = f'\nПричина: {request.comment}' if request.comment else ''
|
||||
tg_message = f'❌ Ваша заявка на партнёрство отклонена.{comment_text}'
|
||||
bot = Bot(token=settings.BOT_TOKEN)
|
||||
try:
|
||||
await notification_delivery_service.notify_partner_rejected(
|
||||
user=user,
|
||||
comment=request.comment,
|
||||
bot=bot,
|
||||
telegram_message=tg_message,
|
||||
)
|
||||
finally:
|
||||
await bot.session.close()
|
||||
except Exception as e:
|
||||
logger.error('Failed to send partner rejection notification', error=e)
|
||||
|
||||
return {'success': True}
|
||||
|
||||
|
||||
# ==================== Stats (static paths) ====================
|
||||
|
||||
|
||||
@router.get('/stats')
|
||||
async def get_partner_stats(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get overall partner statistics."""
|
||||
total_partners = await db.execute(
|
||||
select(func.count()).select_from(User).where(User.partner_status == PartnerStatus.APPROVED.value)
|
||||
)
|
||||
pending_apps = await db.execute(
|
||||
select(func.count())
|
||||
.select_from(PartnerApplication)
|
||||
.where(PartnerApplication.status == PartnerStatus.PENDING.value)
|
||||
)
|
||||
total_referrals = await db.execute(select(func.count()).select_from(User).where(User.referred_by_id.isnot(None)))
|
||||
total_earnings = await db.execute(select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0)))
|
||||
|
||||
return {
|
||||
'total_partners': total_partners.scalar() or 0,
|
||||
'pending_applications': pending_apps.scalar() or 0,
|
||||
'total_referrals': total_referrals.scalar() or 0,
|
||||
'total_earnings_kopeks': total_earnings.scalar() or 0,
|
||||
}
|
||||
|
||||
|
||||
# ==================== Partners list ====================
|
||||
|
||||
|
||||
@router.get('', response_model=AdminPartnerListResponse)
|
||||
async def list_partners(
|
||||
offset: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""List approved partners."""
|
||||
count_result = await db.execute(
|
||||
select(func.count()).select_from(User).where(User.partner_status == PartnerStatus.APPROVED.value)
|
||||
)
|
||||
total = count_result.scalar() or 0
|
||||
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.where(User.partner_status == PartnerStatus.APPROVED.value)
|
||||
.order_by(desc(User.created_at))
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
partners = result.scalars().all()
|
||||
|
||||
# Batch-fetch earnings and referral counts to avoid N+1
|
||||
partner_ids = [u.id for u in partners]
|
||||
earnings_map: dict[int, int] = {}
|
||||
referral_count_map: dict[int, int] = {}
|
||||
|
||||
if partner_ids:
|
||||
earnings_result = await db.execute(
|
||||
select(ReferralEarning.user_id, func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0))
|
||||
.where(ReferralEarning.user_id.in_(partner_ids))
|
||||
.group_by(ReferralEarning.user_id)
|
||||
)
|
||||
earnings_map = {row[0]: int(row[1]) for row in earnings_result.all()}
|
||||
|
||||
referral_result = await db.execute(
|
||||
select(User.referred_by_id, func.count())
|
||||
.where(User.referred_by_id.in_(partner_ids))
|
||||
.group_by(User.referred_by_id)
|
||||
)
|
||||
referral_count_map = {row[0]: row[1] for row in referral_result.all()}
|
||||
|
||||
items = []
|
||||
for user in partners:
|
||||
items.append(
|
||||
AdminPartnerItem(
|
||||
user_id=user.id,
|
||||
username=user.username,
|
||||
first_name=user.first_name,
|
||||
telegram_id=user.telegram_id,
|
||||
commission_percent=user.referral_commission_percent,
|
||||
total_referrals=referral_count_map.get(user.id, 0),
|
||||
total_earnings_kopeks=earnings_map.get(user.id, 0),
|
||||
balance_kopeks=user.balance_kopeks,
|
||||
partner_status=user.partner_status,
|
||||
created_at=user.created_at,
|
||||
)
|
||||
)
|
||||
|
||||
return AdminPartnerListResponse(items=items, total=total)
|
||||
|
||||
|
||||
# ==================== Partner detail (parametric paths last) ====================
|
||||
|
||||
|
||||
@router.get('/{user_id}', response_model=AdminPartnerDetailResponse)
|
||||
async def get_partner_detail(
|
||||
user_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get detailed partner info."""
|
||||
user = await db.get(User, user_id)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Пользователь не найден',
|
||||
)
|
||||
|
||||
stats = await PartnerStatsService.get_referrer_detailed_stats(db, user_id)
|
||||
|
||||
# Get assigned campaigns
|
||||
campaigns_result = await db.execute(
|
||||
select(AdvertisingCampaign).where(AdvertisingCampaign.partner_user_id == user_id)
|
||||
)
|
||||
campaigns = campaigns_result.scalars().all()
|
||||
campaign_list = [
|
||||
CampaignSummary(
|
||||
id=c.id,
|
||||
name=c.name,
|
||||
start_parameter=c.start_parameter,
|
||||
is_active=c.is_active,
|
||||
)
|
||||
for c in campaigns
|
||||
]
|
||||
|
||||
summary = stats['summary']
|
||||
earnings = stats['earnings']
|
||||
|
||||
return AdminPartnerDetailResponse(
|
||||
user_id=user.id,
|
||||
username=user.username,
|
||||
first_name=user.first_name,
|
||||
telegram_id=user.telegram_id,
|
||||
commission_percent=user.referral_commission_percent,
|
||||
partner_status=user.partner_status,
|
||||
balance_kopeks=user.balance_kopeks,
|
||||
total_referrals=summary['total_referrals'],
|
||||
paid_referrals=summary['paid_referrals'],
|
||||
active_referrals=summary['active_referrals'],
|
||||
earnings_all_time=earnings['all_time_kopeks'],
|
||||
earnings_today=earnings['today_kopeks'],
|
||||
earnings_week=earnings['week_kopeks'],
|
||||
earnings_month=earnings['month_kopeks'],
|
||||
conversion_to_paid=summary['conversion_to_paid_percent'],
|
||||
campaigns=campaign_list,
|
||||
created_at=user.created_at,
|
||||
)
|
||||
|
||||
|
||||
@router.patch('/{user_id}/commission')
|
||||
async def update_commission(
|
||||
user_id: int,
|
||||
request: AdminUpdateCommissionRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update partner commission percent."""
|
||||
user = await db.get(User, user_id)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Пользователь не найден',
|
||||
)
|
||||
|
||||
if user.partner_status != PartnerStatus.APPROVED.value:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Пользователь не является партнёром',
|
||||
)
|
||||
|
||||
old_commission = user.referral_commission_percent
|
||||
user.referral_commission_percent = request.commission_percent
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
'Комиссия партнёра обновлена',
|
||||
user_id=user_id,
|
||||
old_commission=old_commission,
|
||||
new_commission=request.commission_percent,
|
||||
admin_id=admin.id,
|
||||
)
|
||||
|
||||
return {'success': True, 'commission_percent': request.commission_percent}
|
||||
|
||||
|
||||
@router.post('/{user_id}/revoke')
|
||||
async def revoke_partner(
|
||||
user_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Revoke partner status."""
|
||||
success, error = await partner_application_service.revoke_partner(db, user_id=user_id, admin_id=admin.id)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=error,
|
||||
)
|
||||
|
||||
return {'success': True}
|
||||
|
||||
|
||||
@router.post('/{user_id}/campaigns/{campaign_id}/assign')
|
||||
async def assign_campaign(
|
||||
user_id: int,
|
||||
campaign_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Assign a campaign to a partner."""
|
||||
campaign = await db.get(AdvertisingCampaign, campaign_id)
|
||||
if not campaign:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Кампания не найдена',
|
||||
)
|
||||
|
||||
user = await db.get(User, user_id)
|
||||
if not user or user.partner_status != PartnerStatus.APPROVED.value:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Пользователь не является партнёром',
|
||||
)
|
||||
|
||||
# Atomic check-and-set to prevent race conditions
|
||||
result = await db.execute(
|
||||
update(AdvertisingCampaign)
|
||||
.where(
|
||||
AdvertisingCampaign.id == campaign_id,
|
||||
or_(
|
||||
AdvertisingCampaign.partner_user_id.is_(None),
|
||||
AdvertisingCampaign.partner_user_id == user_id,
|
||||
),
|
||||
)
|
||||
.values(partner_user_id=user_id, updated_at=datetime.now(UTC))
|
||||
)
|
||||
if result.rowcount == 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Кампания уже привязана к другому партнёру',
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
return {'success': True}
|
||||
|
||||
|
||||
@router.post('/{user_id}/campaigns/{campaign_id}/unassign')
|
||||
async def unassign_campaign(
|
||||
user_id: int,
|
||||
campaign_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Unassign a campaign from a partner."""
|
||||
campaign = await db.get(AdvertisingCampaign, campaign_id)
|
||||
if not campaign:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Кампания не найдена',
|
||||
)
|
||||
|
||||
if campaign.partner_user_id != user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Кампания не привязана к этому партнёру',
|
||||
)
|
||||
|
||||
campaign.partner_user_id = None
|
||||
campaign.updated_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
|
||||
return {'success': True}
|
||||
@@ -0,0 +1,228 @@
|
||||
"""Admin routes for payment method configuration in cabinet."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
import structlog
|
||||
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 = structlog.get_logger(__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('Admin updated payment methods order', admin_id=admin.id, method_ids=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('Admin updated payment method config', admin_id=admin.id, method_id=method_id)
|
||||
|
||||
defaults = _get_method_defaults()
|
||||
return _enrich_config(config, defaults)
|
||||
@@ -1,36 +1,38 @@
|
||||
"""Admin routes for payment verification in cabinet."""
|
||||
|
||||
import logging
|
||||
import math
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import User, PaymentMethod
|
||||
from app.database.models import PaymentMethod, User
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.services.payment_verification_service import (
|
||||
list_recent_pending_payments,
|
||||
get_payment_record,
|
||||
run_manual_check,
|
||||
SUPPORTED_MANUAL_CHECK_METHODS,
|
||||
method_display_name,
|
||||
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"])
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/payments', tags=['Cabinet Admin Payments'])
|
||||
|
||||
|
||||
# ============ Schemas ============
|
||||
|
||||
|
||||
class PendingPaymentResponse(BaseModel):
|
||||
"""Pending payment details."""
|
||||
|
||||
id: int
|
||||
method: str
|
||||
method_display: str
|
||||
@@ -43,11 +45,11 @@ class PendingPaymentResponse(BaseModel):
|
||||
is_paid: bool
|
||||
is_checkable: bool
|
||||
created_at: datetime
|
||||
expires_at: Optional[datetime] = None
|
||||
payment_url: Optional[str] = None
|
||||
user_id: Optional[int] = None
|
||||
user_telegram_id: Optional[int] = None
|
||||
user_username: Optional[str] = None
|
||||
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
|
||||
@@ -55,7 +57,8 @@ class PendingPaymentResponse(BaseModel):
|
||||
|
||||
class PendingPaymentListResponse(BaseModel):
|
||||
"""Paginated list of pending payments."""
|
||||
items: List[PendingPaymentResponse]
|
||||
|
||||
items: list[PendingPaymentResponse]
|
||||
total: int
|
||||
page: int
|
||||
per_page: int
|
||||
@@ -64,120 +67,123 @@ class PendingPaymentListResponse(BaseModel):
|
||||
|
||||
class ManualCheckResponse(BaseModel):
|
||||
"""Response after manual payment status check."""
|
||||
|
||||
success: bool
|
||||
message: str
|
||||
payment: Optional[PendingPaymentResponse] = None
|
||||
payment: PendingPaymentResponse | None = None
|
||||
status_changed: bool = False
|
||||
old_status: Optional[str] = None
|
||||
new_status: Optional[str] = None
|
||||
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()
|
||||
status_str = (record.status or '').lower()
|
||||
|
||||
if record.is_paid:
|
||||
return "✅", "Оплачено"
|
||||
return '✅', 'Оплачено'
|
||||
|
||||
if record.method == PaymentMethod.PAL24:
|
||||
mapping = {
|
||||
"new": ("⏳", "Ожидает оплаты"),
|
||||
"process": ("⌛", "Обрабатывается"),
|
||||
"success": ("✅", "Оплачено"),
|
||||
"fail": ("❌", "Ошибка"),
|
||||
"canceled": ("❌", "Отменено"),
|
||||
'new': ('⏳', 'Ожидает оплаты'),
|
||||
'process': ('⌛', 'Обрабатывается'),
|
||||
'success': ('✅', 'Оплачено'),
|
||||
'fail': ('❌', 'Ошибка'),
|
||||
'canceled': ('❌', 'Отменено'),
|
||||
}
|
||||
return mapping.get(status_str, ("❓", "Неизвестно"))
|
||||
return mapping.get(status_str, ('❓', 'Неизвестно'))
|
||||
|
||||
if record.method == PaymentMethod.MULENPAY:
|
||||
mapping = {
|
||||
"created": ("⏳", "Ожидает оплаты"),
|
||||
"processing": ("⌛", "Обрабатывается"),
|
||||
"hold": ("🔒", "На удержании"),
|
||||
"success": ("✅", "Оплачено"),
|
||||
"canceled": ("❌", "Отменено"),
|
||||
"error": ("❌", "Ошибка"),
|
||||
'created': ('⏳', 'Ожидает оплаты'),
|
||||
'processing': ('⌛', 'Обрабатывается'),
|
||||
'hold': ('🔒', 'На удержании'),
|
||||
'success': ('✅', 'Оплачено'),
|
||||
'canceled': ('❌', 'Отменено'),
|
||||
'error': ('❌', 'Ошибка'),
|
||||
}
|
||||
return mapping.get(status_str, ("❓", "Неизвестно"))
|
||||
return mapping.get(status_str, ('❓', 'Неизвестно'))
|
||||
|
||||
if record.method == PaymentMethod.WATA:
|
||||
mapping = {
|
||||
"opened": ("⏳", "Ожидает оплаты"),
|
||||
"pending": ("⏳", "Ожидает оплаты"),
|
||||
"processing": ("⌛", "Обрабатывается"),
|
||||
"paid": ("✅", "Оплачено"),
|
||||
"closed": ("✅", "Оплачено"),
|
||||
"declined": ("❌", "Отклонено"),
|
||||
"canceled": ("❌", "Отменено"),
|
||||
"expired": ("⌛", "Истёк"),
|
||||
'opened': ('⏳', 'Ожидает оплаты'),
|
||||
'pending': ('⏳', 'Ожидает оплаты'),
|
||||
'processing': ('⌛', 'Обрабатывается'),
|
||||
'paid': ('✅', 'Оплачено'),
|
||||
'closed': ('✅', 'Оплачено'),
|
||||
'declined': ('❌', 'Отклонено'),
|
||||
'canceled': ('❌', 'Отменено'),
|
||||
'expired': ('⌛', 'Истёк'),
|
||||
}
|
||||
return mapping.get(status_str, ("❓", "Неизвестно"))
|
||||
return mapping.get(status_str, ('❓', 'Неизвестно'))
|
||||
|
||||
if record.method == PaymentMethod.PLATEGA:
|
||||
mapping = {
|
||||
"pending": ("⏳", "Ожидает оплаты"),
|
||||
"inprogress": ("⌛", "Обрабатывается"),
|
||||
"confirmed": ("✅", "Оплачено"),
|
||||
"failed": ("❌", "Ошибка"),
|
||||
"canceled": ("❌", "Отменено"),
|
||||
"expired": ("⌛", "Истёк"),
|
||||
'pending': ('⏳', 'Ожидает оплаты'),
|
||||
'inprogress': ('⌛', 'Обрабатывается'),
|
||||
'confirmed': ('✅', 'Оплачено'),
|
||||
'failed': ('❌', 'Ошибка'),
|
||||
'canceled': ('❌', 'Отменено'),
|
||||
'expired': ('⌛', 'Истёк'),
|
||||
}
|
||||
return mapping.get(status_str, ("❓", "Неизвестно"))
|
||||
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 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": ("❌", "Отменено"),
|
||||
'pending': ('⏳', 'Ожидает оплаты'),
|
||||
'waiting_for_capture': ('⌛', 'Обрабатывается'),
|
||||
'succeeded': ('✅', 'Оплачено'),
|
||||
'canceled': ('❌', 'Отменено'),
|
||||
}
|
||||
return mapping.get(status_str, ("❓", "Неизвестно"))
|
||||
return mapping.get(status_str, ('❓', 'Неизвестно'))
|
||||
|
||||
if record.method == PaymentMethod.CRYPTOBOT:
|
||||
mapping = {
|
||||
"active": ("⏳", "Ожидает оплаты"),
|
||||
"paid": ("✅", "Оплачено"),
|
||||
"expired": ("⌛", "Истёк"),
|
||||
'active': ('⏳', 'Ожидает оплаты'),
|
||||
'paid': ('✅', 'Оплачено'),
|
||||
'expired': ('⌛', 'Истёк'),
|
||||
}
|
||||
return mapping.get(status_str, ("❓", "Неизвестно"))
|
||||
return mapping.get(status_str, ('❓', 'Неизвестно'))
|
||||
|
||||
if record.method == PaymentMethod.CLOUDPAYMENTS:
|
||||
mapping = {
|
||||
"pending": ("⏳", "Ожидает оплаты"),
|
||||
"authorized": ("⌛", "Авторизовано"),
|
||||
"completed": ("✅", "Оплачено"),
|
||||
"failed": ("❌", "Ошибка"),
|
||||
'pending': ('⏳', 'Ожидает оплаты'),
|
||||
'authorized': ('⌛', 'Авторизовано'),
|
||||
'completed': ('✅', 'Оплачено'),
|
||||
'failed': ('❌', 'Ошибка'),
|
||||
}
|
||||
return mapping.get(status_str, ("❓", "Неизвестно"))
|
||||
return mapping.get(status_str, ('❓', 'Неизвестно'))
|
||||
|
||||
if record.method == PaymentMethod.FREEKASSA:
|
||||
mapping = {
|
||||
"pending": ("⏳", "Ожидает оплаты"),
|
||||
"success": ("✅", "Оплачено"),
|
||||
"paid": ("✅", "Оплачено"),
|
||||
"canceled": ("❌", "Отменено"),
|
||||
"error": ("❌", "Ошибка"),
|
||||
'pending': ('⏳', 'Ожидает оплаты'),
|
||||
'success': ('✅', 'Оплачено'),
|
||||
'paid': ('✅', 'Оплачено'),
|
||||
'canceled': ('❌', 'Отменено'),
|
||||
'error': ('❌', 'Ошибка'),
|
||||
}
|
||||
return mapping.get(status_str, ("❓", "Неизвестно"))
|
||||
return mapping.get(status_str, ('❓', 'Неизвестно'))
|
||||
|
||||
return "❓", "Неизвестно"
|
||||
return '❓', 'Неизвестно'
|
||||
|
||||
|
||||
def _is_checkable(record: PendingPayment) -> bool:
|
||||
@@ -186,52 +192,50 @@ def _is_checkable(record: PendingPayment) -> bool:
|
||||
return False
|
||||
if not record.is_recent():
|
||||
return False
|
||||
status_str = (record.status or "").lower()
|
||||
status_str = (record.status or '').lower()
|
||||
if record.method == PaymentMethod.PAL24:
|
||||
return status_str in {"new", "process"}
|
||||
return status_str in {'new', 'process'}
|
||||
if record.method == PaymentMethod.MULENPAY:
|
||||
return status_str in {"created", "processing", "hold"}
|
||||
return status_str in {'created', 'processing', 'hold'}
|
||||
if record.method == PaymentMethod.WATA:
|
||||
return status_str in {"opened", "pending", "processing", "inprogress", "in_progress"}
|
||||
return status_str in {'opened', 'pending', 'processing', 'inprogress', 'in_progress'}
|
||||
if record.method == PaymentMethod.PLATEGA:
|
||||
return status_str in {"pending", "inprogress", "in_progress"}
|
||||
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"}
|
||||
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"}
|
||||
return status_str in {'pending', 'waiting_for_capture'}
|
||||
if record.method == PaymentMethod.CRYPTOBOT:
|
||||
return status_str in {"active"}
|
||||
return status_str in {'active'}
|
||||
if record.method == PaymentMethod.CLOUDPAYMENTS:
|
||||
return status_str in {"pending", "authorized"}
|
||||
return status_str in {'pending', 'authorized'}
|
||||
if record.method == PaymentMethod.FREEKASSA:
|
||||
return status_str in {"pending", "created", "processing"}
|
||||
return status_str in {'pending', 'created', 'processing'}
|
||||
return False
|
||||
|
||||
|
||||
def _get_payment_url(record: PendingPayment) -> Optional[str]:
|
||||
def _get_payment_url(record: PendingPayment) -> str | None:
|
||||
"""Extract payment URL from record."""
|
||||
payment = record.payment
|
||||
payment_url = getattr(payment, "payment_url", None)
|
||||
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
|
||||
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
|
||||
payment_url = getattr(payment, 'url', None) or payment_url
|
||||
elif record.method == PaymentMethod.YOOKASSA:
|
||||
payment_url = getattr(payment, "confirmation_url", None) or payment_url
|
||||
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)
|
||||
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:
|
||||
payment_url = getattr(payment, "payment_url", None) or payment_url
|
||||
elif record.method == PaymentMethod.FREEKASSA:
|
||||
payment_url = getattr(payment, "payment_url", None) or payment_url
|
||||
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
|
||||
|
||||
@@ -246,7 +250,7 @@ def _record_to_response(record: PendingPayment) -> PendingPaymentResponse:
|
||||
identifier=record.identifier,
|
||||
amount_kopeks=record.amount_kopeks,
|
||||
amount_rubles=record.amount_kopeks / 100,
|
||||
status=record.status or "",
|
||||
status=record.status or '',
|
||||
status_emoji=status_emoji,
|
||||
status_text=status_text,
|
||||
is_paid=record.is_paid,
|
||||
@@ -262,11 +266,12 @@ def _record_to_response(record: PendingPayment) -> PendingPaymentResponse:
|
||||
|
||||
# ============ Routes ============
|
||||
|
||||
@router.get("", response_model=PendingPaymentListResponse)
|
||||
|
||||
@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: Optional[str] = Query(None, description="Filter by payment method"),
|
||||
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),
|
||||
):
|
||||
@@ -286,7 +291,7 @@ async def get_all_pending_payments(
|
||||
|
||||
# Paginate
|
||||
start_idx = (page - 1) * per_page
|
||||
page_payments = all_pending[start_idx:start_idx + per_page]
|
||||
page_payments = all_pending[start_idx : start_idx + per_page]
|
||||
|
||||
items = [_record_to_response(p) for p in page_payments]
|
||||
|
||||
@@ -299,7 +304,7 @@ async def get_all_pending_payments(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/stats", response_model=PaymentsStatsResponse)
|
||||
@router.get('/stats', response_model=PaymentsStatsResponse)
|
||||
async def get_payments_stats(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
@@ -320,7 +325,7 @@ async def get_payments_stats(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{method}/{payment_id}", response_model=PendingPaymentResponse)
|
||||
@router.get('/{method}/{payment_id}', response_model=PendingPaymentResponse)
|
||||
async def get_pending_payment_details(
|
||||
method: str,
|
||||
payment_id: int,
|
||||
@@ -333,7 +338,7 @@ async def get_pending_payment_details(
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid payment method: {method}",
|
||||
detail=f'Invalid payment method: {method}',
|
||||
)
|
||||
|
||||
record = await get_payment_record(db, payment_method, payment_id)
|
||||
@@ -341,13 +346,13 @@ async def get_pending_payment_details(
|
||||
if not record:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Payment not found",
|
||||
detail='Payment not found',
|
||||
)
|
||||
|
||||
return _record_to_response(record)
|
||||
|
||||
|
||||
@router.post("/{method}/{payment_id}/check", response_model=ManualCheckResponse)
|
||||
@router.post('/{method}/{payment_id}/check', response_model=ManualCheckResponse)
|
||||
async def check_payment_status(
|
||||
method: str,
|
||||
payment_id: int,
|
||||
@@ -360,7 +365,7 @@ async def check_payment_status(
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid payment method: {method}",
|
||||
detail=f'Invalid payment method: {method}',
|
||||
)
|
||||
|
||||
# Get current record
|
||||
@@ -369,14 +374,14 @@ async def check_payment_status(
|
||||
if not record:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Payment not found",
|
||||
detail='Payment not found',
|
||||
)
|
||||
|
||||
# Check if manual check is available
|
||||
if not _is_checkable(record):
|
||||
return ManualCheckResponse(
|
||||
success=False,
|
||||
message="Ручная проверка недоступна для этого платежа",
|
||||
message='Ручная проверка недоступна для этого платежа',
|
||||
payment=_record_to_response(record),
|
||||
status_changed=False,
|
||||
)
|
||||
@@ -391,7 +396,7 @@ async def check_payment_status(
|
||||
if not updated:
|
||||
return ManualCheckResponse(
|
||||
success=False,
|
||||
message="Не удалось проверить статус платежа",
|
||||
message='Не удалось проверить статус платежа',
|
||||
payment=_record_to_response(record),
|
||||
status_changed=False,
|
||||
)
|
||||
@@ -400,12 +405,17 @@ async def check_payment_status(
|
||||
|
||||
if status_changed:
|
||||
_, new_status_text = _get_status_info(updated)
|
||||
message = f"Статус обновлён: {new_status_text}"
|
||||
message = f'Статус обновлён: {new_status_text}'
|
||||
logger.info(
|
||||
f"Admin {admin.id} checked payment {method}/{payment_id}: {old_status} -> {updated.status}"
|
||||
'Admin checked payment /',
|
||||
admin_id=admin.id,
|
||||
method=method,
|
||||
payment_id=payment_id,
|
||||
old_status=old_status,
|
||||
status=updated.status,
|
||||
)
|
||||
else:
|
||||
message = "Статус не изменился"
|
||||
message = 'Статус не изменился'
|
||||
|
||||
return ManualCheckResponse(
|
||||
success=True,
|
||||
|
||||
@@ -0,0 +1,412 @@
|
||||
"""Admin routes for pinned messages in cabinet."""
|
||||
|
||||
import time
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from aiogram import Bot
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import PinnedMessage, User
|
||||
from app.services.pinned_message_service import (
|
||||
broadcast_pinned_message,
|
||||
deactivate_active_pinned_message,
|
||||
get_active_pinned_message,
|
||||
set_active_pinned_message,
|
||||
unpin_active_pinned_message,
|
||||
)
|
||||
from app.utils.validators import sanitize_html, validate_html_tags
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
from ..schemas.pinned_messages import (
|
||||
PinnedMessageBroadcastResponse,
|
||||
PinnedMessageCreateRequest,
|
||||
PinnedMessageListResponse,
|
||||
PinnedMessageResponse,
|
||||
PinnedMessageSettingsRequest,
|
||||
PinnedMessageUnpinResponse,
|
||||
PinnedMessageUpdateRequest,
|
||||
)
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/pinned-messages', tags=['Cabinet Admin Pinned Messages'])
|
||||
|
||||
# Broadcast cooldown: min 60 seconds between mass operations
|
||||
_BROADCAST_COOLDOWN_SECONDS = 60
|
||||
_last_broadcast_time: float = 0.0
|
||||
|
||||
|
||||
def _check_broadcast_cooldown() -> None:
|
||||
global _last_broadcast_time
|
||||
now = time.monotonic()
|
||||
elapsed = now - _last_broadcast_time
|
||||
if _last_broadcast_time > 0 and elapsed < _BROADCAST_COOLDOWN_SECONDS:
|
||||
remaining = int(_BROADCAST_COOLDOWN_SECONDS - elapsed)
|
||||
raise HTTPException(
|
||||
status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
f'Broadcast cooldown active. Try again in {remaining} seconds.',
|
||||
)
|
||||
_last_broadcast_time = now
|
||||
|
||||
|
||||
def _serialize_pinned_message(msg: PinnedMessage) -> PinnedMessageResponse:
|
||||
return PinnedMessageResponse(
|
||||
id=msg.id,
|
||||
content=msg.content,
|
||||
media_type=msg.media_type,
|
||||
media_file_id=msg.media_file_id,
|
||||
send_before_menu=msg.send_before_menu,
|
||||
send_on_every_start=msg.send_on_every_start,
|
||||
is_active=msg.is_active,
|
||||
created_by=msg.created_by,
|
||||
created_at=msg.created_at,
|
||||
updated_at=msg.updated_at,
|
||||
)
|
||||
|
||||
|
||||
_cached_bot: Bot | None = None
|
||||
|
||||
|
||||
def _get_bot() -> Bot:
|
||||
global _cached_bot
|
||||
if _cached_bot is None:
|
||||
_cached_bot = Bot(
|
||||
token=settings.BOT_TOKEN,
|
||||
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
|
||||
)
|
||||
return _cached_bot
|
||||
|
||||
|
||||
# ============ List / Get Endpoints ============
|
||||
|
||||
|
||||
@router.get('', response_model=PinnedMessageListResponse)
|
||||
async def list_pinned_messages(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
offset: int = Query(0, ge=0),
|
||||
active_only: bool = Query(False),
|
||||
) -> PinnedMessageListResponse:
|
||||
"""Get list of pinned messages with pagination."""
|
||||
query = select(PinnedMessage).order_by(PinnedMessage.created_at.desc())
|
||||
count_query = select(func.count(PinnedMessage.id))
|
||||
|
||||
if active_only:
|
||||
query = query.where(PinnedMessage.is_active.is_(True))
|
||||
count_query = count_query.where(PinnedMessage.is_active.is_(True))
|
||||
|
||||
total = await db.scalar(count_query) or 0
|
||||
result = await db.execute(query.offset(offset).limit(limit))
|
||||
items = result.scalars().all()
|
||||
|
||||
return PinnedMessageListResponse(
|
||||
items=[_serialize_pinned_message(msg) for msg in items],
|
||||
total=int(total),
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
|
||||
@router.get('/active', response_model=PinnedMessageResponse | None)
|
||||
async def get_active_message(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> PinnedMessageResponse | None:
|
||||
"""Get current active pinned message."""
|
||||
msg = await get_active_pinned_message(db)
|
||||
if not msg:
|
||||
return None
|
||||
return _serialize_pinned_message(msg)
|
||||
|
||||
|
||||
@router.get('/{message_id}', response_model=PinnedMessageResponse)
|
||||
async def get_pinned_message(
|
||||
message_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> PinnedMessageResponse:
|
||||
"""Get pinned message by ID."""
|
||||
result = await db.execute(select(PinnedMessage).where(PinnedMessage.id == message_id))
|
||||
msg = result.scalar_one_or_none()
|
||||
if not msg:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Pinned message not found')
|
||||
return _serialize_pinned_message(msg)
|
||||
|
||||
|
||||
# ============ Create / Update Endpoints ============
|
||||
|
||||
|
||||
@router.post('', response_model=PinnedMessageBroadcastResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_pinned_message(
|
||||
payload: PinnedMessageCreateRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> PinnedMessageBroadcastResponse:
|
||||
"""
|
||||
Create a new pinned message.
|
||||
|
||||
Automatically deactivates previous active message.
|
||||
If broadcast=true, sends to all active users immediately.
|
||||
"""
|
||||
# Проверяем cooldown ДО мутации в БД
|
||||
if payload.broadcast:
|
||||
_check_broadcast_cooldown()
|
||||
|
||||
content = payload.content.strip()
|
||||
if not content and not payload.media:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Either content or media must be provided')
|
||||
|
||||
media_type = payload.media.type if payload.media else None
|
||||
media_file_id = payload.media.file_id if payload.media else None
|
||||
|
||||
try:
|
||||
msg = await set_active_pinned_message(
|
||||
db=db,
|
||||
content=content,
|
||||
created_by=admin.id,
|
||||
media_type=media_type,
|
||||
media_file_id=media_file_id,
|
||||
send_before_menu=payload.send_before_menu,
|
||||
send_on_every_start=payload.send_on_every_start,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e))
|
||||
|
||||
sent_count = 0
|
||||
failed_count = 0
|
||||
|
||||
if payload.broadcast:
|
||||
sent_count, failed_count = await broadcast_pinned_message(_get_bot(), db, msg)
|
||||
|
||||
logger.info(
|
||||
'Admin created pinned message # (broadcast=)', admin_id=admin.id, message_id=msg.id, broadcast=payload.broadcast
|
||||
)
|
||||
|
||||
return PinnedMessageBroadcastResponse(
|
||||
message=_serialize_pinned_message(msg),
|
||||
sent_count=sent_count,
|
||||
failed_count=failed_count,
|
||||
)
|
||||
|
||||
|
||||
@router.patch('/{message_id}', response_model=PinnedMessageResponse)
|
||||
async def update_pinned_message(
|
||||
message_id: int,
|
||||
payload: PinnedMessageUpdateRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> PinnedMessageResponse:
|
||||
"""Update a pinned message content, media, or settings."""
|
||||
result = await db.execute(select(PinnedMessage).where(PinnedMessage.id == message_id))
|
||||
msg = result.scalar_one_or_none()
|
||||
if not msg:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Pinned message not found')
|
||||
|
||||
if payload.content is not None:
|
||||
sanitized = sanitize_html(payload.content)
|
||||
is_valid, error = validate_html_tags(sanitized)
|
||||
if not is_valid:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, error)
|
||||
msg.content = sanitized
|
||||
|
||||
if payload.media is not None:
|
||||
msg.media_type = payload.media.type
|
||||
msg.media_file_id = payload.media.file_id
|
||||
|
||||
if payload.send_before_menu is not None:
|
||||
msg.send_before_menu = payload.send_before_menu
|
||||
|
||||
if payload.send_on_every_start is not None:
|
||||
msg.send_on_every_start = payload.send_on_every_start
|
||||
|
||||
msg.updated_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
await db.refresh(msg)
|
||||
|
||||
logger.info('Admin updated pinned message #', admin_id=admin.id, message_id=message_id)
|
||||
|
||||
return _serialize_pinned_message(msg)
|
||||
|
||||
|
||||
@router.patch('/{message_id}/settings', response_model=PinnedMessageResponse)
|
||||
async def update_pinned_message_settings(
|
||||
message_id: int,
|
||||
payload: PinnedMessageSettingsRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> PinnedMessageResponse:
|
||||
"""Update only pinned message display settings."""
|
||||
result = await db.execute(select(PinnedMessage).where(PinnedMessage.id == message_id))
|
||||
msg = result.scalar_one_or_none()
|
||||
if not msg:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Pinned message not found')
|
||||
|
||||
if payload.send_before_menu is not None:
|
||||
msg.send_before_menu = payload.send_before_menu
|
||||
|
||||
if payload.send_on_every_start is not None:
|
||||
msg.send_on_every_start = payload.send_on_every_start
|
||||
|
||||
msg.updated_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
await db.refresh(msg)
|
||||
|
||||
return _serialize_pinned_message(msg)
|
||||
|
||||
|
||||
# ============ Active Message Actions (before /{message_id} POST routes) ============
|
||||
|
||||
|
||||
@router.post('/active/deactivate', response_model=PinnedMessageResponse | None)
|
||||
async def deactivate_active_message(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> PinnedMessageResponse | None:
|
||||
"""Deactivate the current active pinned message without unpinning from users."""
|
||||
msg = await deactivate_active_pinned_message(db)
|
||||
if not msg:
|
||||
return None
|
||||
|
||||
logger.info('Admin deactivated pinned message #', admin_id=admin.id, message_id=msg.id)
|
||||
|
||||
return _serialize_pinned_message(msg)
|
||||
|
||||
|
||||
@router.post('/active/unpin', response_model=PinnedMessageUnpinResponse)
|
||||
async def unpin_active_message(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> PinnedMessageUnpinResponse:
|
||||
"""Unpin messages from all users and deactivate the active pinned message."""
|
||||
_check_broadcast_cooldown()
|
||||
unpinned_count, failed_count, was_active = await unpin_active_pinned_message(_get_bot(), db)
|
||||
|
||||
if was_active:
|
||||
logger.info(
|
||||
'Admin unpinned active message: unpinned=, failed',
|
||||
admin_id=admin.id,
|
||||
unpinned_count=unpinned_count,
|
||||
failed_count=failed_count,
|
||||
)
|
||||
|
||||
return PinnedMessageUnpinResponse(
|
||||
unpinned_count=unpinned_count,
|
||||
failed_count=failed_count,
|
||||
was_active=was_active,
|
||||
)
|
||||
|
||||
|
||||
# ============ Per-Message Actions ============
|
||||
|
||||
|
||||
@router.post('/{message_id}/activate', response_model=PinnedMessageBroadcastResponse)
|
||||
async def activate_pinned_message(
|
||||
message_id: int,
|
||||
broadcast: bool = Query(False),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> PinnedMessageBroadcastResponse:
|
||||
"""
|
||||
Activate a pinned message.
|
||||
|
||||
Deactivates the current active message and activates the specified one.
|
||||
If broadcast=true, sends to all active users immediately.
|
||||
"""
|
||||
# Проверяем cooldown ДО мутации в БД
|
||||
if broadcast:
|
||||
_check_broadcast_cooldown()
|
||||
|
||||
result = await db.execute(select(PinnedMessage).where(PinnedMessage.id == message_id))
|
||||
msg = result.scalar_one_or_none()
|
||||
if not msg:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Pinned message not found')
|
||||
|
||||
await db.execute(
|
||||
update(PinnedMessage)
|
||||
.where(PinnedMessage.is_active.is_(True))
|
||||
.values(is_active=False, updated_at=datetime.now(UTC))
|
||||
)
|
||||
|
||||
msg.is_active = True
|
||||
msg.updated_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
await db.refresh(msg)
|
||||
|
||||
sent_count = 0
|
||||
failed_count = 0
|
||||
|
||||
if broadcast:
|
||||
sent_count, failed_count = await broadcast_pinned_message(_get_bot(), db, msg)
|
||||
|
||||
logger.info(
|
||||
'Admin activated pinned message # (broadcast=)', admin_id=admin.id, message_id=message_id, broadcast=broadcast
|
||||
)
|
||||
|
||||
return PinnedMessageBroadcastResponse(
|
||||
message=_serialize_pinned_message(msg),
|
||||
sent_count=sent_count,
|
||||
failed_count=failed_count,
|
||||
)
|
||||
|
||||
|
||||
@router.post('/{message_id}/broadcast', response_model=PinnedMessageBroadcastResponse)
|
||||
async def broadcast_message(
|
||||
message_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> PinnedMessageBroadcastResponse:
|
||||
"""Broadcast a pinned message to all active users."""
|
||||
_check_broadcast_cooldown()
|
||||
|
||||
result = await db.execute(select(PinnedMessage).where(PinnedMessage.id == message_id))
|
||||
msg = result.scalar_one_or_none()
|
||||
if not msg:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Pinned message not found')
|
||||
|
||||
sent_count, failed_count = await broadcast_pinned_message(_get_bot(), db, msg)
|
||||
|
||||
logger.info(
|
||||
'Admin broadcast pinned message #: sent=, failed',
|
||||
admin_id=admin.id,
|
||||
message_id=message_id,
|
||||
sent_count=sent_count,
|
||||
failed_count=failed_count,
|
||||
)
|
||||
|
||||
return PinnedMessageBroadcastResponse(
|
||||
message=_serialize_pinned_message(msg),
|
||||
sent_count=sent_count,
|
||||
failed_count=failed_count,
|
||||
)
|
||||
|
||||
|
||||
@router.delete('/{message_id}', status_code=status.HTTP_204_NO_CONTENT, response_model=None)
|
||||
async def delete_pinned_message(
|
||||
message_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> None:
|
||||
"""Delete a pinned message. Active messages must be deactivated first."""
|
||||
result = await db.execute(select(PinnedMessage).where(PinnedMessage.id == message_id))
|
||||
msg = result.scalar_one_or_none()
|
||||
if not msg:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Pinned message not found')
|
||||
|
||||
if msg.is_active:
|
||||
raise HTTPException(
|
||||
status.HTTP_409_CONFLICT,
|
||||
'Cannot delete active pinned message. Deactivate it first.',
|
||||
)
|
||||
|
||||
await db.delete(msg)
|
||||
await db.commit()
|
||||
|
||||
logger.info('Admin deleted pinned message #', admin_id=admin.id, message_id=message_id)
|
||||
@@ -2,13 +2,21 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
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,
|
||||
@@ -21,45 +29,51 @@ from app.database.crud.promo_offer_template import (
|
||||
list_promo_offer_templates,
|
||||
update_promo_offer_template,
|
||||
)
|
||||
from app.database.crud.user import get_user_by_telegram_id
|
||||
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
|
||||
|
||||
router = APIRouter(prefix="/admin/promo-offers", tags=["Admin Promo Offers"])
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/promo-offers', tags=['Admin Promo Offers'])
|
||||
|
||||
|
||||
# ============== Schemas ==============
|
||||
|
||||
|
||||
class PromoOfferUserInfo(BaseModel):
|
||||
id: int
|
||||
telegram_id: int
|
||||
username: Optional[str] = None
|
||||
first_name: Optional[str] = None
|
||||
last_name: Optional[str] = None
|
||||
full_name: Optional[str] = None
|
||||
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: Optional[int] = None
|
||||
notification_type: Optional[str] = None
|
||||
discount_percent: Optional[int] = None
|
||||
bonus_amount_kopeks: Optional[int] = None
|
||||
expires_at: Optional[datetime] = None
|
||||
claimed_at: Optional[datetime] = None
|
||||
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: Optional[str] = None
|
||||
extra_data: Dict[str, Any] = Field(default_factory=dict)
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
user: Optional[PromoOfferUserInfo] = None
|
||||
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]
|
||||
items: list[PromoOfferResponse]
|
||||
total: int
|
||||
limit: int
|
||||
offset: int
|
||||
@@ -74,30 +88,30 @@ class PromoOfferTemplateResponse(BaseModel):
|
||||
valid_hours: int
|
||||
discount_percent: int
|
||||
bonus_amount_kopeks: int
|
||||
active_discount_hours: Optional[int] = None
|
||||
test_duration_hours: Optional[int] = None
|
||||
test_squad_uuids: List[str] = Field(default_factory=list)
|
||||
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: Optional[int] = None
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
created_by: int | None = None
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
|
||||
|
||||
class PromoOfferTemplateListResponse(BaseModel):
|
||||
items: List[PromoOfferTemplateResponse]
|
||||
items: list[PromoOfferTemplateResponse]
|
||||
|
||||
|
||||
class PromoOfferTemplateUpdateRequest(BaseModel):
|
||||
name: Optional[str] = None
|
||||
message_text: Optional[str] = None
|
||||
button_text: Optional[str] = None
|
||||
valid_hours: Optional[int] = Field(None, ge=1)
|
||||
discount_percent: Optional[int] = Field(None, ge=0)
|
||||
bonus_amount_kopeks: Optional[int] = Field(None, ge=0)
|
||||
active_discount_hours: Optional[int] = Field(None, ge=1)
|
||||
test_duration_hours: Optional[int] = Field(None, ge=1)
|
||||
test_squad_uuids: Optional[List[str]] = None
|
||||
is_active: Optional[bool] = None
|
||||
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):
|
||||
@@ -105,46 +119,53 @@ class PromoOfferBroadcastRequest(BaseModel):
|
||||
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: Optional[str] = None
|
||||
user_id: Optional[int] = None
|
||||
telegram_id: Optional[int] = None
|
||||
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: Optional[str] = None
|
||||
user_ids: list[int]
|
||||
target: str | None = None
|
||||
notifications_sent: int = 0
|
||||
notifications_failed: int = 0
|
||||
|
||||
|
||||
class PromoOfferLogOfferInfo(BaseModel):
|
||||
id: int
|
||||
notification_type: Optional[str] = None
|
||||
discount_percent: Optional[int] = None
|
||||
bonus_amount_kopeks: Optional[int] = None
|
||||
effect_type: Optional[str] = None
|
||||
expires_at: Optional[datetime] = None
|
||||
claimed_at: Optional[datetime] = None
|
||||
is_active: Optional[bool] = None
|
||||
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: Optional[int] = None
|
||||
offer_id: Optional[int] = None
|
||||
user_id: int | None = None
|
||||
offer_id: int | None = None
|
||||
action: str
|
||||
source: Optional[str] = None
|
||||
percent: Optional[int] = None
|
||||
effect_type: Optional[str] = None
|
||||
details: Dict[str, Any] = Field(default_factory=dict)
|
||||
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: Optional[PromoOfferUserInfo] = None
|
||||
offer: Optional[PromoOfferLogOfferInfo] = None
|
||||
user: PromoOfferUserInfo | None = None
|
||||
offer: PromoOfferLogOfferInfo | None = None
|
||||
|
||||
|
||||
class PromoOfferLogListResponse(BaseModel):
|
||||
items: List[PromoOfferLogResponse]
|
||||
items: list[PromoOfferLogResponse]
|
||||
total: int
|
||||
limit: int
|
||||
offset: int
|
||||
@@ -152,16 +173,18 @@ class PromoOfferLogListResponse(BaseModel):
|
||||
|
||||
# ============== Helpers ==============
|
||||
|
||||
def _serialize_user(user: Optional[User]) -> Optional[PromoOfferUserInfo]:
|
||||
|
||||
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),
|
||||
full_name=getattr(user, 'full_name', None),
|
||||
)
|
||||
|
||||
|
||||
@@ -180,7 +203,7 @@ def _serialize_offer(offer: DiscountOffer) -> PromoOfferResponse:
|
||||
extra_data=offer.extra_data or {},
|
||||
created_at=offer.created_at,
|
||||
updated_at=offer.updated_at,
|
||||
user=_serialize_user(getattr(offer, "user", None)),
|
||||
user=_serialize_user(getattr(offer, 'user', None)),
|
||||
)
|
||||
|
||||
|
||||
@@ -205,10 +228,10 @@ def _serialize_template(template: PromoOfferTemplate) -> PromoOfferTemplateRespo
|
||||
|
||||
|
||||
def _serialize_log(entry: PromoOfferLog) -> PromoOfferLogResponse:
|
||||
user_info = _serialize_user(getattr(entry, "user", None))
|
||||
user_info = _serialize_user(getattr(entry, 'user', None))
|
||||
|
||||
offer = getattr(entry, "offer", None)
|
||||
offer_info: Optional[PromoOfferLogOfferInfo] = None
|
||||
offer = getattr(entry, 'offer', None)
|
||||
offer_info: PromoOfferLogOfferInfo | None = None
|
||||
if offer:
|
||||
offer_info = PromoOfferLogOfferInfo(
|
||||
id=offer.id,
|
||||
@@ -238,15 +261,16 @@ def _serialize_log(entry: PromoOfferLog) -> PromoOfferLogResponse:
|
||||
|
||||
async def _resolve_target_users(db: AsyncSession, target: str) -> list[User]:
|
||||
normalized = target.strip().lower()
|
||||
if normalized.startswith("custom_"):
|
||||
criteria = normalized[len("custom_"):]
|
||||
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)
|
||||
|
||||
@router.get('/templates', response_model=PromoOfferTemplateListResponse)
|
||||
async def list_templates(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
@@ -258,12 +282,10 @@ async def list_templates(
|
||||
if not templates:
|
||||
templates = await ensure_default_templates(db, created_by=admin.id)
|
||||
|
||||
return PromoOfferTemplateListResponse(
|
||||
items=[_serialize_template(template) for template in templates]
|
||||
)
|
||||
return PromoOfferTemplateListResponse(items=[_serialize_template(template) for template in templates])
|
||||
|
||||
|
||||
@router.get("/templates/{template_id}", response_model=PromoOfferTemplateResponse)
|
||||
@router.get('/templates/{template_id}', response_model=PromoOfferTemplateResponse)
|
||||
async def get_template(
|
||||
template_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
@@ -272,11 +294,11 @@ async def get_template(
|
||||
"""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")
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Template not found')
|
||||
return _serialize_template(template)
|
||||
|
||||
|
||||
@router.patch("/templates/{template_id}", response_model=PromoOfferTemplateResponse)
|
||||
@router.patch('/templates/{template_id}', response_model=PromoOfferTemplateResponse)
|
||||
async def update_template(
|
||||
template_id: int,
|
||||
payload: PromoOfferTemplateUpdateRequest,
|
||||
@@ -286,7 +308,7 @@ async def update_template(
|
||||
"""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")
|
||||
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()]
|
||||
@@ -313,14 +335,15 @@ async def update_template(
|
||||
|
||||
# ============== Offer Endpoints ==============
|
||||
|
||||
@router.get("", response_model=PromoOfferListResponse)
|
||||
|
||||
@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: Optional[int] = Query(None, ge=1),
|
||||
is_active: Optional[bool] = Query(None),
|
||||
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(
|
||||
@@ -344,13 +367,134 @@ async def list_offers(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/broadcast", response_model=PromoOfferBroadcastResponse, status_code=status.HTTP_201_CREATED)
|
||||
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('Skipping promo notification for email-only user', user_id=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', telegram_id=user.telegram_id, exc=exc)
|
||||
return False
|
||||
except Exception as exc:
|
||||
logger.error('Error sending promo notification to user', telegram_id=user.telegram_id, exc=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."""
|
||||
"""Broadcast promo offer to users with optional Telegram notification."""
|
||||
recipients: dict[int, User] = {}
|
||||
|
||||
# Resolve target segment
|
||||
@@ -360,16 +504,28 @@ async def broadcast_offer(
|
||||
|
||||
# Resolve specific user
|
||||
target_user_id = payload.user_id
|
||||
user: Optional[User] = None
|
||||
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")
|
||||
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",
|
||||
'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
|
||||
|
||||
@@ -377,17 +533,19 @@ async def broadcast_offer(
|
||||
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")
|
||||
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",
|
||||
'No recipients: specify target or user',
|
||||
)
|
||||
|
||||
# Create offers for all recipients
|
||||
# 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,
|
||||
@@ -402,24 +560,57 @@ async def broadcast_offer(
|
||||
)
|
||||
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)
|
||||
|
||||
@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: Optional[int] = Query(None, ge=1),
|
||||
action: Optional[str] = Query(None, min_length=1),
|
||||
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(
|
||||
|
||||
@@ -2,13 +2,22 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Optional
|
||||
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,
|
||||
@@ -19,24 +28,17 @@ from app.database.crud.promocode import (
|
||||
get_promocodes_list,
|
||||
update_promocode,
|
||||
)
|
||||
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.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"])
|
||||
|
||||
router = APIRouter(prefix='/admin/promocodes', tags=['Admin Promocodes'])
|
||||
|
||||
|
||||
# ============== Schemas ==============
|
||||
|
||||
|
||||
class PromoCodeResponse(BaseModel):
|
||||
id: int
|
||||
code: str
|
||||
@@ -51,9 +53,9 @@ class PromoCodeResponse(BaseModel):
|
||||
is_valid: bool
|
||||
first_purchase_only: bool
|
||||
valid_from: datetime
|
||||
valid_until: Optional[datetime] = None
|
||||
promo_group_id: Optional[int] = None
|
||||
created_by: Optional[int] = None
|
||||
valid_until: datetime | None = None
|
||||
promo_group_id: int | None = None
|
||||
created_by: int | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
@@ -68,9 +70,9 @@ class PromoCodeListResponse(BaseModel):
|
||||
class PromoCodeRecentUse(BaseModel):
|
||||
id: int
|
||||
user_id: int
|
||||
user_username: Optional[str] = None
|
||||
user_full_name: Optional[str] = None
|
||||
user_telegram_id: Optional[int] = None
|
||||
user_username: str | None = None
|
||||
user_full_name: str | None = None
|
||||
user_telegram_id: int | None = None
|
||||
used_at: datetime
|
||||
|
||||
|
||||
@@ -86,28 +88,29 @@ class PromoCodeCreateRequest(BaseModel):
|
||||
balance_bonus_kopeks: int = 0
|
||||
subscription_days: int = 0
|
||||
max_uses: int = Field(default=1, ge=0)
|
||||
valid_from: Optional[datetime] = None
|
||||
valid_until: Optional[datetime] = None
|
||||
valid_from: datetime | None = None
|
||||
valid_until: datetime | None = None
|
||||
is_active: bool = True
|
||||
first_purchase_only: bool = False
|
||||
promo_group_id: Optional[int] = None
|
||||
promo_group_id: int | None = None
|
||||
|
||||
|
||||
class PromoCodeUpdateRequest(BaseModel):
|
||||
code: Optional[str] = Field(default=None, min_length=1, max_length=50)
|
||||
type: Optional[PromoCodeType] = None
|
||||
balance_bonus_kopeks: Optional[int] = None
|
||||
subscription_days: Optional[int] = None
|
||||
max_uses: Optional[int] = Field(default=None, ge=0)
|
||||
valid_from: Optional[datetime] = None
|
||||
valid_until: Optional[datetime] = None
|
||||
is_active: Optional[bool] = None
|
||||
first_purchase_only: Optional[bool] = None
|
||||
promo_group_id: Optional[int] = None
|
||||
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
|
||||
@@ -115,12 +118,12 @@ class PromoGroupResponse(BaseModel):
|
||||
traffic_discount_percent: int
|
||||
device_discount_percent: int
|
||||
period_discounts: dict[int, int] = Field(default_factory=dict)
|
||||
auto_assign_total_spent_kopeks: Optional[int] = None
|
||||
auto_assign_total_spent_kopeks: int | None = None
|
||||
apply_discounts_to_addons: bool
|
||||
is_default: bool
|
||||
members_count: int = 0
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
|
||||
|
||||
class PromoGroupListResponse(BaseModel):
|
||||
@@ -135,32 +138,33 @@ class PromoGroupCreateRequest(BaseModel):
|
||||
server_discount_percent: int = 0
|
||||
traffic_discount_percent: int = 0
|
||||
device_discount_percent: int = 0
|
||||
period_discounts: Optional[dict[int, int]] = None
|
||||
auto_assign_total_spent_kopeks: Optional[int] = None
|
||||
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: Optional[str] = None
|
||||
server_discount_percent: Optional[int] = None
|
||||
traffic_discount_percent: Optional[int] = None
|
||||
device_discount_percent: Optional[int] = None
|
||||
period_discounts: Optional[dict[int, int]] = None
|
||||
auto_assign_total_spent_kopeks: Optional[int] = None
|
||||
apply_discounts_to_addons: Optional[bool] = None
|
||||
is_default: Optional[bool] = None
|
||||
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: Optional[datetime]) -> Optional[datetime]:
|
||||
|
||||
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(timezone.utc).replace(tzinfo=None)
|
||||
return value.astimezone(UTC)
|
||||
if value.tzinfo is not None:
|
||||
return value.replace(tzinfo=None)
|
||||
return value
|
||||
return value
|
||||
|
||||
|
||||
@@ -192,9 +196,9 @@ 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),
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -223,54 +227,41 @@ def _serialize_promo_group(group: PromoGroup, members_count: int = 0) -> PromoGr
|
||||
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),
|
||||
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")
|
||||
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"
|
||||
)
|
||||
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"
|
||||
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"
|
||||
)
|
||||
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"
|
||||
)
|
||||
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"
|
||||
)
|
||||
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")
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Code must not be empty')
|
||||
|
||||
if payload.type is not None:
|
||||
new_type = payload.type
|
||||
@@ -278,74 +269,47 @@ def _validate_update_payload(payload: PromoCodeUpdateRequest, promocode: PromoCo
|
||||
new_type = PromoCodeType(promocode.type)
|
||||
|
||||
balance_bonus = (
|
||||
payload.balance_bonus_kopeks
|
||||
if payload.balance_bonus_kopeks is not None
|
||||
else promocode.balance_bonus_kopeks
|
||||
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
|
||||
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"
|
||||
)
|
||||
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"
|
||||
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"
|
||||
)
|
||||
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"
|
||||
)
|
||||
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
|
||||
)
|
||||
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"
|
||||
)
|
||||
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"
|
||||
)
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, 'max_uses cannot be less than current uses')
|
||||
|
||||
|
||||
# ============== Promocode Endpoints ==============
|
||||
|
||||
@router.get("", response_model=PromoCodeListResponse)
|
||||
|
||||
@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: Optional[bool] = Query(default=None),
|
||||
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
|
||||
@@ -359,7 +323,7 @@ async def list_promocodes(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{promocode_id}", response_model=PromoCodeDetailResponse)
|
||||
@router.get('/{promocode_id}', response_model=PromoCodeDetailResponse)
|
||||
async def get_promocode(
|
||||
promocode_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
@@ -368,24 +332,21 @@ async def get_promocode(
|
||||
"""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")
|
||||
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", [])
|
||||
]
|
||||
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),
|
||||
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)
|
||||
@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),
|
||||
@@ -400,10 +361,10 @@ async def create_promocode_endpoint(
|
||||
|
||||
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"
|
||||
)
|
||||
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,
|
||||
@@ -411,22 +372,22 @@ async def create_promocode_endpoint(
|
||||
type=payload.type,
|
||||
balance_bonus_kopeks=payload.balance_bonus_kopeks,
|
||||
subscription_days=payload.subscription_days,
|
||||
max_uses=payload.max_uses,
|
||||
max_uses=effective_max_uses,
|
||||
valid_until=normalized_valid_until,
|
||||
created_by=admin.id,
|
||||
)
|
||||
|
||||
update_fields = {}
|
||||
if normalized_valid_from is not None:
|
||||
update_fields["valid_from"] = normalized_valid_from
|
||||
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
|
||||
update_fields['is_active'] = payload.is_active
|
||||
if normalized_valid_until is not None:
|
||||
update_fields["valid_until"] = normalized_valid_until
|
||||
update_fields['valid_until'] = normalized_valid_until
|
||||
if payload.first_purchase_only:
|
||||
update_fields["first_purchase_only"] = 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
|
||||
update_fields['promo_group_id'] = payload.promo_group_id
|
||||
|
||||
if update_fields:
|
||||
promocode = await update_promocode(db, promocode, **update_fields)
|
||||
@@ -434,7 +395,7 @@ async def create_promocode_endpoint(
|
||||
return _serialize_promocode(promocode)
|
||||
|
||||
|
||||
@router.patch("/{promocode_id}", response_model=PromoCodeResponse)
|
||||
@router.patch('/{promocode_id}', response_model=PromoCodeResponse)
|
||||
async def update_promocode_endpoint(
|
||||
promocode_id: int,
|
||||
payload: PromoCodeUpdateRequest,
|
||||
@@ -444,7 +405,7 @@ async def update_promocode_endpoint(
|
||||
"""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")
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Promo code not found')
|
||||
|
||||
_validate_update_payload(payload, promocode)
|
||||
|
||||
@@ -455,38 +416,35 @@ async def update_promocode_endpoint(
|
||||
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
|
||||
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
|
||||
updates['type'] = payload.type.value
|
||||
|
||||
if payload.balance_bonus_kopeks is not None:
|
||||
updates["balance_bonus_kopeks"] = payload.balance_bonus_kopeks
|
||||
updates['balance_bonus_kopeks'] = payload.balance_bonus_kopeks
|
||||
|
||||
if payload.subscription_days is not None:
|
||||
updates["subscription_days"] = payload.subscription_days
|
||||
updates['subscription_days'] = payload.subscription_days
|
||||
|
||||
if payload.max_uses is not None:
|
||||
updates["max_uses"] = payload.max_uses
|
||||
updates['max_uses'] = 999999 if payload.max_uses == 0 else payload.max_uses
|
||||
|
||||
if payload.valid_from is not None:
|
||||
updates["valid_from"] = _normalize_datetime(payload.valid_from)
|
||||
updates['valid_from'] = _normalize_datetime(payload.valid_from)
|
||||
|
||||
if payload.valid_until is not None:
|
||||
updates["valid_until"] = _normalize_datetime(payload.valid_until)
|
||||
updates['valid_until'] = _normalize_datetime(payload.valid_until)
|
||||
|
||||
if payload.is_active is not None:
|
||||
updates["is_active"] = payload.is_active
|
||||
updates['is_active'] = payload.is_active
|
||||
|
||||
if payload.first_purchase_only is not None:
|
||||
updates["first_purchase_only"] = payload.first_purchase_only
|
||||
updates['first_purchase_only'] = payload.first_purchase_only
|
||||
|
||||
if payload.promo_group_id is not None:
|
||||
updates["promo_group_id"] = payload.promo_group_id
|
||||
updates['promo_group_id'] = payload.promo_group_id
|
||||
|
||||
if not updates:
|
||||
return _serialize_promocode(promocode)
|
||||
@@ -496,7 +454,7 @@ async def update_promocode_endpoint(
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{promocode_id}",
|
||||
'/{promocode_id}',
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
response_class=Response,
|
||||
)
|
||||
@@ -508,21 +466,76 @@ async def delete_promocode_endpoint(
|
||||
"""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")
|
||||
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")
|
||||
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 = APIRouter(prefix='/admin/promo-groups', tags=['Admin Promo Groups'])
|
||||
|
||||
|
||||
@promo_groups_router.get("", response_model=PromoGroupListResponse)
|
||||
@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),
|
||||
@@ -545,7 +558,7 @@ async def list_promo_groups(
|
||||
)
|
||||
|
||||
|
||||
@promo_groups_router.get("/{group_id}", response_model=PromoGroupResponse)
|
||||
@promo_groups_router.get('/{group_id}', response_model=PromoGroupResponse)
|
||||
async def get_promo_group(
|
||||
group_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
@@ -554,13 +567,13 @@ async def get_promo_group(
|
||||
"""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")
|
||||
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)
|
||||
@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),
|
||||
@@ -585,13 +598,13 @@ async def create_promo_group_endpoint(
|
||||
await db.rollback()
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
"Promo group with this name already exists",
|
||||
'Promo group with this name already exists',
|
||||
)
|
||||
|
||||
return _serialize_promo_group(group, members_count=0)
|
||||
|
||||
|
||||
@promo_groups_router.patch("/{group_id}", response_model=PromoGroupResponse)
|
||||
@promo_groups_router.patch('/{group_id}', response_model=PromoGroupResponse)
|
||||
async def update_promo_group_endpoint(
|
||||
group_id: int,
|
||||
payload: PromoGroupUpdateRequest,
|
||||
@@ -603,7 +616,7 @@ async def update_promo_group_endpoint(
|
||||
|
||||
group = await get_promo_group_by_id(db, group_id)
|
||||
if not group:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Promo group not found")
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Promo group not found')
|
||||
|
||||
try:
|
||||
group = await update_promo_group(
|
||||
@@ -622,14 +635,14 @@ async def update_promo_group_endpoint(
|
||||
await db.rollback()
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
"Promo group with this name already exists",
|
||||
'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)
|
||||
@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),
|
||||
@@ -638,13 +651,10 @@ async def delete_promo_group_endpoint(
|
||||
"""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")
|
||||
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"
|
||||
)
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Cannot delete default promo group')
|
||||
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,43 +1,41 @@
|
||||
"""Admin routes for managing servers in cabinet."""
|
||||
|
||||
import logging
|
||||
from typing import List
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import String, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, String
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import User, ServerSquad, Subscription, Tariff, PromoGroup
|
||||
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,
|
||||
sync_with_remnawave,
|
||||
count_active_users_for_squad,
|
||||
)
|
||||
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 (
|
||||
ServerListResponse,
|
||||
ServerListItem,
|
||||
PromoGroupInfo,
|
||||
ServerDetailResponse,
|
||||
ServerUpdateRequest,
|
||||
ServerToggleResponse,
|
||||
ServerTrialToggleResponse,
|
||||
ServerListItem,
|
||||
ServerListResponse,
|
||||
ServerStatsResponse,
|
||||
ServerSyncResponse,
|
||||
PromoGroupInfo,
|
||||
ServerToggleResponse,
|
||||
ServerTrialToggleResponse,
|
||||
ServerUpdateRequest,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/admin/servers", tags=["Cabinet Admin Servers"])
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/servers', tags=['Cabinet Admin Servers'])
|
||||
|
||||
|
||||
async def _get_server_promo_groups(db: AsyncSession, server: ServerSquad) -> List[PromoGroupInfo]:
|
||||
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()
|
||||
@@ -54,7 +52,7 @@ async def _get_server_promo_groups(db: AsyncSession, server: ServerSquad) -> Lis
|
||||
]
|
||||
|
||||
|
||||
async def _get_tariffs_using_server(db: AsyncSession, squad_uuid: str) -> List[str]:
|
||||
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))
|
||||
@@ -65,7 +63,7 @@ async def _get_tariffs_using_server(db: AsyncSession, squad_uuid: str) -> List[s
|
||||
return tariff_names
|
||||
|
||||
|
||||
@router.get("", response_model=ServerListResponse)
|
||||
@router.get('', response_model=ServerListResponse)
|
||||
async def list_servers(
|
||||
include_unavailable: bool = True,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
@@ -79,28 +77,30 @@ async def list_servers(
|
||||
|
||||
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,
|
||||
))
|
||||
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)
|
||||
@router.get('/{server_id}', response_model=ServerDetailResponse)
|
||||
async def get_server(
|
||||
server_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
@@ -111,7 +111,7 @@ async def get_server(
|
||||
if not server:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Server not found",
|
||||
detail='Server not found',
|
||||
)
|
||||
|
||||
promo_groups = await _get_server_promo_groups(db, server)
|
||||
@@ -142,7 +142,7 @@ async def get_server(
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{server_id}", response_model=ServerDetailResponse)
|
||||
@router.put('/{server_id}', response_model=ServerDetailResponse)
|
||||
async def update_existing_server(
|
||||
server_id: int,
|
||||
request: ServerUpdateRequest,
|
||||
@@ -154,27 +154,27 @@ async def update_existing_server(
|
||||
if not server:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Server not found",
|
||||
detail='Server not found',
|
||||
)
|
||||
|
||||
# Build updates dict
|
||||
updates = {}
|
||||
if request.display_name is not None:
|
||||
updates["display_name"] = request.display_name
|
||||
updates['display_name'] = request.display_name
|
||||
if request.description is not None:
|
||||
updates["description"] = request.description
|
||||
updates['description'] = request.description
|
||||
if request.country_code is not None:
|
||||
updates["country_code"] = request.country_code
|
||||
updates['country_code'] = request.country_code
|
||||
if request.is_available is not None:
|
||||
updates["is_available"] = request.is_available
|
||||
updates['is_available'] = request.is_available
|
||||
if request.is_trial_eligible is not None:
|
||||
updates["is_trial_eligible"] = request.is_trial_eligible
|
||||
updates['is_trial_eligible'] = request.is_trial_eligible
|
||||
if request.price_kopeks is not None:
|
||||
updates["price_kopeks"] = request.price_kopeks
|
||||
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
|
||||
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
|
||||
updates['sort_order'] = request.sort_order
|
||||
|
||||
if updates:
|
||||
await update_server_squad(db, server_id, **updates)
|
||||
@@ -183,12 +183,12 @@ async def update_existing_server(
|
||||
if request.promo_group_ids is not None:
|
||||
await update_server_squad_promo_groups(db, server_id, request.promo_group_ids)
|
||||
|
||||
logger.info(f"Admin {admin.id} updated server {server_id}")
|
||||
logger.info('Admin updated server', admin_id=admin.id, server_id=server_id)
|
||||
|
||||
return await get_server(server_id, admin, db)
|
||||
|
||||
|
||||
@router.post("/{server_id}/toggle", response_model=ServerToggleResponse)
|
||||
@router.post('/{server_id}/toggle', response_model=ServerToggleResponse)
|
||||
async def toggle_server(
|
||||
server_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
@@ -199,23 +199,23 @@ async def toggle_server(
|
||||
if not server:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Server 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}")
|
||||
status_text = 'enabled' if new_status else 'disabled'
|
||||
logger.info('Admin server', admin_id=admin.id, status_text=status_text, server_id=server_id)
|
||||
|
||||
return ServerToggleResponse(
|
||||
id=server_id,
|
||||
is_available=new_status,
|
||||
message=f"Server {status_text}",
|
||||
message=f'Server {status_text}',
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{server_id}/trial", response_model=ServerTrialToggleResponse)
|
||||
@router.post('/{server_id}/trial', response_model=ServerTrialToggleResponse)
|
||||
async def toggle_server_trial(
|
||||
server_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
@@ -226,23 +226,23 @@ async def toggle_server_trial(
|
||||
if not server:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Server 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}")
|
||||
status_text = 'enabled for trial' if new_status else 'disabled for trial'
|
||||
logger.info('Admin server', admin_id=admin.id, status_text=status_text, server_id=server_id)
|
||||
|
||||
return ServerTrialToggleResponse(
|
||||
id=server_id,
|
||||
is_trial_eligible=new_status,
|
||||
message=f"Server {status_text}",
|
||||
message=f'Server {status_text}',
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{server_id}/stats", response_model=ServerStatsResponse)
|
||||
@router.get('/{server_id}/stats', response_model=ServerStatsResponse)
|
||||
async def get_server_stats(
|
||||
server_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
@@ -253,7 +253,7 @@ async def get_server_stats(
|
||||
if not server:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Server not found",
|
||||
detail='Server not found',
|
||||
)
|
||||
|
||||
active_subs = await count_active_users_for_squad(db, server.squad_uuid)
|
||||
@@ -261,10 +261,9 @@ async def get_server_stats(
|
||||
# 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(
|
||||
select(func.count(Subscription.id)).where(
|
||||
Subscription.is_trial == True,
|
||||
Subscription.status == "active",
|
||||
Subscription.status == 'active',
|
||||
func.cast(Subscription.connected_squads, String).like(f'%"{server.squad_uuid}"%'),
|
||||
)
|
||||
)
|
||||
@@ -286,7 +285,7 @@ async def get_server_stats(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/sync", response_model=ServerSyncResponse)
|
||||
@router.post('/sync', response_model=ServerSyncResponse)
|
||||
async def sync_servers(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
@@ -297,7 +296,7 @@ async def sync_servers(
|
||||
if not subscription_service.is_configured:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="RemnaWave is not configured",
|
||||
detail='RemnaWave is not configured',
|
||||
)
|
||||
|
||||
# Get squads from RemnaWave
|
||||
@@ -305,26 +304,26 @@ async def sync_servers(
|
||||
if squads is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to fetch squads from RemnaWave",
|
||||
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}")
|
||||
logger.info('Admin synced servers: + ~', admin_id=admin.id, created=created, updated=updated, removed=removed)
|
||||
|
||||
return ServerSyncResponse(
|
||||
created=created,
|
||||
updated=updated,
|
||||
removed=removed,
|
||||
message=f"Synced: {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}")
|
||||
logger.error('Failed to sync servers', error=e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Sync failed: {str(e)}",
|
||||
detail=f'Sync failed: {e!s}',
|
||||
)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"""Admin settings routes for cabinet - system configuration management."""
|
||||
|
||||
import logging
|
||||
from typing import Any, Optional, List
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -15,44 +15,51 @@ from app.services.system_settings_service import (
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/admin/settings", tags=["Admin Settings"])
|
||||
logger = structlog.get_logger(__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 = ""
|
||||
description: str = ''
|
||||
items: int
|
||||
|
||||
|
||||
class SettingChoice(BaseModel):
|
||||
"""Choice option for setting."""
|
||||
|
||||
value: Any
|
||||
label: str
|
||||
description: Optional[str] = None
|
||||
description: str | None = None
|
||||
|
||||
|
||||
class SettingHint(BaseModel):
|
||||
"""Setting hints and guidance."""
|
||||
description: str = ""
|
||||
format: str = ""
|
||||
example: str = ""
|
||||
warning: str = ""
|
||||
|
||||
description: str = ''
|
||||
format: str = ''
|
||||
example: str = ''
|
||||
warning: str = ''
|
||||
|
||||
|
||||
class SettingDefinition(BaseModel):
|
||||
"""Full setting definition with current state."""
|
||||
|
||||
key: str
|
||||
name: str
|
||||
category: SettingCategoryRef
|
||||
@@ -62,17 +69,19 @@ class SettingDefinition(BaseModel):
|
||||
original: Any = Field(default=None)
|
||||
has_override: bool
|
||||
read_only: bool = Field(default=False)
|
||||
choices: List[SettingChoice] = Field(default_factory=list)
|
||||
hint: Optional[SettingHint] = None
|
||||
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)
|
||||
@@ -80,7 +89,7 @@ def _coerce_value(key: str, value: Any) -> Any:
|
||||
if value is None:
|
||||
if definition.is_optional:
|
||||
return None
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Value is required")
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Value is required')
|
||||
|
||||
python_type = definition.python_type
|
||||
|
||||
@@ -90,14 +99,14 @@ def _coerce_value(key: str, value: Any) -> Any:
|
||||
normalized = value
|
||||
elif isinstance(value, str):
|
||||
lowered = value.strip().lower()
|
||||
if lowered in {"true", "1", "yes", "on", "да"}:
|
||||
if lowered in {'true', '1', 'yes', 'on', 'да'}:
|
||||
normalized = True
|
||||
elif lowered in {"false", "0", "no", "off", "нет"}:
|
||||
elif lowered in {'false', '0', 'no', 'off', 'нет'}:
|
||||
normalized = False
|
||||
else:
|
||||
raise ValueError("invalid bool")
|
||||
raise ValueError('invalid bool')
|
||||
else:
|
||||
raise ValueError("invalid bool")
|
||||
raise ValueError('invalid bool')
|
||||
|
||||
elif python_type is int:
|
||||
normalized = int(value)
|
||||
@@ -106,16 +115,16 @@ def _coerce_value(key: str, value: Any) -> Any:
|
||||
else:
|
||||
normalized = str(value)
|
||||
except ValueError:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Invalid value type") from None
|
||||
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)
|
||||
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}",
|
||||
detail=f'Value must be one of: {readable}',
|
||||
)
|
||||
|
||||
return normalized
|
||||
@@ -127,7 +136,7 @@ def _serialize_definition(definition, include_choices: bool = True) -> SettingDe
|
||||
original = bot_configuration_service.get_original_value(definition.key)
|
||||
has_override = bot_configuration_service.has_override(definition.key)
|
||||
|
||||
choices: List[SettingChoice] = []
|
||||
choices: list[SettingChoice] = []
|
||||
if include_choices:
|
||||
choices = [
|
||||
SettingChoice(
|
||||
@@ -141,10 +150,10 @@ def _serialize_definition(definition, include_choices: bool = True) -> SettingDe
|
||||
# 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", ""),
|
||||
description=guidance.get('description', ''),
|
||||
format=guidance.get('format', ''),
|
||||
example=guidance.get('example', ''),
|
||||
warning=guidance.get('warning', ''),
|
||||
)
|
||||
|
||||
return SettingDefinition(
|
||||
@@ -167,7 +176,8 @@ def _serialize_definition(definition, include_choices: bool = True) -> SettingDe
|
||||
|
||||
# ============ Routes ============
|
||||
|
||||
@router.get("/categories", response_model=List[SettingCategorySummary])
|
||||
|
||||
@router.get('/categories', response_model=list[SettingCategorySummary])
|
||||
async def list_categories(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
):
|
||||
@@ -184,13 +194,13 @@ async def list_categories(
|
||||
]
|
||||
|
||||
|
||||
@router.get("", response_model=List[SettingDefinition])
|
||||
@router.get('', response_model=list[SettingDefinition])
|
||||
async def list_settings(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
category: Optional[str] = Query(default=None, alias="category_key"),
|
||||
category: str | None = Query(default=None, alias='category_key'),
|
||||
):
|
||||
"""Get list of all settings or settings for a specific category."""
|
||||
items: List[SettingDefinition] = []
|
||||
items: list[SettingDefinition] = []
|
||||
|
||||
if category:
|
||||
definitions = bot_configuration_service.get_settings_for_category(category)
|
||||
@@ -204,7 +214,7 @@ async def list_settings(
|
||||
return items
|
||||
|
||||
|
||||
@router.get("/{key}", response_model=SettingDefinition)
|
||||
@router.get('/{key}', response_model=SettingDefinition)
|
||||
async def get_setting(
|
||||
key: str,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
@@ -213,12 +223,12 @@ async def get_setting(
|
||||
try:
|
||||
definition = bot_configuration_service.get_definition(key)
|
||||
except KeyError as error:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Setting not found") from error
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Setting not found') from error
|
||||
|
||||
return _serialize_definition(definition)
|
||||
|
||||
|
||||
@router.put("/{key}", response_model=SettingDefinition)
|
||||
@router.put('/{key}', response_model=SettingDefinition)
|
||||
async def update_setting(
|
||||
key: str,
|
||||
payload: SettingUpdateRequest,
|
||||
@@ -229,7 +239,7 @@ async def update_setting(
|
||||
try:
|
||||
definition = bot_configuration_service.get_definition(key)
|
||||
except KeyError as error:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Setting not found") from error
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Setting not found') from error
|
||||
|
||||
value = _coerce_value(key, payload.value)
|
||||
try:
|
||||
@@ -238,11 +248,11 @@ async def update_setting(
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, str(error)) from error
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"Admin {admin.telegram_id} updated setting {key} to {value}")
|
||||
logger.info('Admin updated setting to', telegram_id=admin.telegram_id, key=key, value=value)
|
||||
return _serialize_definition(definition)
|
||||
|
||||
|
||||
@router.delete("/{key}", response_model=SettingDefinition)
|
||||
@router.delete('/{key}', response_model=SettingDefinition)
|
||||
async def reset_setting(
|
||||
key: str,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
@@ -252,7 +262,7 @@ async def reset_setting(
|
||||
try:
|
||||
definition = bot_configuration_service.get_definition(key)
|
||||
except KeyError as error:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Setting not found") from error
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Setting not found') from error
|
||||
|
||||
try:
|
||||
await bot_configuration_service.reset_value(db, key)
|
||||
@@ -260,5 +270,5 @@ async def reset_setting(
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, str(error)) from error
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"Admin {admin.telegram_id} reset setting {key}")
|
||||
logger.info('Admin reset setting', telegram_id=admin.telegram_id, key=key)
|
||||
return _serialize_definition(definition)
|
||||
|
||||
+361
-334
File diff suppressed because it is too large
Load Diff
+141
-129
@@ -1,51 +1,50 @@
|
||||
"""Admin routes for managing tariffs in cabinet."""
|
||||
|
||||
import logging
|
||||
from typing import List, Optional
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
|
||||
from app.database.models import User, Tariff, Subscription, ServerSquad, PromoGroup, Transaction, TransactionType
|
||||
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,
|
||||
create_tariff,
|
||||
update_tariff,
|
||||
delete_tariff,
|
||||
get_tariff_subscriptions_count,
|
||||
set_tariff_promo_groups,
|
||||
load_period_prices_from_db,
|
||||
reorder_tariffs,
|
||||
set_tariff_promo_groups,
|
||||
update_tariff,
|
||||
)
|
||||
from app.database.crud.server_squad import get_all_server_squads
|
||||
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 (
|
||||
TariffListResponse,
|
||||
TariffListItem,
|
||||
TariffDetailResponse,
|
||||
PeriodPrice,
|
||||
PromoGroupInfo,
|
||||
ServerInfo,
|
||||
ServerTrafficLimit,
|
||||
TariffCreateRequest,
|
||||
TariffUpdateRequest,
|
||||
TariffDetailResponse,
|
||||
TariffListItem,
|
||||
TariffListResponse,
|
||||
TariffSortOrderRequest,
|
||||
TariffStatsResponse,
|
||||
TariffToggleResponse,
|
||||
TariffTrialResponse,
|
||||
TariffStatsResponse,
|
||||
PeriodPrice,
|
||||
ServerInfo,
|
||||
PromoGroupInfo,
|
||||
ServerTrafficLimit,
|
||||
TariffUpdateRequest,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/admin/tariffs", tags=["Cabinet Admin Tariffs"])
|
||||
logger = structlog.get_logger(__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]:
|
||||
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 {}
|
||||
@@ -60,18 +59,20 @@ async def _get_tariff_servers(
|
||||
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,
|
||||
))
|
||||
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]:
|
||||
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()
|
||||
@@ -88,7 +89,7 @@ async def _get_tariff_promo_groups(db: AsyncSession, tariff: Tariff) -> List[Pro
|
||||
]
|
||||
|
||||
|
||||
def _period_prices_to_list(period_prices: dict) -> List[PeriodPrice]:
|
||||
def _period_prices_to_list(period_prices: dict) -> list[PeriodPrice]:
|
||||
"""Convert period_prices dict to list."""
|
||||
if not period_prices:
|
||||
return []
|
||||
@@ -98,12 +99,12 @@ def _period_prices_to_list(period_prices: dict) -> List[PeriodPrice]:
|
||||
]
|
||||
|
||||
|
||||
def _period_prices_to_dict(period_prices: List[PeriodPrice]) -> dict:
|
||||
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)
|
||||
@router.get('', response_model=TariffListResponse)
|
||||
async def list_tariffs(
|
||||
include_inactive: bool = True,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
@@ -115,28 +116,30 @@ async def list_tariffs(
|
||||
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,
|
||||
))
|
||||
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])
|
||||
@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),
|
||||
@@ -155,7 +158,22 @@ async def get_available_servers(
|
||||
]
|
||||
|
||||
|
||||
@router.get("/{tariff_id}", response_model=TariffDetailResponse)
|
||||
@router.put('/order')
|
||||
async def update_tariff_order(
|
||||
request: TariffSortOrderRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update the display order of tariffs."""
|
||||
await reorder_tariffs(db, request.tariff_ids)
|
||||
await db.commit()
|
||||
|
||||
logger.info('Admin updated tariff order', admin_id=admin.id, tariff_ids=request.tariff_ids)
|
||||
|
||||
return {'message': 'Tariff order updated successfully'}
|
||||
|
||||
|
||||
@router.get('/{tariff_id}', response_model=TariffDetailResponse)
|
||||
async def get_tariff(
|
||||
tariff_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
@@ -166,7 +184,7 @@ async def get_tariff(
|
||||
if not tariff:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Tariff not found",
|
||||
detail='Tariff not found',
|
||||
)
|
||||
|
||||
allowed_squads = tariff.allowed_squads or []
|
||||
@@ -225,7 +243,7 @@ async def get_tariff(
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=TariffDetailResponse)
|
||||
@router.post('', response_model=TariffDetailResponse)
|
||||
async def create_new_tariff(
|
||||
request: TariffCreateRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
@@ -235,9 +253,11 @@ async def create_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 {}
|
||||
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,
|
||||
@@ -274,7 +294,7 @@ async def create_new_tariff(
|
||||
traffic_reset_mode=request.traffic_reset_mode,
|
||||
)
|
||||
|
||||
logger.info(f"Admin {admin.id} created tariff {tariff.id}: {tariff.name}")
|
||||
logger.info('Admin created tariff', admin_id=admin.id, tariff_id=tariff.id, tariff_name=tariff.name)
|
||||
|
||||
# Перезагружаем периоды из БД для синхронизации с ботом
|
||||
await load_period_prices_from_db(db)
|
||||
@@ -283,7 +303,7 @@ async def create_new_tariff(
|
||||
return await get_tariff(tariff.id, admin, db)
|
||||
|
||||
|
||||
@router.put("/{tariff_id}", response_model=TariffDetailResponse)
|
||||
@router.put('/{tariff_id}', response_model=TariffDetailResponse)
|
||||
async def update_existing_tariff(
|
||||
tariff_id: int,
|
||||
request: TariffUpdateRequest,
|
||||
@@ -295,81 +315,81 @@ async def update_existing_tariff(
|
||||
if not tariff:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Tariff not found",
|
||||
detail='Tariff not found',
|
||||
)
|
||||
|
||||
# Build updates dict
|
||||
updates = {}
|
||||
if request.name is not None:
|
||||
updates["name"] = request.name
|
||||
updates['name'] = request.name
|
||||
if request.description is not None:
|
||||
updates["description"] = request.description
|
||||
updates['description'] = request.description
|
||||
if request.is_active is not None:
|
||||
updates["is_active"] = request.is_active
|
||||
updates['is_active'] = request.is_active
|
||||
if request.allow_traffic_topup is not None:
|
||||
updates["allow_traffic_topup"] = request.allow_traffic_topup
|
||||
updates['allow_traffic_topup'] = request.allow_traffic_topup
|
||||
if request.traffic_topup_enabled is not None:
|
||||
updates["traffic_topup_enabled"] = request.traffic_topup_enabled
|
||||
updates['traffic_topup_enabled'] = request.traffic_topup_enabled
|
||||
if request.traffic_topup_packages is not None:
|
||||
updates["traffic_topup_packages"] = request.traffic_topup_packages
|
||||
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
|
||||
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
|
||||
updates['traffic_limit_gb'] = request.traffic_limit_gb
|
||||
if request.device_limit is not None:
|
||||
updates["device_limit"] = request.device_limit
|
||||
updates['device_limit'] = request.device_limit
|
||||
if request.device_price_kopeks is not None:
|
||||
updates["device_price_kopeks"] = request.device_price_kopeks
|
||||
updates['device_price_kopeks'] = request.device_price_kopeks
|
||||
if request.max_device_limit is not None:
|
||||
updates["max_device_limit"] = request.max_device_limit
|
||||
updates['max_device_limit'] = request.max_device_limit
|
||||
if request.tier_level is not None:
|
||||
updates["tier_level"] = request.tier_level
|
||||
updates['tier_level'] = request.tier_level
|
||||
if request.display_order is not None:
|
||||
updates["display_order"] = request.display_order
|
||||
updates['display_order'] = request.display_order
|
||||
if request.period_prices is not None:
|
||||
updates["period_prices"] = _period_prices_to_dict(request.period_prices)
|
||||
updates['period_prices'] = _period_prices_to_dict(request.period_prices)
|
||||
if request.allowed_squads is not None:
|
||||
updates["allowed_squads"] = request.allowed_squads
|
||||
updates['allowed_squads'] = request.allowed_squads
|
||||
if request.server_traffic_limits is not None:
|
||||
# Преобразуем ServerTrafficLimit в dict для хранения
|
||||
updates["server_traffic_limits"] = {
|
||||
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
|
||||
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
|
||||
updates['price_per_day_kopeks'] = request.price_per_day_kopeks
|
||||
if request.min_days is not None:
|
||||
updates["min_days"] = request.min_days
|
||||
updates['min_days'] = request.min_days
|
||||
if request.max_days is not None:
|
||||
updates["max_days"] = request.max_days
|
||||
updates['max_days'] = request.max_days
|
||||
# Произвольный трафик при покупке
|
||||
if request.custom_traffic_enabled is not None:
|
||||
updates["custom_traffic_enabled"] = request.custom_traffic_enabled
|
||||
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
|
||||
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
|
||||
updates['min_traffic_gb'] = request.min_traffic_gb
|
||||
if request.max_traffic_gb is not None:
|
||||
updates["max_traffic_gb"] = request.max_traffic_gb
|
||||
updates['max_traffic_gb'] = request.max_traffic_gb
|
||||
# Дневной тариф
|
||||
if request.is_daily is not None:
|
||||
updates["is_daily"] = request.is_daily
|
||||
updates['is_daily'] = request.is_daily
|
||||
if request.daily_price_kopeks is not None:
|
||||
updates["daily_price_kopeks"] = request.daily_price_kopeks
|
||||
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
|
||||
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_id, request.promo_group_ids)
|
||||
await set_tariff_promo_groups(db, tariff, request.promo_group_ids)
|
||||
|
||||
logger.info(f"Admin {admin.id} updated tariff {tariff_id}")
|
||||
logger.info('Admin updated tariff', admin_id=admin.id, tariff_id=tariff_id)
|
||||
|
||||
# Перезагружаем периоды из БД для синхронизации с ботом
|
||||
await load_period_prices_from_db(db)
|
||||
@@ -377,7 +397,7 @@ async def update_existing_tariff(
|
||||
return await get_tariff(tariff_id, admin, db)
|
||||
|
||||
|
||||
@router.delete("/{tariff_id}")
|
||||
@router.delete('/{tariff_id}')
|
||||
async def delete_existing_tariff(
|
||||
tariff_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
@@ -388,27 +408,26 @@ async def delete_existing_tariff(
|
||||
if not tariff:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Tariff not found",
|
||||
detail='Tariff not found',
|
||||
)
|
||||
|
||||
# Check if tariff has subscriptions
|
||||
subs_count = await get_tariff_subscriptions_count(db, tariff_id)
|
||||
if subs_count > 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Cannot delete tariff with {subs_count} active subscriptions",
|
||||
)
|
||||
|
||||
await delete_tariff(db, tariff)
|
||||
logger.info(f"Admin {admin.id} deleted tariff {tariff_id}: {tariff.name}")
|
||||
logger.info(
|
||||
'Admin deleted tariff (affected subscriptions: )',
|
||||
admin_id=admin.id,
|
||||
tariff_id=tariff_id,
|
||||
tariff_name=tariff.name,
|
||||
subs_count=subs_count,
|
||||
)
|
||||
|
||||
# Перезагружаем периоды из БД для синхронизации с ботом
|
||||
await load_period_prices_from_db(db)
|
||||
|
||||
return {"message": "Tariff deleted successfully"}
|
||||
return {'message': 'Tariff deleted successfully', 'affected_subscriptions': subs_count}
|
||||
|
||||
|
||||
@router.post("/{tariff_id}/toggle", response_model=TariffToggleResponse)
|
||||
@router.post('/{tariff_id}/toggle', response_model=TariffToggleResponse)
|
||||
async def toggle_tariff(
|
||||
tariff_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
@@ -419,14 +438,14 @@ async def toggle_tariff(
|
||||
if not tariff:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Tariff 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}")
|
||||
status_text = 'activated' if new_status else 'deactivated'
|
||||
logger.info('Admin tariff', admin_id=admin.id, status_text=status_text, tariff_id=tariff_id)
|
||||
|
||||
# Перезагружаем периоды из БД для синхронизации с ботом
|
||||
await load_period_prices_from_db(db)
|
||||
@@ -434,11 +453,11 @@ async def toggle_tariff(
|
||||
return TariffToggleResponse(
|
||||
id=tariff_id,
|
||||
is_active=new_status,
|
||||
message=f"Tariff {status_text}",
|
||||
message=f'Tariff {status_text}',
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{tariff_id}/trial", response_model=TariffTrialResponse)
|
||||
@router.post('/{tariff_id}/trial', response_model=TariffTrialResponse)
|
||||
async def toggle_trial_tariff(
|
||||
tariff_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
@@ -453,7 +472,7 @@ async def toggle_trial_tariff(
|
||||
if not tariff:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Tariff not found",
|
||||
detail='Tariff not found',
|
||||
)
|
||||
|
||||
new_status = not tariff.is_trial_available
|
||||
@@ -461,26 +480,24 @@ async def toggle_trial_tariff(
|
||||
if new_status:
|
||||
# При включении триала - снимаем флаг со ВСЕХ тарифов, затем ставим на текущий
|
||||
# Это гарантирует, что триальным будет только один тариф
|
||||
await db.execute(
|
||||
Tariff.__table__.update().values(is_trial_available=False)
|
||||
)
|
||||
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}")
|
||||
status_text = 'set as trial' if new_status else 'removed from trial'
|
||||
logger.info('Admin tariff', admin_id=admin.id, status_text=status_text, tariff_id=tariff_id)
|
||||
|
||||
return TariffTrialResponse(
|
||||
id=tariff_id,
|
||||
is_trial_available=new_status,
|
||||
message=f"Tariff {status_text}",
|
||||
message=f'Tariff {status_text}',
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{tariff_id}/stats", response_model=TariffStatsResponse)
|
||||
@router.get('/{tariff_id}/stats', response_model=TariffStatsResponse)
|
||||
async def get_tariff_stats(
|
||||
tariff_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
@@ -491,30 +508,25 @@ async def get_tariff_stats(
|
||||
if not tariff:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Tariff 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_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(
|
||||
select(func.count(Subscription.id)).where(
|
||||
Subscription.tariff_id == tariff_id,
|
||||
Subscription.status == "active",
|
||||
Subscription.status == 'active',
|
||||
)
|
||||
)
|
||||
active_count = active_result.scalar() or 0
|
||||
|
||||
# Count trial subscriptions
|
||||
trial_result = await db.execute(
|
||||
select(func.count(Subscription.id))
|
||||
.where(
|
||||
select(func.count(Subscription.id)).where(
|
||||
Subscription.tariff_id == tariff_id,
|
||||
Subscription.is_trial == True,
|
||||
)
|
||||
@@ -523,7 +535,7 @@ async def get_tariff_stats(
|
||||
|
||||
# Calculate revenue from subscription payments for users on this tariff
|
||||
revenue_result = await db.execute(
|
||||
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0))
|
||||
select(func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0))
|
||||
.join(Subscription, Transaction.user_id == Subscription.user_id)
|
||||
.where(
|
||||
Subscription.tariff_id == tariff_id,
|
||||
|
||||
+215
-206
@@ -1,38 +1,40 @@
|
||||
"""Admin tickets routes for cabinet."""
|
||||
|
||||
import logging
|
||||
import math
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, desc
|
||||
from sqlalchemy.orm import selectinload
|
||||
import structlog
|
||||
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.database.models import User, Ticket, TicketMessage
|
||||
from app.database.crud.ticket import TicketCRUD, TicketMessageCRUD
|
||||
from app.database.crud.ticket_notification import TicketNotificationCRUD
|
||||
from app.config import settings
|
||||
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"])
|
||||
logger = structlog.get_logger(__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
|
||||
username: Optional[str] = None
|
||||
first_name: Optional[str] = None
|
||||
last_name: Optional[str] = None
|
||||
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
|
||||
@@ -40,16 +42,17 @@ class AdminTicketUserInfo(BaseModel):
|
||||
|
||||
class AdminTicketResponse(BaseModel):
|
||||
"""Ticket data for admin."""
|
||||
|
||||
id: int
|
||||
title: str
|
||||
status: str
|
||||
priority: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
closed_at: Optional[datetime] = None
|
||||
closed_at: datetime | None = None
|
||||
messages_count: int = 0
|
||||
user: Optional[AdminTicketUserInfo] = None
|
||||
last_message: Optional[TicketMessageResponse] = None
|
||||
user: AdminTicketUserInfo | None = None
|
||||
last_message: TicketMessageResponse | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -57,16 +60,17 @@ class AdminTicketResponse(BaseModel):
|
||||
|
||||
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: Optional[datetime] = None
|
||||
closed_at: datetime | None = None
|
||||
is_reply_blocked: bool = False
|
||||
user: Optional[AdminTicketUserInfo] = None
|
||||
messages: List[TicketMessageResponse] = []
|
||||
user: AdminTicketUserInfo | None = None
|
||||
messages: list[TicketMessageResponse] = []
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -74,7 +78,8 @@ class AdminTicketDetailResponse(BaseModel):
|
||||
|
||||
class AdminTicketListResponse(BaseModel):
|
||||
"""Paginated ticket list for admin."""
|
||||
items: List[AdminTicketResponse]
|
||||
|
||||
items: list[AdminTicketResponse]
|
||||
total: int
|
||||
page: int
|
||||
per_page: int
|
||||
@@ -83,21 +88,25 @@ class AdminTicketListResponse(BaseModel):
|
||||
|
||||
class AdminReplyRequest(BaseModel):
|
||||
"""Admin reply to ticket."""
|
||||
message: str = Field(..., min_length=1, max_length=4000, description="Reply message")
|
||||
|
||||
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")
|
||||
|
||||
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")
|
||||
|
||||
priority: str = Field(..., description='New priority: low, normal, high, urgent')
|
||||
|
||||
|
||||
class AdminStatsResponse(BaseModel):
|
||||
"""Ticket statistics for admin."""
|
||||
|
||||
total: int
|
||||
open: int
|
||||
pending: int
|
||||
@@ -107,6 +116,7 @@ class AdminStatsResponse(BaseModel):
|
||||
|
||||
class TicketSettingsResponse(BaseModel):
|
||||
"""Ticket system settings."""
|
||||
|
||||
sla_enabled: bool
|
||||
sla_minutes: int
|
||||
sla_check_interval_seconds: int
|
||||
@@ -119,21 +129,24 @@ class TicketSettingsResponse(BaseModel):
|
||||
|
||||
class TicketSettingsUpdateRequest(BaseModel):
|
||||
"""Update ticket settings."""
|
||||
sla_enabled: Optional[bool] = None
|
||||
sla_minutes: Optional[int] = Field(None, ge=1, le=1440, description="SLA time in minutes (1-1440)")
|
||||
sla_check_interval_seconds: Optional[int] = Field(None, ge=30, le=600, description="Check interval (30-600 seconds)")
|
||||
sla_reminder_cooldown_minutes: Optional[int] = Field(None, ge=1, le=120, description="Reminder cooldown (1-120 minutes)")
|
||||
support_system_mode: Optional[str] = Field(None, description="Support mode: tickets, contact, both")
|
||||
|
||||
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: Optional[bool] = Field(None, description="Enable user notifications in cabinet")
|
||||
cabinet_admin_notifications_enabled: Optional[bool] = Field(None, description="Enable admin notifications in cabinet")
|
||||
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 "",
|
||||
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,
|
||||
@@ -148,6 +161,7 @@ def _user_to_info(user: User) -> AdminTicketUserInfo:
|
||||
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,
|
||||
@@ -169,9 +183,9 @@ def _ticket_to_admin_response(ticket: Ticket, include_messages: bool = False) ->
|
||||
|
||||
return AdminTicketResponse(
|
||||
id=ticket.id,
|
||||
title=ticket.title or f"Ticket #{ticket.id}",
|
||||
title=ticket.title or f'Ticket #{ticket.id}',
|
||||
status=ticket.status,
|
||||
priority=ticket.priority or "normal",
|
||||
priority=ticket.priority or 'normal',
|
||||
created_at=ticket.created_at,
|
||||
updated_at=ticket.updated_at or ticket.created_at,
|
||||
closed_at=ticket.closed_at,
|
||||
@@ -181,7 +195,7 @@ def _ticket_to_admin_response(ticket: Ticket, include_messages: bool = False) ->
|
||||
)
|
||||
|
||||
|
||||
@router.get("/stats", response_model=AdminStatsResponse)
|
||||
@router.get('/stats', response_model=AdminStatsResponse)
|
||||
async def get_ticket_stats(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
@@ -193,36 +207,142 @@ async def get_ticket_stats(
|
||||
|
||||
# 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)
|
||||
)
|
||||
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),
|
||||
open=statuses.get('open', 0),
|
||||
pending=statuses.get('pending', 0),
|
||||
answered=statuses.get('answered', 0),
|
||||
closed=statuses.get('closed', 0),
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=AdminTicketListResponse)
|
||||
@router.get('/settings', response_model=TicketSettingsResponse)
|
||||
async def get_ticket_settings(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get ticket system settings."""
|
||||
from app.services.support_settings_service import SupportSettingsService
|
||||
|
||||
return TicketSettingsResponse(
|
||||
sla_enabled=settings.SUPPORT_TICKET_SLA_ENABLED,
|
||||
sla_minutes=settings.SUPPORT_TICKET_SLA_MINUTES,
|
||||
sla_check_interval_seconds=settings.SUPPORT_TICKET_SLA_CHECK_INTERVAL_SECONDS,
|
||||
sla_reminder_cooldown_minutes=settings.SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES,
|
||||
support_system_mode=settings.get_support_system_mode(),
|
||||
cabinet_user_notifications_enabled=SupportSettingsService.get_cabinet_user_notifications_enabled(),
|
||||
cabinet_admin_notifications_enabled=SupportSettingsService.get_cabinet_admin_notifications_enabled(),
|
||||
)
|
||||
|
||||
|
||||
@router.patch('/settings', response_model=TicketSettingsResponse)
|
||||
async def update_ticket_settings(
|
||||
request: TicketSettingsUpdateRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update ticket system settings."""
|
||||
from pathlib import Path
|
||||
|
||||
from app.services.support_settings_service import SupportSettingsService
|
||||
|
||||
# Validate support_system_mode
|
||||
if request.support_system_mode is not None:
|
||||
mode = request.support_system_mode.strip().lower()
|
||||
if mode not in {'tickets', 'contact', 'both'}:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Invalid support_system_mode. Must be: tickets, contact, or both',
|
||||
)
|
||||
|
||||
# Update in-memory settings
|
||||
if request.sla_enabled is not None:
|
||||
settings.SUPPORT_TICKET_SLA_ENABLED = request.sla_enabled
|
||||
if request.sla_minutes is not None:
|
||||
settings.SUPPORT_TICKET_SLA_MINUTES = request.sla_minutes
|
||||
if request.sla_check_interval_seconds is not None:
|
||||
settings.SUPPORT_TICKET_SLA_CHECK_INTERVAL_SECONDS = request.sla_check_interval_seconds
|
||||
if request.sla_reminder_cooldown_minutes is not None:
|
||||
settings.SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES = request.sla_reminder_cooldown_minutes
|
||||
if request.support_system_mode is not None:
|
||||
SupportSettingsService.set_system_mode(request.support_system_mode.strip().lower())
|
||||
|
||||
# Update cabinet notification settings
|
||||
if request.cabinet_user_notifications_enabled is not None:
|
||||
SupportSettingsService.set_cabinet_user_notifications_enabled(request.cabinet_user_notifications_enabled)
|
||||
if request.cabinet_admin_notifications_enabled is not None:
|
||||
SupportSettingsService.set_cabinet_admin_notifications_enabled(request.cabinet_admin_notifications_enabled)
|
||||
|
||||
# Try to persist to .env file
|
||||
try:
|
||||
env_file = Path('.env')
|
||||
if env_file.exists():
|
||||
lines = env_file.read_text().splitlines()
|
||||
updates = {}
|
||||
|
||||
if request.sla_enabled is not None:
|
||||
updates['SUPPORT_TICKET_SLA_ENABLED'] = str(request.sla_enabled).lower()
|
||||
if request.sla_minutes is not None:
|
||||
updates['SUPPORT_TICKET_SLA_MINUTES'] = str(request.sla_minutes)
|
||||
if request.sla_check_interval_seconds is not None:
|
||||
updates['SUPPORT_TICKET_SLA_CHECK_INTERVAL_SECONDS'] = str(request.sla_check_interval_seconds)
|
||||
if request.sla_reminder_cooldown_minutes is not None:
|
||||
updates['SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES'] = str(request.sla_reminder_cooldown_minutes)
|
||||
if request.support_system_mode is not None:
|
||||
updates['SUPPORT_SYSTEM_MODE'] = request.support_system_mode.strip().lower()
|
||||
|
||||
new_lines = []
|
||||
updated_keys = set()
|
||||
|
||||
for line in lines:
|
||||
updated = False
|
||||
for key, value in updates.items():
|
||||
if line.startswith(f'{key}='):
|
||||
new_lines.append(f'{key}={value}')
|
||||
updated_keys.add(key)
|
||||
updated = True
|
||||
break
|
||||
if not updated:
|
||||
new_lines.append(line)
|
||||
|
||||
# Add any keys that weren't found
|
||||
for key, value in updates.items():
|
||||
if key not in updated_keys:
|
||||
new_lines.append(f'{key}={value}')
|
||||
|
||||
env_file.write_text('\n'.join(new_lines) + '\n')
|
||||
logger.info('Updated ticket settings in .env file')
|
||||
except Exception as e:
|
||||
logger.warning('Failed to update .env file', error=e)
|
||||
|
||||
return TicketSettingsResponse(
|
||||
sla_enabled=settings.SUPPORT_TICKET_SLA_ENABLED,
|
||||
sla_minutes=settings.SUPPORT_TICKET_SLA_MINUTES,
|
||||
sla_check_interval_seconds=settings.SUPPORT_TICKET_SLA_CHECK_INTERVAL_SECONDS,
|
||||
sla_reminder_cooldown_minutes=settings.SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES,
|
||||
support_system_mode=settings.get_support_system_mode(),
|
||||
cabinet_user_notifications_enabled=SupportSettingsService.get_cabinet_user_notifications_enabled(),
|
||||
cabinet_admin_notifications_enabled=SupportSettingsService.get_cabinet_admin_notifications_enabled(),
|
||||
)
|
||||
|
||||
|
||||
@router.get('', response_model=AdminTicketListResponse)
|
||||
async def get_all_tickets(
|
||||
page: int = Query(1, ge=1, description="Page number"),
|
||||
per_page: int = Query(20, ge=1, le=100, description="Items per page"),
|
||||
status_filter: Optional[str] = Query(None, alias="status", description="Filter by status"),
|
||||
priority_filter: Optional[str] = Query(None, alias="priority", description="Filter by priority"),
|
||||
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))
|
||||
)
|
||||
query = select(Ticket).options(selectinload(Ticket.messages), selectinload(Ticket.user))
|
||||
|
||||
# Build count query
|
||||
count_query = select(func.count()).select_from(Ticket)
|
||||
@@ -236,6 +356,10 @@ async def get_all_tickets(
|
||||
query = query.where(Ticket.priority == priority_filter)
|
||||
count_query = count_query.where(Ticket.priority == priority_filter)
|
||||
|
||||
if user_id:
|
||||
query = query.where(Ticket.user_id == user_id)
|
||||
count_query = count_query.where(Ticket.user_id == user_id)
|
||||
|
||||
# Get total count
|
||||
total_result = await db.execute(count_query)
|
||||
total = total_result.scalar() or 0
|
||||
@@ -259,7 +383,7 @@ async def get_all_tickets(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{ticket_id}", response_model=AdminTicketDetailResponse)
|
||||
@router.get('/{ticket_id}', response_model=AdminTicketDetailResponse)
|
||||
async def get_ticket_detail(
|
||||
ticket_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
@@ -267,9 +391,7 @@ async def get_ticket_detail(
|
||||
):
|
||||
"""Get ticket with all messages for admin."""
|
||||
query = (
|
||||
select(Ticket)
|
||||
.where(Ticket.id == ticket_id)
|
||||
.options(selectinload(Ticket.messages), selectinload(Ticket.user))
|
||||
select(Ticket).where(Ticket.id == ticket_id).options(selectinload(Ticket.messages), selectinload(Ticket.user))
|
||||
)
|
||||
|
||||
result = await db.execute(query)
|
||||
@@ -278,7 +400,7 @@ async def get_ticket_detail(
|
||||
if not ticket:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Ticket not found",
|
||||
detail='Ticket not found',
|
||||
)
|
||||
|
||||
messages = sorted(ticket.messages or [], key=lambda m: m.created_at)
|
||||
@@ -290,19 +412,19 @@ async def get_ticket_detail(
|
||||
|
||||
return AdminTicketDetailResponse(
|
||||
id=ticket.id,
|
||||
title=ticket.title or f"Ticket #{ticket.id}",
|
||||
title=ticket.title or f'Ticket #{ticket.id}',
|
||||
status=ticket.status,
|
||||
priority=ticket.priority or "normal",
|
||||
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,
|
||||
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)
|
||||
@router.post('/{ticket_id}/reply', response_model=TicketMessageResponse)
|
||||
async def reply_to_ticket(
|
||||
ticket_id: int,
|
||||
request: AdminReplyRequest,
|
||||
@@ -316,7 +438,7 @@ async def reply_to_ticket(
|
||||
if not ticket:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Ticket not found",
|
||||
detail='Ticket not found',
|
||||
)
|
||||
|
||||
# Create admin message
|
||||
@@ -325,13 +447,13 @@ async def reply_to_ticket(
|
||||
user_id=ticket.user_id,
|
||||
message_text=request.message,
|
||||
is_from_admin=True,
|
||||
created_at=datetime.utcnow(),
|
||||
created_at=datetime.now(UTC),
|
||||
)
|
||||
db.add(message)
|
||||
|
||||
# Update ticket status to answered
|
||||
ticket.status = "answered"
|
||||
ticket.updated_at = datetime.utcnow()
|
||||
ticket.status = 'answered'
|
||||
ticket.updated_at = datetime.now(UTC)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(message)
|
||||
@@ -348,13 +470,14 @@ async def reply_to_ticket(
|
||||
)
|
||||
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}")
|
||||
logger.warning('Failed to notify user about ticket reply', error=e)
|
||||
finally:
|
||||
await bot.session.close()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to send Telegram notification: {e}")
|
||||
logger.warning('Failed to send Telegram notification', error=e)
|
||||
|
||||
# Уведомить пользователя в кабинете
|
||||
try:
|
||||
@@ -363,14 +486,14 @@ async def reply_to_ticket(
|
||||
)
|
||||
if notification:
|
||||
# Отправить WebSocket уведомление
|
||||
await notify_user_ticket_reply(ticket.user_id, ticket.id, (request.message or "")[:100])
|
||||
await notify_user_ticket_reply(ticket.user_id, ticket.id, (request.message or '')[:100])
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to create cabinet notification for admin reply: {e}")
|
||||
logger.warning('Failed to create cabinet notification for admin reply', error=e)
|
||||
|
||||
return _message_to_response(message)
|
||||
|
||||
|
||||
@router.post("/{ticket_id}/status", response_model=AdminTicketDetailResponse)
|
||||
@router.post('/{ticket_id}/status', response_model=AdminTicketDetailResponse)
|
||||
async def update_ticket_status(
|
||||
ticket_id: int,
|
||||
request: AdminStatusUpdateRequest,
|
||||
@@ -378,17 +501,15 @@ async def update_ticket_status(
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update ticket status."""
|
||||
allowed_statuses = {"open", "pending", "answered", "closed"}
|
||||
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)}",
|
||||
detail=f'Invalid status. Allowed: {", ".join(allowed_statuses)}',
|
||||
)
|
||||
|
||||
query = (
|
||||
select(Ticket)
|
||||
.where(Ticket.id == ticket_id)
|
||||
.options(selectinload(Ticket.messages), selectinload(Ticket.user))
|
||||
select(Ticket).where(Ticket.id == ticket_id).options(selectinload(Ticket.messages), selectinload(Ticket.user))
|
||||
)
|
||||
|
||||
result = await db.execute(query)
|
||||
@@ -397,13 +518,13 @@ async def update_ticket_status(
|
||||
if not ticket:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Ticket not found",
|
||||
detail='Ticket not found',
|
||||
)
|
||||
|
||||
ticket.status = request.status
|
||||
ticket.updated_at = datetime.utcnow()
|
||||
if request.status == "closed":
|
||||
ticket.closed_at = datetime.utcnow()
|
||||
ticket.updated_at = datetime.now(UTC)
|
||||
if request.status == 'closed':
|
||||
ticket.closed_at = datetime.now(UTC)
|
||||
else:
|
||||
ticket.closed_at = None
|
||||
|
||||
@@ -419,19 +540,19 @@ async def update_ticket_status(
|
||||
|
||||
return AdminTicketDetailResponse(
|
||||
id=ticket.id,
|
||||
title=ticket.title or f"Ticket #{ticket.id}",
|
||||
title=ticket.title or f'Ticket #{ticket.id}',
|
||||
status=ticket.status,
|
||||
priority=ticket.priority or "normal",
|
||||
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,
|
||||
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)
|
||||
@router.post('/{ticket_id}/priority', response_model=AdminTicketDetailResponse)
|
||||
async def update_ticket_priority(
|
||||
ticket_id: int,
|
||||
request: AdminPriorityUpdateRequest,
|
||||
@@ -439,17 +560,15 @@ async def update_ticket_priority(
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update ticket priority."""
|
||||
allowed_priorities = {"low", "normal", "high", "urgent"}
|
||||
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)}",
|
||||
detail=f'Invalid priority. Allowed: {", ".join(allowed_priorities)}',
|
||||
)
|
||||
|
||||
query = (
|
||||
select(Ticket)
|
||||
.where(Ticket.id == ticket_id)
|
||||
.options(selectinload(Ticket.messages), selectinload(Ticket.user))
|
||||
select(Ticket).where(Ticket.id == ticket_id).options(selectinload(Ticket.messages), selectinload(Ticket.user))
|
||||
)
|
||||
|
||||
result = await db.execute(query)
|
||||
@@ -458,11 +577,11 @@ async def update_ticket_priority(
|
||||
if not ticket:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Ticket not found",
|
||||
detail='Ticket not found',
|
||||
)
|
||||
|
||||
ticket.priority = request.priority
|
||||
ticket.updated_at = datetime.utcnow()
|
||||
ticket.updated_at = datetime.now(UTC)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(ticket)
|
||||
@@ -476,123 +595,13 @@ async def update_ticket_priority(
|
||||
|
||||
return AdminTicketDetailResponse(
|
||||
id=ticket.id,
|
||||
title=ticket.title or f"Ticket #{ticket.id}",
|
||||
title=ticket.title or f'Ticket #{ticket.id}',
|
||||
status=ticket.status,
|
||||
priority=ticket.priority or "normal",
|
||||
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,
|
||||
is_reply_blocked=ticket.is_reply_blocked if hasattr(ticket, 'is_reply_blocked') else False,
|
||||
user=user_info,
|
||||
messages=messages_response,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/settings", response_model=TicketSettingsResponse)
|
||||
async def get_ticket_settings(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get ticket system settings."""
|
||||
from app.services.support_settings_service import SupportSettingsService
|
||||
|
||||
return TicketSettingsResponse(
|
||||
sla_enabled=settings.SUPPORT_TICKET_SLA_ENABLED,
|
||||
sla_minutes=settings.SUPPORT_TICKET_SLA_MINUTES,
|
||||
sla_check_interval_seconds=settings.SUPPORT_TICKET_SLA_CHECK_INTERVAL_SECONDS,
|
||||
sla_reminder_cooldown_minutes=settings.SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES,
|
||||
support_system_mode=settings.get_support_system_mode(),
|
||||
cabinet_user_notifications_enabled=SupportSettingsService.get_cabinet_user_notifications_enabled(),
|
||||
cabinet_admin_notifications_enabled=SupportSettingsService.get_cabinet_admin_notifications_enabled(),
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/settings", response_model=TicketSettingsResponse)
|
||||
async def update_ticket_settings(
|
||||
request: TicketSettingsUpdateRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update ticket system settings."""
|
||||
import os
|
||||
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(f"Updated ticket settings in .env file")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to update .env file: {e}")
|
||||
|
||||
return TicketSettingsResponse(
|
||||
sla_enabled=settings.SUPPORT_TICKET_SLA_ENABLED,
|
||||
sla_minutes=settings.SUPPORT_TICKET_SLA_MINUTES,
|
||||
sla_check_interval_seconds=settings.SUPPORT_TICKET_SLA_CHECK_INTERVAL_SECONDS,
|
||||
sla_reminder_cooldown_minutes=settings.SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES,
|
||||
support_system_mode=settings.get_support_system_mode(),
|
||||
cabinet_user_notifications_enabled=SupportSettingsService.get_cabinet_user_notifications_enabled(),
|
||||
cabinet_admin_notifications_enabled=SupportSettingsService.get_cabinet_admin_notifications_enabled(),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,694 @@
|
||||
"""Admin routes for traffic usage statistics."""
|
||||
|
||||
import asyncio
|
||||
import csv
|
||||
import io
|
||||
import time
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import structlog
|
||||
from aiogram import Bot
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
from aiogram.types import BufferedInputFile
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import and_, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import Subscription, Transaction, TransactionType, User
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
from ..schemas.traffic import (
|
||||
ExportCsvRequest,
|
||||
ExportCsvResponse,
|
||||
TrafficEnrichmentResponse,
|
||||
TrafficNodeInfo,
|
||||
TrafficUsageResponse,
|
||||
UserTrafficEnrichment,
|
||||
UserTrafficItem,
|
||||
)
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/traffic', tags=['Admin Traffic'])
|
||||
|
||||
_ALLOWED_PERIODS = frozenset({1, 3, 7, 14, 30})
|
||||
_CONCURRENCY_LIMIT = 5 # Max parallel API calls to avoid rate limiting
|
||||
|
||||
# In-memory cache: {(start_str, end_str): (timestamp, aggregated_data, nodes_info)}
|
||||
_traffic_cache: dict[tuple[str, str], tuple[float, dict[str, dict[str, int]], list[TrafficNodeInfo]]] = {}
|
||||
_CACHE_TTL = 300 # 5 minutes
|
||||
_cache_lock = asyncio.Lock()
|
||||
|
||||
# Valid sort fields for the GET endpoint
|
||||
_SORT_FIELDS = frozenset({'total_bytes', 'full_name', 'tariff_name', 'device_limit', 'traffic_limit_gb'})
|
||||
_ENRICHMENT_SORT_FIELDS = frozenset({'connected', 'total_spent', 'sub_start', 'sub_end', 'last_node'})
|
||||
|
||||
|
||||
def _get_status(sub) -> str | None:
|
||||
"""Get subscription status via actual_status property."""
|
||||
return sub.actual_status
|
||||
|
||||
|
||||
def _validate_period(period: int) -> None:
|
||||
if period not in _ALLOWED_PERIODS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Period must be one of: {sorted(_ALLOWED_PERIODS)}',
|
||||
)
|
||||
|
||||
|
||||
async def _aggregate_traffic(
|
||||
start_str: str, end_str: str, user_uuids: list[str]
|
||||
) -> tuple[dict[str, dict[str, int]], list[TrafficNodeInfo]]:
|
||||
"""Aggregate per-user traffic across all nodes for a given date range.
|
||||
|
||||
Uses legacy per-node endpoint to fetch all users' traffic per node —
|
||||
O(nodes) API calls instead of O(users). The legacy endpoint returns
|
||||
{userUuid, nodeUuid, total} per entry (non-legacy only returns topUsers
|
||||
without userUuid).
|
||||
|
||||
Returns (user_traffic, nodes_info) where:
|
||||
user_traffic = {remnawave_uuid: {node_uuid: total_bytes, ...}}
|
||||
nodes_info = [TrafficNodeInfo, ...]
|
||||
"""
|
||||
cache_key = (start_str, end_str)
|
||||
|
||||
# Quick check without lock
|
||||
now = time.time()
|
||||
cached = _traffic_cache.get(cache_key)
|
||||
if cached and (now - cached[0]) < _CACHE_TTL:
|
||||
return cached[1], cached[2]
|
||||
|
||||
# Acquire lock for the slow path
|
||||
async with _cache_lock:
|
||||
# Re-check after acquiring lock
|
||||
now = time.time()
|
||||
cached = _traffic_cache.get(cache_key)
|
||||
if cached and (now - cached[0]) < _CACHE_TTL:
|
||||
return cached[1], cached[2]
|
||||
|
||||
service = RemnaWaveService()
|
||||
if not service.is_configured:
|
||||
return {}, []
|
||||
|
||||
user_uuids_set = set(user_uuids)
|
||||
|
||||
async with service.get_api_client() as api:
|
||||
nodes = await api.get_all_nodes()
|
||||
|
||||
# Fetch per-node user stats — O(nodes) calls instead of O(users)
|
||||
semaphore = asyncio.Semaphore(_CONCURRENCY_LIMIT)
|
||||
|
||||
async def fetch_node_users(node):
|
||||
async with semaphore:
|
||||
try:
|
||||
stats = await api.get_bandwidth_stats_node_users_legacy(node.uuid, start_str, end_str)
|
||||
return node.uuid, stats
|
||||
except Exception:
|
||||
logger.warning('Failed to get traffic for node', node_name=node.name, exc_info=True)
|
||||
return node.uuid, None
|
||||
|
||||
results = await asyncio.gather(*(fetch_node_users(n) for n in nodes))
|
||||
|
||||
nodes_info: list[TrafficNodeInfo] = [
|
||||
TrafficNodeInfo(node_uuid=node.uuid, node_name=node.name, country_code=node.country_code) for node in nodes
|
||||
]
|
||||
nodes_info.sort(key=lambda n: n.node_name)
|
||||
|
||||
# Legacy response: [{userUuid, username, nodeUuid, total, date}, ...]
|
||||
user_traffic: dict[str, dict[str, int]] = {}
|
||||
for node_uuid, entries in results:
|
||||
if not isinstance(entries, list):
|
||||
continue
|
||||
for entry in entries:
|
||||
uid = entry.get('userUuid', '')
|
||||
total = int(entry.get('total', 0))
|
||||
if uid and total > 0 and uid in user_uuids_set:
|
||||
user_traffic.setdefault(uid, {})[node_uuid] = user_traffic.get(uid, {}).get(node_uuid, 0) + total
|
||||
|
||||
_traffic_cache[cache_key] = (now, user_traffic, nodes_info)
|
||||
|
||||
# Evict expired entries to prevent unbounded growth
|
||||
expired = [k for k, (ts, _, _) in _traffic_cache.items() if (now - ts) >= _CACHE_TTL]
|
||||
for k in expired:
|
||||
del _traffic_cache[k]
|
||||
|
||||
return user_traffic, nodes_info
|
||||
|
||||
|
||||
def _compute_date_range(period_days: int) -> tuple[str, str]:
|
||||
"""Compute ISO date-time range from period days.
|
||||
|
||||
Truncates to 5-minute intervals for stable cache keys.
|
||||
"""
|
||||
end_dt = datetime.now(UTC).replace(second=0, microsecond=0)
|
||||
end_dt = end_dt.replace(minute=(end_dt.minute // 5) * 5)
|
||||
start_dt = end_dt - timedelta(days=period_days)
|
||||
return start_dt.strftime('%Y-%m-%dT%H:%M:%SZ'), end_dt.strftime('%Y-%m-%dT%H:%M:%SZ')
|
||||
|
||||
|
||||
async def _load_user_map(db: AsyncSession) -> dict[str, User]:
|
||||
"""Load all users with remnawave_uuid, eagerly loading subscription + tariff."""
|
||||
stmt = (
|
||||
select(User)
|
||||
.where(User.remnawave_uuid.isnot(None))
|
||||
.options(selectinload(User.subscription).selectinload(Subscription.tariff))
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
users = result.scalars().all()
|
||||
return {u.remnawave_uuid: u for u in users if u.remnawave_uuid}
|
||||
|
||||
|
||||
def _build_traffic_items(
|
||||
user_traffic: dict[str, dict[str, int]],
|
||||
user_map: dict[str, User],
|
||||
nodes_info: list[TrafficNodeInfo],
|
||||
search: str = '',
|
||||
sort_by: str = 'total_bytes',
|
||||
sort_desc: bool = True,
|
||||
tariff_filter: set[str] | None = None,
|
||||
status_filter: set[str] | None = None,
|
||||
node_filter: set[str] | None = None,
|
||||
) -> list[UserTrafficItem]:
|
||||
"""Merge traffic data with user data, apply search/tariff/status/node filters, return sorted list."""
|
||||
items: list[UserTrafficItem] = []
|
||||
search_lower = search.lower().strip()
|
||||
|
||||
all_uuids = set(user_traffic.keys()) | set(user_map.keys())
|
||||
for uuid in all_uuids:
|
||||
user = user_map.get(uuid)
|
||||
if not user:
|
||||
continue
|
||||
|
||||
traffic = user_traffic.get(uuid, {})
|
||||
|
||||
full_name = user.full_name
|
||||
username = user.username
|
||||
email = user.email
|
||||
|
||||
if search_lower:
|
||||
if (
|
||||
search_lower not in (full_name or '').lower()
|
||||
and search_lower not in (username or '').lower()
|
||||
and search_lower not in (email or '').lower()
|
||||
):
|
||||
continue
|
||||
|
||||
sub = user.subscription
|
||||
tariff_name = None
|
||||
subscription_status = None
|
||||
traffic_limit_gb = 0.0
|
||||
device_limit = 1
|
||||
|
||||
if sub:
|
||||
subscription_status = _get_status(sub)
|
||||
traffic_limit_gb = float(sub.traffic_limit_gb or 0)
|
||||
device_limit = sub.device_limit or 1
|
||||
if sub.tariff:
|
||||
tariff_name = sub.tariff.name
|
||||
|
||||
if tariff_filter is not None:
|
||||
if (tariff_name or '') not in tariff_filter:
|
||||
continue
|
||||
|
||||
if status_filter is not None:
|
||||
if (subscription_status or '') not in status_filter:
|
||||
continue
|
||||
|
||||
# Apply node filter: keep only selected nodes, recalculate total
|
||||
if node_filter is not None:
|
||||
traffic = {k: v for k, v in traffic.items() if k in node_filter}
|
||||
|
||||
total_bytes = sum(traffic.values())
|
||||
|
||||
items.append(
|
||||
UserTrafficItem(
|
||||
user_id=user.id,
|
||||
telegram_id=user.telegram_id,
|
||||
username=username,
|
||||
email=email,
|
||||
full_name=full_name,
|
||||
tariff_name=tariff_name,
|
||||
subscription_status=subscription_status,
|
||||
traffic_limit_gb=traffic_limit_gb,
|
||||
device_limit=device_limit,
|
||||
node_traffic=traffic,
|
||||
total_bytes=total_bytes,
|
||||
)
|
||||
)
|
||||
|
||||
# Sort by the requested field; node columns use 'node_<uuid>' prefix
|
||||
if sort_by.startswith('node_'):
|
||||
node_uuid = sort_by[5:]
|
||||
items.sort(key=lambda x: x.node_traffic.get(node_uuid, 0), reverse=sort_desc)
|
||||
elif sort_by in ('full_name', 'tariff_name'):
|
||||
items.sort(key=lambda x: (getattr(x, sort_by, None) or '').lower(), reverse=sort_desc)
|
||||
else:
|
||||
items.sort(key=lambda x: getattr(x, sort_by, 0) or 0, reverse=sort_desc)
|
||||
|
||||
return items
|
||||
|
||||
|
||||
@router.get('', response_model=TrafficUsageResponse)
|
||||
async def get_traffic_usage(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
period: int = Query(30, ge=1, le=30),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
search: str = Query('', max_length=100),
|
||||
sort_by: str = Query('total_bytes', max_length=100),
|
||||
sort_desc: bool = Query(True),
|
||||
tariffs: str = Query('', max_length=500),
|
||||
statuses: str = Query('', max_length=500),
|
||||
nodes: str = Query('', max_length=2000),
|
||||
start_date: str = Query('', max_length=10),
|
||||
end_date: str = Query('', max_length=10),
|
||||
):
|
||||
"""Get paginated per-user traffic usage by node."""
|
||||
# Determine date range: custom dates or period-based
|
||||
if start_date.strip() and end_date.strip():
|
||||
try:
|
||||
start_dt = datetime.strptime(start_date.strip(), '%Y-%m-%d').replace(tzinfo=UTC)
|
||||
end_dt = datetime.strptime(end_date.strip(), '%Y-%m-%d').replace(tzinfo=UTC, hour=23, minute=59, second=59)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Invalid date format. Use YYYY-MM-DD.')
|
||||
|
||||
now = datetime.now(UTC)
|
||||
end_dt = min(end_dt, now)
|
||||
|
||||
if start_dt > end_dt:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='start_date must be before end_date.')
|
||||
|
||||
if (end_dt - start_dt).days > 31:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Date range cannot exceed 31 days.')
|
||||
|
||||
start_str = start_dt.strftime('%Y-%m-%dT%H:%M:%SZ')
|
||||
end_str = end_dt.strftime('%Y-%m-%dT%H:%M:%SZ')
|
||||
effective_period = (end_dt - start_dt).days or 1
|
||||
else:
|
||||
_validate_period(period)
|
||||
start_str, end_str = _compute_date_range(period)
|
||||
effective_period = period
|
||||
|
||||
user_map = await _load_user_map(db)
|
||||
user_traffic, nodes_info = await _aggregate_traffic(start_str, end_str, list(user_map.keys()))
|
||||
|
||||
# Collect all available tariff names (before filtering)
|
||||
available_tariffs = sorted(
|
||||
{
|
||||
u.subscription.tariff.name
|
||||
for u in user_map.values()
|
||||
if u.subscription and u.subscription.tariff and u.subscription.tariff.name
|
||||
}
|
||||
)
|
||||
|
||||
# Collect all available statuses (before filtering)
|
||||
available_statuses = sorted(
|
||||
{_get_status(sub) for u in user_map.values() if (sub := u.subscription) and _get_status(sub)}
|
||||
)
|
||||
|
||||
# Parse tariff filter
|
||||
tariff_filter: set[str] | None = None
|
||||
if tariffs.strip():
|
||||
tariff_filter = {t.strip() for t in tariffs.split(',') if t.strip()}
|
||||
|
||||
# Parse status filter
|
||||
status_filter: set[str] | None = None
|
||||
if statuses.strip():
|
||||
status_filter = {s.strip() for s in statuses.split(',') if s.strip()}
|
||||
|
||||
# Parse node filter
|
||||
node_filter: set[str] | None = None
|
||||
all_node_uuids = {n.node_uuid for n in nodes_info}
|
||||
if nodes.strip():
|
||||
node_filter = {n.strip() for n in nodes.split(',') if n.strip()} & all_node_uuids
|
||||
if not node_filter:
|
||||
node_filter = None # No valid nodes matched, treat as "all nodes"
|
||||
|
||||
# Validate sort_by: allow known fields + enrichment fields + 'node_<uuid>'
|
||||
is_node_sort = sort_by.startswith('node_') and sort_by[5:] in all_node_uuids
|
||||
is_enrichment_sort = sort_by in _ENRICHMENT_SORT_FIELDS
|
||||
if sort_by not in _SORT_FIELDS and not is_node_sort and not is_enrichment_sort:
|
||||
sort_by = 'total_bytes'
|
||||
|
||||
# For enrichment sort, build items unsorted then sort by enrichment field
|
||||
effective_sort = 'total_bytes' if is_enrichment_sort else sort_by
|
||||
items = _build_traffic_items(
|
||||
user_traffic, user_map, nodes_info, search, effective_sort, sort_desc, tariff_filter, status_filter, node_filter
|
||||
)
|
||||
|
||||
if is_enrichment_sort:
|
||||
enrichment_data = await _build_enrichment(db, user_map)
|
||||
enr_key_map = {
|
||||
'connected': lambda e: e.devices_connected,
|
||||
'total_spent': lambda e: e.total_spent_kopeks,
|
||||
'sub_start': lambda e: e.subscription_start_date or '',
|
||||
'sub_end': lambda e: e.subscription_end_date or '',
|
||||
'last_node': lambda e: e.last_node_name or '',
|
||||
}
|
||||
key_fn = enr_key_map[sort_by]
|
||||
empty = UserTrafficEnrichment()
|
||||
items.sort(key=lambda x: key_fn(enrichment_data.get(x.user_id, empty)), reverse=sort_desc)
|
||||
|
||||
total = len(items)
|
||||
paginated = items[offset : offset + limit]
|
||||
|
||||
return TrafficUsageResponse(
|
||||
items=paginated,
|
||||
nodes=nodes_info,
|
||||
total=total,
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
period_days=effective_period,
|
||||
available_tariffs=available_tariffs,
|
||||
available_statuses=available_statuses,
|
||||
)
|
||||
|
||||
|
||||
# ============== Enrichment endpoint ==============
|
||||
|
||||
_enrichment_cache: dict[str, tuple[float, dict[int, UserTrafficEnrichment]]] = {}
|
||||
_ENRICHMENT_CACHE_TTL = 300 # 5 minutes
|
||||
_enrichment_lock = asyncio.Lock()
|
||||
|
||||
|
||||
async def _get_bulk_spending(db: AsyncSession, user_ids: list[int]) -> dict[int, int]:
|
||||
"""Get total spent kopeks for multiple users in a single query."""
|
||||
if not user_ids:
|
||||
return {}
|
||||
result = await db.execute(
|
||||
select(Transaction.user_id, func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0))
|
||||
.where(
|
||||
and_(
|
||||
Transaction.user_id.in_(user_ids),
|
||||
Transaction.is_completed.is_(True),
|
||||
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
|
||||
)
|
||||
)
|
||||
.group_by(Transaction.user_id)
|
||||
)
|
||||
return {row[0]: int(row[1]) for row in result.all()}
|
||||
|
||||
|
||||
async def _build_enrichment(db: AsyncSession, user_map: dict[str, User]) -> dict[int, UserTrafficEnrichment]:
|
||||
"""Build enrichment data for all users: devices, spending, dates, last node."""
|
||||
uuid_to_user_id: dict[str, int] = {}
|
||||
for uuid, user in user_map.items():
|
||||
uuid_to_user_id[uuid] = user.id
|
||||
|
||||
service = RemnaWaveService()
|
||||
devices_by_user: dict[int, int] = {}
|
||||
last_node_uuid_by_user: dict[int, str] = {}
|
||||
node_uuid_to_name: dict[str, str] = {}
|
||||
|
||||
if service.is_configured:
|
||||
async with service.get_api_client() as api:
|
||||
# 3 bulk calls: nodes + users (paginated) + devices
|
||||
try:
|
||||
nodes_list = await api.get_all_nodes()
|
||||
except Exception:
|
||||
logger.warning('Failed to fetch nodes for enrichment', exc_info=True)
|
||||
nodes_list = []
|
||||
|
||||
for node in nodes_list:
|
||||
node_uuid_to_name[node.uuid] = node.name
|
||||
|
||||
# Fetch all panel users (paginated) for last connected node
|
||||
panel_users = []
|
||||
try:
|
||||
first_page = await api.get_all_users(start=0, size=500)
|
||||
panel_users.extend(first_page['users'])
|
||||
total_panel = first_page['total']
|
||||
|
||||
if total_panel > 500:
|
||||
remaining_tasks = [
|
||||
api.get_all_users(start=offset, size=500) for offset in range(500, total_panel, 500)
|
||||
]
|
||||
pages = await asyncio.gather(*remaining_tasks, return_exceptions=True)
|
||||
for page in pages:
|
||||
if isinstance(page, dict):
|
||||
panel_users.extend(page['users'])
|
||||
except Exception:
|
||||
logger.warning('Failed to fetch panel users for enrichment', exc_info=True)
|
||||
|
||||
for pu in panel_users:
|
||||
uid = uuid_to_user_id.get(pu.uuid)
|
||||
if uid is None:
|
||||
continue
|
||||
if pu.user_traffic and pu.user_traffic.last_connected_node_uuid:
|
||||
last_node_uuid_by_user[uid] = pu.user_traffic.last_connected_node_uuid
|
||||
|
||||
# Bulk device fetch — single API call (paginated with start/size)
|
||||
try:
|
||||
devices_data = await api.get_all_hwid_devices()
|
||||
for device in devices_data.get('devices', []):
|
||||
user_uuid = device.get('userUuid', '')
|
||||
uid = uuid_to_user_id.get(user_uuid)
|
||||
if uid is not None:
|
||||
devices_by_user[uid] = devices_by_user.get(uid, 0) + 1
|
||||
except Exception:
|
||||
logger.warning('Failed to fetch bulk devices for enrichment', exc_info=True)
|
||||
|
||||
# Bulk spending stats
|
||||
all_user_ids = [u.id for u in user_map.values()]
|
||||
spending_map = await _get_bulk_spending(db, all_user_ids)
|
||||
|
||||
# Build enrichment data
|
||||
enrichment: dict[int, UserTrafficEnrichment] = {}
|
||||
for uuid, user in user_map.items():
|
||||
uid = user.id
|
||||
sub = user.subscription
|
||||
|
||||
start_date = None
|
||||
end_date = None
|
||||
if sub:
|
||||
if sub.start_date:
|
||||
start_date = sub.start_date.isoformat()
|
||||
if sub.end_date:
|
||||
end_date = sub.end_date.isoformat()
|
||||
|
||||
last_node_name = None
|
||||
last_uuid = last_node_uuid_by_user.get(uid)
|
||||
if last_uuid:
|
||||
last_node_name = node_uuid_to_name.get(last_uuid)
|
||||
|
||||
enrichment[uid] = UserTrafficEnrichment(
|
||||
devices_connected=devices_by_user.get(uid, 0),
|
||||
total_spent_kopeks=spending_map.get(uid, 0),
|
||||
subscription_start_date=start_date,
|
||||
subscription_end_date=end_date,
|
||||
last_node_name=last_node_name,
|
||||
)
|
||||
|
||||
return enrichment
|
||||
|
||||
|
||||
@router.get('/enrichment', response_model=TrafficEnrichmentResponse)
|
||||
async def get_traffic_enrichment(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Return enrichment data: device counts, spending, dates, last node."""
|
||||
cache_key = 'enrichment'
|
||||
now = time.time()
|
||||
|
||||
cached = _enrichment_cache.get(cache_key)
|
||||
if cached and (now - cached[0]) < _ENRICHMENT_CACHE_TTL:
|
||||
return TrafficEnrichmentResponse(data=cached[1])
|
||||
|
||||
async with _enrichment_lock:
|
||||
now = time.time()
|
||||
cached = _enrichment_cache.get(cache_key)
|
||||
if cached and (now - cached[0]) < _ENRICHMENT_CACHE_TTL:
|
||||
return TrafficEnrichmentResponse(data=cached[1])
|
||||
|
||||
user_map = await _load_user_map(db)
|
||||
enrichment = await _build_enrichment(db, user_map)
|
||||
|
||||
_enrichment_cache[cache_key] = (now, enrichment)
|
||||
|
||||
# Evict expired
|
||||
expired = [k for k, (ts, _) in _enrichment_cache.items() if (now - ts) >= _ENRICHMENT_CACHE_TTL]
|
||||
for k in expired:
|
||||
del _enrichment_cache[k]
|
||||
|
||||
return TrafficEnrichmentResponse(data=enrichment)
|
||||
|
||||
|
||||
@router.post('/export-csv', response_model=ExportCsvResponse)
|
||||
async def export_traffic_csv(
|
||||
request: ExportCsvRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Generate CSV with traffic usage and send to admin's Telegram DM."""
|
||||
if not admin.telegram_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Admin has no Telegram ID configured',
|
||||
)
|
||||
|
||||
# Determine date range: custom dates or period-based
|
||||
if request.start_date and request.end_date:
|
||||
try:
|
||||
start_dt = datetime.strptime(request.start_date.strip(), '%Y-%m-%d').replace(tzinfo=UTC)
|
||||
end_dt = datetime.strptime(request.end_date.strip(), '%Y-%m-%d').replace(
|
||||
tzinfo=UTC, hour=23, minute=59, second=59
|
||||
)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Invalid date format. Use YYYY-MM-DD.')
|
||||
|
||||
now = datetime.now(UTC)
|
||||
end_dt = min(end_dt, now)
|
||||
if start_dt > end_dt:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='start_date must be before end_date.')
|
||||
if (end_dt - start_dt).days > 31:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Date range cannot exceed 31 days.')
|
||||
|
||||
start_str = start_dt.strftime('%Y-%m-%dT%H:%M:%SZ')
|
||||
end_str = end_dt.strftime('%Y-%m-%dT%H:%M:%SZ')
|
||||
period_label = f'{request.start_date}_{request.end_date}'
|
||||
else:
|
||||
_validate_period(request.period)
|
||||
start_str, end_str = _compute_date_range(request.period)
|
||||
period_label = f'{request.period}d'
|
||||
|
||||
user_map = await _load_user_map(db)
|
||||
user_traffic, nodes_info = await _aggregate_traffic(start_str, end_str, list(user_map.keys()))
|
||||
enrichment = await _build_enrichment(db, user_map)
|
||||
|
||||
# Parse filters
|
||||
tariff_filter: set[str] | None = None
|
||||
if request.tariffs and request.tariffs.strip():
|
||||
tariff_filter = {t.strip() for t in request.tariffs.split(',') if t.strip()}
|
||||
|
||||
status_filter: set[str] | None = None
|
||||
if request.statuses and request.statuses.strip():
|
||||
status_filter = {s.strip() for s in request.statuses.split(',') if s.strip()}
|
||||
|
||||
node_filter: set[str] | None = None
|
||||
all_node_uuids = {n.node_uuid for n in nodes_info}
|
||||
if request.nodes and request.nodes.strip():
|
||||
node_filter = {n.strip() for n in request.nodes.split(',') if n.strip()} & all_node_uuids
|
||||
if not node_filter:
|
||||
node_filter = None
|
||||
|
||||
items = _build_traffic_items(
|
||||
user_traffic,
|
||||
user_map,
|
||||
nodes_info,
|
||||
sort_by='total_bytes',
|
||||
sort_desc=True,
|
||||
tariff_filter=tariff_filter,
|
||||
status_filter=status_filter,
|
||||
node_filter=node_filter,
|
||||
)
|
||||
|
||||
# Determine which nodes to include in CSV columns
|
||||
csv_nodes = [n for n in nodes_info if n.node_uuid in node_filter] if node_filter else nodes_info
|
||||
|
||||
# Compute period days for risk calculation
|
||||
if request.start_date and request.end_date:
|
||||
period_days = max((end_dt - start_dt).days, 1)
|
||||
else:
|
||||
period_days = request.period
|
||||
|
||||
total_thr = request.total_threshold_gb or 0
|
||||
node_thr = request.node_threshold_gb or 0
|
||||
has_risk = total_thr > 0 or node_thr > 0
|
||||
|
||||
# Build CSV rows
|
||||
rows: list[dict] = []
|
||||
for item in items:
|
||||
row: dict = {
|
||||
'User ID': item.user_id,
|
||||
'Telegram ID': item.telegram_id or '',
|
||||
'Username': item.username or '',
|
||||
'Email': item.email or '',
|
||||
'Full Name': item.full_name,
|
||||
'Tariff': item.tariff_name or '',
|
||||
'Status': item.subscription_status or '',
|
||||
'Traffic Limit (GB)': item.traffic_limit_gb,
|
||||
'Device Limit': item.device_limit,
|
||||
}
|
||||
# Enrichment columns
|
||||
enr = enrichment.get(item.user_id)
|
||||
row['Connected Devices'] = enr.devices_connected if enr else 0
|
||||
row['Total Spent (RUB)'] = round(enr.total_spent_kopeks / 100, 2) if enr else 0
|
||||
row['Sub Start'] = enr.subscription_start_date or '' if enr else ''
|
||||
row['Sub End'] = enr.subscription_end_date or '' if enr else ''
|
||||
row['Last Node'] = enr.last_node_name or '' if enr else ''
|
||||
|
||||
for node in csv_nodes:
|
||||
row[f'{node.node_name} (bytes)'] = item.node_traffic.get(node.node_uuid, 0)
|
||||
row['Total (bytes)'] = item.total_bytes
|
||||
row['Total (GB)'] = round(item.total_bytes / (1024**3), 2) if item.total_bytes else 0
|
||||
|
||||
if has_risk:
|
||||
daily_total = item.total_bytes / period_days / (1024**3) if period_days > 0 else 0
|
||||
row['Total GB/day'] = round(daily_total, 4)
|
||||
|
||||
total_ratio = daily_total / total_thr if total_thr > 0 else 0
|
||||
|
||||
max_node_ratio = 0.0
|
||||
worst_node_daily = 0.0
|
||||
for node_bytes in item.node_traffic.values():
|
||||
if node_bytes > 0 and node_thr > 0:
|
||||
daily_node = node_bytes / period_days / (1024**3) if period_days > 0 else 0
|
||||
ratio = daily_node / node_thr
|
||||
if ratio > max_node_ratio:
|
||||
max_node_ratio = ratio
|
||||
worst_node_daily = daily_node
|
||||
|
||||
ratio = max(total_ratio, max_node_ratio)
|
||||
if ratio < 0.5:
|
||||
risk_level = 'low'
|
||||
elif ratio < 0.8:
|
||||
risk_level = 'medium'
|
||||
elif ratio < 1.2:
|
||||
risk_level = 'high'
|
||||
else:
|
||||
risk_level = 'critical'
|
||||
|
||||
row['Risk Level'] = risk_level
|
||||
row['Risk Ratio'] = round(ratio, 3)
|
||||
row['Risk GB/day'] = round(daily_total if total_ratio >= max_node_ratio else worst_node_daily, 4)
|
||||
|
||||
rows.append(row)
|
||||
|
||||
# Generate CSV
|
||||
output = io.StringIO()
|
||||
if rows:
|
||||
writer = csv.DictWriter(output, fieldnames=rows[0].keys())
|
||||
writer.writeheader()
|
||||
writer.writerows(rows)
|
||||
csv_bytes = output.getvalue().encode('utf-8-sig')
|
||||
|
||||
timestamp = datetime.now(UTC).strftime('%Y%m%d_%H%M%S')
|
||||
filename = f'traffic_usage_{period_label}_{timestamp}.csv'
|
||||
|
||||
try:
|
||||
bot = Bot(
|
||||
token=settings.BOT_TOKEN,
|
||||
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
|
||||
)
|
||||
async with bot:
|
||||
await bot.send_document(
|
||||
chat_id=admin.telegram_id,
|
||||
document=BufferedInputFile(csv_bytes, filename=filename),
|
||||
caption=f'Traffic usage report ({period_label})\nUsers: {len(rows)}',
|
||||
)
|
||||
except Exception:
|
||||
logger.error('Failed to send CSV to admin', telegram_id=admin.telegram_id, exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to send CSV report. Please try again later.',
|
||||
)
|
||||
|
||||
return ExportCsvResponse(success=True, message=f'CSV sent ({len(rows)} users)')
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Admin routes for version and release information."""
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import aiohttp
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.database.models import User
|
||||
from app.services.version_service import version_service
|
||||
|
||||
from ..dependencies import get_current_admin_user
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/updates', tags=['Cabinet Admin Updates'])
|
||||
|
||||
|
||||
# ============ Schemas ============
|
||||
|
||||
|
||||
class ReleaseItem(BaseModel):
|
||||
tag_name: str
|
||||
name: str
|
||||
body: str
|
||||
published_at: str
|
||||
prerelease: bool
|
||||
|
||||
|
||||
class ProjectReleasesInfo(BaseModel):
|
||||
current_version: str
|
||||
has_updates: bool
|
||||
releases: list[ReleaseItem]
|
||||
repo_url: str
|
||||
|
||||
|
||||
class ReleasesResponse(BaseModel):
|
||||
bot: ProjectReleasesInfo
|
||||
cabinet: ProjectReleasesInfo
|
||||
|
||||
|
||||
# ============ Cabinet releases cache ============
|
||||
|
||||
CABINET_REPO = 'BEDOLAGA-DEV/bedolaga-cabinet'
|
||||
_cabinet_cache: dict = {}
|
||||
_cabinet_last_check: datetime | None = None
|
||||
_CACHE_TTL = 3600
|
||||
|
||||
|
||||
async def _fetch_cabinet_releases(force: bool = False) -> list[dict]:
|
||||
global _cabinet_last_check
|
||||
|
||||
if not force and _cabinet_cache.get('releases') and _cabinet_last_check:
|
||||
if datetime.now(UTC) - _cabinet_last_check < timedelta(seconds=_CACHE_TTL):
|
||||
return _cabinet_cache['releases']
|
||||
|
||||
url = f'https://api.github.com/repos/{CABINET_REPO}/releases'
|
||||
|
||||
try:
|
||||
timeout = aiohttp.ClientTimeout(total=10)
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session, session.get(url) as response:
|
||||
if response.status == 200:
|
||||
data = await response.json()
|
||||
releases = []
|
||||
for item in data[:20]:
|
||||
releases.append(
|
||||
{
|
||||
'tag_name': item['tag_name'],
|
||||
'name': item.get('name') or item['tag_name'],
|
||||
'body': item.get('body') or '',
|
||||
'published_at': item['published_at'],
|
||||
'prerelease': item.get('prerelease', False),
|
||||
}
|
||||
)
|
||||
_cabinet_cache['releases'] = releases
|
||||
_cabinet_last_check = datetime.now(UTC)
|
||||
logger.info('Fetched cabinet releases from GitHub', releases_count=len(releases))
|
||||
return releases
|
||||
logger.warning('GitHub API returned status for cabinet releases', response_status=response.status)
|
||||
return _cabinet_cache.get('releases', [])
|
||||
except TimeoutError:
|
||||
logger.warning('Timeout fetching cabinet releases from GitHub')
|
||||
return _cabinet_cache.get('releases', [])
|
||||
except Exception as e:
|
||||
logger.error('Error fetching cabinet releases', e=e)
|
||||
return _cabinet_cache.get('releases', [])
|
||||
|
||||
|
||||
# ============ Routes ============
|
||||
|
||||
|
||||
@router.get('/releases', response_model=ReleasesResponse)
|
||||
async def get_releases(
|
||||
current_user: User = Depends(get_current_admin_user),
|
||||
) -> ReleasesResponse:
|
||||
"""Get release information for bot and cabinet."""
|
||||
# Bot releases
|
||||
bot_releases_raw = await version_service._fetch_releases()
|
||||
has_updates, _ = await version_service.check_for_updates()
|
||||
|
||||
bot_releases = [
|
||||
ReleaseItem(
|
||||
tag_name=r.tag_name,
|
||||
name=r.name,
|
||||
body=r.full_description,
|
||||
published_at=r.published_at.isoformat(),
|
||||
prerelease=r.prerelease,
|
||||
)
|
||||
for r in bot_releases_raw[:10]
|
||||
]
|
||||
|
||||
bot_info = ProjectReleasesInfo(
|
||||
current_version=version_service.current_version,
|
||||
has_updates=has_updates,
|
||||
releases=bot_releases,
|
||||
repo_url=f'https://github.com/{version_service.repo}',
|
||||
)
|
||||
|
||||
# Cabinet releases
|
||||
cabinet_releases_raw = await _fetch_cabinet_releases()
|
||||
cabinet_releases = [ReleaseItem(**r) for r in cabinet_releases_raw[:10]]
|
||||
|
||||
# Current version = latest non-prerelease tag
|
||||
cabinet_current = ''
|
||||
for r in cabinet_releases_raw:
|
||||
if not r.get('prerelease', False):
|
||||
cabinet_current = r['tag_name']
|
||||
break
|
||||
|
||||
cabinet_info = ProjectReleasesInfo(
|
||||
current_version=cabinet_current,
|
||||
has_updates=False,
|
||||
releases=cabinet_releases,
|
||||
repo_url=f'https://github.com/{CABINET_REPO}',
|
||||
)
|
||||
|
||||
return ReleasesResponse(bot=bot_info, cabinet=cabinet_info)
|
||||
+1225
-382
File diff suppressed because it is too large
Load Diff
@@ -1,46 +1,46 @@
|
||||
"""
|
||||
API роуты колеса удачи для администраторов.
|
||||
"""
|
||||
import logging
|
||||
|
||||
import math
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import User
|
||||
from app.database.crud.wheel import (
|
||||
get_or_create_wheel_config,
|
||||
update_wheel_config,
|
||||
get_wheel_prizes,
|
||||
get_wheel_prize_by_id,
|
||||
create_wheel_prize,
|
||||
update_wheel_prize,
|
||||
delete_wheel_prize,
|
||||
reorder_wheel_prizes,
|
||||
get_all_spins,
|
||||
get_wheel_statistics,
|
||||
)
|
||||
from app.services.wheel_service import wheel_service
|
||||
from app.cabinet.dependencies import get_cabinet_db, get_current_admin_user
|
||||
from app.cabinet.schemas.wheel import (
|
||||
AdminWheelConfigResponse,
|
||||
WheelPrizeAdminResponse,
|
||||
UpdateWheelConfigRequest,
|
||||
CreatePrizeRequest,
|
||||
UpdatePrizeRequest,
|
||||
ReorderPrizesRequest,
|
||||
AdminSpinsResponse,
|
||||
AdminSpinItem,
|
||||
AdminSpinsResponse,
|
||||
AdminWheelConfigResponse,
|
||||
CreatePrizeRequest,
|
||||
ReorderPrizesRequest,
|
||||
UpdatePrizeRequest,
|
||||
UpdateWheelConfigRequest,
|
||||
WheelPrizeAdminResponse,
|
||||
WheelStatisticsResponse,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/admin/wheel", tags=["Admin Fortune Wheel"])
|
||||
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
|
||||
|
||||
|
||||
@router.get("/config", response_model=AdminWheelConfigResponse)
|
||||
logger = structlog.get_logger(__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),
|
||||
@@ -90,7 +90,7 @@ async def get_admin_wheel_config(
|
||||
)
|
||||
|
||||
|
||||
@router.put("/config", response_model=AdminWheelConfigResponse)
|
||||
@router.put('/config', response_model=AdminWheelConfigResponse)
|
||||
async def update_admin_wheel_config(
|
||||
request: UpdateWheelConfigRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
@@ -102,12 +102,12 @@ async def update_admin_wheel_config(
|
||||
if not update_data:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="No fields to update",
|
||||
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}")
|
||||
logger.info('🎡 Admin updated wheel config', telegram_id=admin.telegram_id, update_data=update_data)
|
||||
|
||||
# Возвращаем полную конфигурацию
|
||||
prizes = await get_wheel_prizes(db, config.id, active_only=False)
|
||||
@@ -153,7 +153,7 @@ async def update_admin_wheel_config(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/prizes", response_model=List[WheelPrizeAdminResponse])
|
||||
@router.get('/prizes', response_model=list[WheelPrizeAdminResponse])
|
||||
async def get_prizes(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
@@ -185,7 +185,7 @@ async def get_prizes(
|
||||
]
|
||||
|
||||
|
||||
@router.post("/prizes", response_model=WheelPrizeAdminResponse, status_code=status.HTTP_201_CREATED)
|
||||
@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),
|
||||
@@ -211,7 +211,7 @@ async def create_prize(
|
||||
promo_traffic_gb=request.promo_traffic_gb,
|
||||
)
|
||||
|
||||
logger.info(f"🎁 Admin {admin.telegram_id} created prize: {prize.display_name}")
|
||||
logger.info('🎁 Admin created prize', telegram_id=admin.telegram_id, display_name=prize.display_name)
|
||||
|
||||
return WheelPrizeAdminResponse(
|
||||
id=prize.id,
|
||||
@@ -233,7 +233,7 @@ async def create_prize(
|
||||
)
|
||||
|
||||
|
||||
@router.put("/prizes/{prize_id}", response_model=WheelPrizeAdminResponse)
|
||||
@router.put('/prizes/{prize_id}', response_model=WheelPrizeAdminResponse)
|
||||
async def update_prize(
|
||||
prize_id: int,
|
||||
request: UpdatePrizeRequest,
|
||||
@@ -244,13 +244,13 @@ async def update_prize(
|
||||
update_data = request.model_dump(exclude_unset=True)
|
||||
|
||||
# Конвертируем enum в строку если есть
|
||||
if 'prize_type' in update_data and update_data['prize_type']:
|
||||
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",
|
||||
detail='No fields to update',
|
||||
)
|
||||
|
||||
prize = await update_wheel_prize(db, prize_id, **update_data)
|
||||
@@ -258,10 +258,10 @@ async def update_prize(
|
||||
if not prize:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Prize not found",
|
||||
detail='Prize not found',
|
||||
)
|
||||
|
||||
logger.info(f"🎁 Admin {admin.telegram_id} updated prize {prize_id}: {update_data}")
|
||||
logger.info('🎁 Admin updated prize', telegram_id=admin.telegram_id, prize_id=prize_id, update_data=update_data)
|
||||
|
||||
return WheelPrizeAdminResponse(
|
||||
id=prize.id,
|
||||
@@ -283,7 +283,7 @@ async def update_prize(
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/prizes/{prize_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
@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),
|
||||
@@ -295,13 +295,13 @@ async def delete_prize_endpoint(
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Prize not found",
|
||||
detail='Prize not found',
|
||||
)
|
||||
|
||||
logger.info(f"🗑️ Admin {admin.telegram_id} deleted prize {prize_id}")
|
||||
logger.info('🗑️ Admin deleted prize', telegram_id=admin.telegram_id, prize_id=prize_id)
|
||||
|
||||
|
||||
@router.post("/prizes/reorder", status_code=status.HTTP_200_OK)
|
||||
@router.post('/prizes/reorder', status_code=status.HTTP_200_OK)
|
||||
async def reorder_prizes(
|
||||
request: ReorderPrizesRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
@@ -309,14 +309,14 @@ async def reorder_prizes(
|
||||
):
|
||||
"""Переупорядочить призы."""
|
||||
await reorder_wheel_prizes(db, request.prize_ids)
|
||||
logger.info(f"🔄 Admin {admin.telegram_id} reordered prizes: {request.prize_ids}")
|
||||
return {"success": True}
|
||||
logger.info('🔄 Admin reordered prizes', telegram_id=admin.telegram_id, prize_ids=request.prize_ids)
|
||||
return {'success': True}
|
||||
|
||||
|
||||
@router.get("/statistics", response_model=WheelStatisticsResponse)
|
||||
@router.get('/statistics', response_model=WheelStatisticsResponse)
|
||||
async def get_statistics(
|
||||
date_from: Optional[datetime] = Query(None),
|
||||
date_to: Optional[datetime] = Query(None),
|
||||
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),
|
||||
):
|
||||
@@ -324,24 +324,24 @@ async def get_statistics(
|
||||
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"],
|
||||
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)
|
||||
@router.get('/spins', response_model=AdminSpinsResponse)
|
||||
async def get_all_spins_endpoint(
|
||||
user_id: Optional[int] = Query(None),
|
||||
date_from: Optional[datetime] = Query(None),
|
||||
date_to: Optional[datetime] = Query(None),
|
||||
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),
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
"""Admin routes for managing withdrawal requests in cabinet."""
|
||||
|
||||
import json
|
||||
from typing import Literal
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import desc, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import (
|
||||
ReferralEarning,
|
||||
User,
|
||||
WithdrawalRequest,
|
||||
WithdrawalRequestStatus,
|
||||
)
|
||||
from app.services.referral_withdrawal_service import referral_withdrawal_service
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
from ..schemas.withdrawals import (
|
||||
AdminApproveWithdrawalRequest,
|
||||
AdminRejectWithdrawalRequest,
|
||||
AdminWithdrawalDetailResponse,
|
||||
AdminWithdrawalItem,
|
||||
AdminWithdrawalListResponse,
|
||||
)
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/withdrawals', tags=['Cabinet Admin Withdrawals'])
|
||||
|
||||
|
||||
def _get_risk_level(risk_score: int) -> str:
|
||||
"""Get risk level from score."""
|
||||
if risk_score >= 70:
|
||||
return 'critical'
|
||||
if risk_score >= 50:
|
||||
return 'high'
|
||||
if risk_score >= 30:
|
||||
return 'medium'
|
||||
return 'low'
|
||||
|
||||
|
||||
@router.get('', response_model=AdminWithdrawalListResponse)
|
||||
async def list_withdrawals(
|
||||
withdrawal_status: Literal['pending', 'approved', 'rejected', 'completed', 'cancelled'] | None = Query(
|
||||
None, alias='status'
|
||||
),
|
||||
offset: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""List all withdrawal requests."""
|
||||
query = select(WithdrawalRequest)
|
||||
count_query = select(func.count()).select_from(WithdrawalRequest)
|
||||
|
||||
if withdrawal_status:
|
||||
query = query.where(WithdrawalRequest.status == withdrawal_status)
|
||||
count_query = count_query.where(WithdrawalRequest.status == withdrawal_status)
|
||||
|
||||
total_result = await db.execute(count_query)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
# Pending stats
|
||||
pending_count_result = await db.execute(
|
||||
select(func.count())
|
||||
.select_from(WithdrawalRequest)
|
||||
.where(WithdrawalRequest.status == WithdrawalRequestStatus.PENDING.value)
|
||||
)
|
||||
pending_count = pending_count_result.scalar() or 0
|
||||
|
||||
pending_total_result = await db.execute(
|
||||
select(func.coalesce(func.sum(WithdrawalRequest.amount_kopeks), 0)).where(
|
||||
WithdrawalRequest.status == WithdrawalRequestStatus.PENDING.value
|
||||
)
|
||||
)
|
||||
pending_total = pending_total_result.scalar() or 0
|
||||
|
||||
query = query.order_by(desc(WithdrawalRequest.created_at)).offset(offset).limit(limit)
|
||||
result = await db.execute(query)
|
||||
withdrawals = result.scalars().all()
|
||||
|
||||
# Batch-fetch users to avoid N+1
|
||||
user_ids = list({w.user_id for w in withdrawals})
|
||||
if user_ids:
|
||||
users_result = await db.execute(select(User).where(User.id.in_(user_ids)))
|
||||
users_map = {u.id: u for u in users_result.scalars().all()}
|
||||
else:
|
||||
users_map = {}
|
||||
|
||||
items = []
|
||||
for w in withdrawals:
|
||||
user = users_map.get(w.user_id)
|
||||
items.append(
|
||||
AdminWithdrawalItem(
|
||||
id=w.id,
|
||||
user_id=w.user_id,
|
||||
username=user.username if user else None,
|
||||
first_name=user.first_name if user else None,
|
||||
telegram_id=user.telegram_id if user else None,
|
||||
amount_kopeks=w.amount_kopeks,
|
||||
amount_rubles=w.amount_kopeks / 100,
|
||||
status=w.status,
|
||||
risk_score=w.risk_score or 0,
|
||||
risk_level=_get_risk_level(w.risk_score or 0),
|
||||
payment_details=w.payment_details,
|
||||
admin_comment=w.admin_comment,
|
||||
created_at=w.created_at,
|
||||
processed_at=w.processed_at,
|
||||
)
|
||||
)
|
||||
|
||||
return AdminWithdrawalListResponse(
|
||||
items=items,
|
||||
total=total,
|
||||
pending_count=pending_count,
|
||||
pending_total_kopeks=pending_total,
|
||||
)
|
||||
|
||||
|
||||
@router.get('/{withdrawal_id}', response_model=AdminWithdrawalDetailResponse)
|
||||
async def get_withdrawal_detail(
|
||||
withdrawal_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get detailed withdrawal request with risk analysis."""
|
||||
withdrawal = await db.get(WithdrawalRequest, withdrawal_id)
|
||||
if not withdrawal:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Заявка не найдена',
|
||||
)
|
||||
|
||||
user = await db.get(User, withdrawal.user_id)
|
||||
|
||||
# Parse risk analysis
|
||||
risk_analysis = None
|
||||
if withdrawal.risk_analysis:
|
||||
try:
|
||||
risk_analysis = json.loads(withdrawal.risk_analysis)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
# Get referral stats
|
||||
referral_count = await db.execute(
|
||||
select(func.count()).select_from(User).where(User.referred_by_id == withdrawal.user_id)
|
||||
)
|
||||
total_earnings = await db.execute(
|
||||
select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0)).where(
|
||||
ReferralEarning.user_id == withdrawal.user_id
|
||||
)
|
||||
)
|
||||
|
||||
return AdminWithdrawalDetailResponse(
|
||||
id=withdrawal.id,
|
||||
user_id=withdrawal.user_id,
|
||||
username=user.username if user else None,
|
||||
first_name=user.first_name if user else None,
|
||||
telegram_id=user.telegram_id if user else None,
|
||||
amount_kopeks=withdrawal.amount_kopeks,
|
||||
amount_rubles=withdrawal.amount_kopeks / 100,
|
||||
status=withdrawal.status,
|
||||
risk_score=withdrawal.risk_score or 0,
|
||||
risk_level=_get_risk_level(withdrawal.risk_score or 0),
|
||||
risk_analysis=risk_analysis,
|
||||
payment_details=withdrawal.payment_details,
|
||||
admin_comment=withdrawal.admin_comment,
|
||||
balance_kopeks=user.balance_kopeks if user else 0,
|
||||
total_referrals=referral_count.scalar() or 0,
|
||||
total_earnings_kopeks=total_earnings.scalar() or 0,
|
||||
created_at=withdrawal.created_at,
|
||||
processed_at=withdrawal.processed_at,
|
||||
)
|
||||
|
||||
|
||||
@router.post('/{withdrawal_id}/approve')
|
||||
async def approve_withdrawal(
|
||||
withdrawal_id: int,
|
||||
request: AdminApproveWithdrawalRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Approve a withdrawal request."""
|
||||
success, error = await referral_withdrawal_service.approve_request(
|
||||
db,
|
||||
request_id=withdrawal_id,
|
||||
admin_id=admin.id,
|
||||
comment=request.comment,
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=error,
|
||||
)
|
||||
|
||||
# Notify user about approval
|
||||
try:
|
||||
from aiogram import Bot
|
||||
|
||||
from app.config import settings
|
||||
from app.services.notification_delivery_service import notification_delivery_service
|
||||
|
||||
if settings.BOT_TOKEN:
|
||||
withdrawal = await db.get(WithdrawalRequest, withdrawal_id)
|
||||
user = await db.get(User, withdrawal.user_id) if withdrawal else None
|
||||
if user and withdrawal:
|
||||
formatted_amount = settings.format_price(withdrawal.amount_kopeks)
|
||||
comment_text = f'\n{request.comment}' if request.comment else ''
|
||||
tg_message = f'✅ Ваш запрос на вывод {formatted_amount} одобрен.{comment_text}'
|
||||
bot = Bot(token=settings.BOT_TOKEN)
|
||||
try:
|
||||
await notification_delivery_service.notify_withdrawal_approved(
|
||||
user=user,
|
||||
amount_kopeks=withdrawal.amount_kopeks,
|
||||
comment=request.comment,
|
||||
bot=bot,
|
||||
telegram_message=tg_message,
|
||||
)
|
||||
finally:
|
||||
await bot.session.close()
|
||||
except Exception as e:
|
||||
logger.error('Failed to send withdrawal approval notification', error=e)
|
||||
|
||||
return {'success': True}
|
||||
|
||||
|
||||
@router.post('/{withdrawal_id}/reject')
|
||||
async def reject_withdrawal(
|
||||
withdrawal_id: int,
|
||||
request: AdminRejectWithdrawalRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Reject a withdrawal request."""
|
||||
success, error = await referral_withdrawal_service.reject_request(
|
||||
db,
|
||||
request_id=withdrawal_id,
|
||||
admin_id=admin.id,
|
||||
comment=request.comment,
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=error or 'Не удалось отклонить заявку',
|
||||
)
|
||||
|
||||
# Notify user about rejection
|
||||
try:
|
||||
from aiogram import Bot
|
||||
|
||||
from app.config import settings
|
||||
from app.services.notification_delivery_service import notification_delivery_service
|
||||
|
||||
if settings.BOT_TOKEN:
|
||||
withdrawal = await db.get(WithdrawalRequest, withdrawal_id)
|
||||
user = await db.get(User, withdrawal.user_id) if withdrawal else None
|
||||
if user and withdrawal:
|
||||
formatted_amount = settings.format_price(withdrawal.amount_kopeks)
|
||||
comment_text = f'\nПричина: {request.comment}' if request.comment else ''
|
||||
tg_message = f'❌ Ваш запрос на вывод {formatted_amount} отклонён.{comment_text}'
|
||||
bot = Bot(token=settings.BOT_TOKEN)
|
||||
try:
|
||||
await notification_delivery_service.notify_withdrawal_rejected(
|
||||
user=user,
|
||||
amount_kopeks=withdrawal.amount_kopeks,
|
||||
comment=request.comment,
|
||||
bot=bot,
|
||||
telegram_message=tg_message,
|
||||
)
|
||||
finally:
|
||||
await bot.session.close()
|
||||
except Exception as e:
|
||||
logger.error('Failed to send withdrawal rejection notification', error=e)
|
||||
|
||||
return {'success': True}
|
||||
|
||||
|
||||
@router.post('/{withdrawal_id}/complete')
|
||||
async def complete_withdrawal(
|
||||
withdrawal_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Mark a withdrawal as completed (money transferred)."""
|
||||
success, error = await referral_withdrawal_service.complete_request(
|
||||
db,
|
||||
request_id=withdrawal_id,
|
||||
admin_id=admin.id,
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=error or 'Не удалось завершить заявку',
|
||||
)
|
||||
|
||||
return {'success': True}
|
||||
+816
-144
File diff suppressed because it is too large
Load Diff
+434
-403
File diff suppressed because it is too large
Load Diff
+320
-147
@@ -1,160 +1,213 @@
|
||||
"""Branding routes for cabinet - logo, project name, and theme colors management."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import User, SystemSetting
|
||||
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"])
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/branding', tags=['Branding'])
|
||||
|
||||
# Directory for storing branding assets
|
||||
BRANDING_DIR = Path("data/branding")
|
||||
LOGO_EXTENSIONS = [".png", ".jpg", ".jpeg", ".webp", ".svg"]
|
||||
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"
|
||||
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"}
|
||||
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: Optional[str] = None
|
||||
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"
|
||||
|
||||
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: Optional[str] = None
|
||||
darkBackground: Optional[str] = None
|
||||
darkSurface: Optional[str] = None
|
||||
darkText: Optional[str] = None
|
||||
darkTextSecondary: Optional[str] = None
|
||||
lightBackground: Optional[str] = None
|
||||
lightSurface: Optional[str] = None
|
||||
lightText: Optional[str] = None
|
||||
lightTextSecondary: Optional[str] = None
|
||||
success: Optional[str] = None
|
||||
warning: Optional[str] = None
|
||||
error: Optional[str] = None
|
||||
|
||||
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: Optional[bool] = None
|
||||
light: Optional[bool] = None
|
||||
|
||||
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",
|
||||
'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) -> Optional[str]:
|
||||
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)
|
||||
)
|
||||
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)
|
||||
)
|
||||
result = await db.execute(select(SystemSetting).where(SystemSetting.key == key))
|
||||
setting = result.scalar_one_or_none()
|
||||
|
||||
if setting:
|
||||
@@ -166,14 +219,14 @@ async def set_setting_value(db: AsyncSession, key: str, value: str):
|
||||
await db.commit()
|
||||
|
||||
|
||||
def get_logo_path() -> Optional[Path]:
|
||||
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}"
|
||||
logo_path = BRANDING_DIR / f'logo{ext}'
|
||||
if logo_path.exists():
|
||||
return logo_path
|
||||
|
||||
@@ -187,7 +240,8 @@ def has_custom_logo() -> bool:
|
||||
|
||||
# ============ Routes ============
|
||||
|
||||
@router.get("", response_model=BrandingResponse)
|
||||
|
||||
@router.get('', response_model=BrandingResponse)
|
||||
async def get_branding(
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
@@ -198,24 +252,23 @@ async def get_branding(
|
||||
# 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')
|
||||
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"
|
||||
logo_letter = name[0].upper() if name else 'V'
|
||||
|
||||
return BrandingResponse(
|
||||
name=name,
|
||||
logo_url="/cabinet/branding/logo" if custom_logo else None,
|
||||
logo_url='/cabinet/branding/logo' if custom_logo else None,
|
||||
logo_letter=logo_letter,
|
||||
has_custom_logo=custom_logo,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/logo")
|
||||
@router.get('/logo')
|
||||
async def get_logo():
|
||||
"""
|
||||
Get the custom logo image.
|
||||
@@ -224,61 +277,51 @@ async def get_logo():
|
||||
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"
|
||||
)
|
||||
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",
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.webp': 'image/webp',
|
||||
'.svg': 'image/svg+xml',
|
||||
}
|
||||
media_type = media_types.get(suffix, "image/png")
|
||||
media_type = media_types.get(suffix, 'image/png')
|
||||
|
||||
return FileResponse(
|
||||
logo_path,
|
||||
media_type=media_type,
|
||||
headers={"Cache-Control": "public, max-age=3600"}
|
||||
)
|
||||
return FileResponse(logo_path, media_type=media_type, headers={'Cache-Control': 'public, max-age=3600'})
|
||||
|
||||
|
||||
@router.put("/name", response_model=BrandingResponse)
|
||||
@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 ""
|
||||
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)"
|
||||
)
|
||||
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}")
|
||||
logger.info('Admin updated branding name to', telegram_id=admin.telegram_id, name=name)
|
||||
|
||||
# Return updated branding
|
||||
custom_logo = has_custom_logo()
|
||||
logo_letter = name[0].upper() if name else "C"
|
||||
logo_letter = name[0].upper() if name else 'C'
|
||||
|
||||
return BrandingResponse(
|
||||
name=name,
|
||||
logo_url="/cabinet/branding/logo" if custom_logo else None,
|
||||
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)
|
||||
@router.post('/logo', response_model=BrandingResponse)
|
||||
async def upload_logo(
|
||||
file: UploadFile = File(...),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
@@ -288,8 +331,7 @@ async def upload_logo(
|
||||
# Validate content type
|
||||
if file.content_type not in ALLOWED_CONTENT_TYPES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid file type. Allowed: PNG, JPEG, WebP, SVG"
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail='Invalid file type. Allowed: PNG, JPEG, WebP, SVG'
|
||||
)
|
||||
|
||||
# Read file content
|
||||
@@ -299,7 +341,7 @@ async def upload_logo(
|
||||
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"
|
||||
detail=f'File too large. Maximum size: {MAX_FILE_SIZE // 1024 // 1024}MB',
|
||||
)
|
||||
|
||||
# Ensure directory exists
|
||||
@@ -307,65 +349,63 @@ async def upload_logo(
|
||||
|
||||
# Determine file extension from content type
|
||||
ext_map = {
|
||||
"image/png": ".png",
|
||||
"image/jpeg": ".jpg",
|
||||
"image/jpg": ".jpg",
|
||||
"image/webp": ".webp",
|
||||
"image/svg+xml": ".svg",
|
||||
'image/png': '.png',
|
||||
'image/jpeg': '.jpg',
|
||||
'image/jpg': '.jpg',
|
||||
'image/webp': '.webp',
|
||||
'image/svg+xml': '.svg',
|
||||
}
|
||||
extension = ext_map.get(file.content_type, ".png")
|
||||
extension = ext_map.get(file.content_type, '.png')
|
||||
|
||||
# Remove old logo files with any extension
|
||||
for old_file in BRANDING_DIR.glob("logo.*"):
|
||||
for old_file in BRANDING_DIR.glob('logo.*'):
|
||||
old_file.unlink()
|
||||
|
||||
# Save new logo
|
||||
logo_path = BRANDING_DIR / f"logo{extension}"
|
||||
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")
|
||||
await set_setting_value(db, BRANDING_LOGO_KEY, 'custom')
|
||||
|
||||
logger.info(f"Admin {admin.telegram_id} uploaded new logo: {logo_path}")
|
||||
logger.info('Admin uploaded new logo', telegram_id=admin.telegram_id, logo_path=logo_path)
|
||||
|
||||
# Get current name for response
|
||||
name = await get_setting_value(db, BRANDING_NAME_KEY)
|
||||
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')
|
||||
name = getattr(settings, 'CABINET_BRANDING_NAME', None) or os.getenv('VITE_APP_NAME', 'Cabinet')
|
||||
|
||||
logo_letter = name[0].upper() if name else "C"
|
||||
logo_letter = name[0].upper() if name else 'C'
|
||||
|
||||
return BrandingResponse(
|
||||
name=name,
|
||||
logo_url="/cabinet/branding/logo",
|
||||
logo_url='/cabinet/branding/logo',
|
||||
logo_letter=logo_letter,
|
||||
has_custom_logo=True,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/logo", response_model=BrandingResponse)
|
||||
@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.*"):
|
||||
for old_file in BRANDING_DIR.glob('logo.*'):
|
||||
old_file.unlink()
|
||||
|
||||
# Update setting
|
||||
await set_setting_value(db, BRANDING_LOGO_KEY, "default")
|
||||
await set_setting_value(db, BRANDING_LOGO_KEY, 'default')
|
||||
|
||||
logger.info(f"Admin {admin.telegram_id} deleted custom logo")
|
||||
logger.info('Admin deleted custom logo', telegram_id=admin.telegram_id)
|
||||
|
||||
# Get current name for response
|
||||
name = await get_setting_value(db, BRANDING_NAME_KEY)
|
||||
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')
|
||||
name = getattr(settings, 'CABINET_BRANDING_NAME', None) or os.getenv('VITE_APP_NAME', 'Cabinet')
|
||||
|
||||
logo_letter = name[0].upper() if name else "C"
|
||||
logo_letter = name[0].upper() if name else 'C'
|
||||
|
||||
return BrandingResponse(
|
||||
name=name,
|
||||
@@ -377,11 +417,12 @@ async def delete_logo(
|
||||
|
||||
# ============ 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("#"):
|
||||
if not color.startswith('#'):
|
||||
return False
|
||||
hex_part = color[1:]
|
||||
if len(hex_part) not in (3, 6):
|
||||
@@ -393,7 +434,7 @@ def validate_hex_color(color: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
@router.get("/colors", response_model=ThemeColorsResponse)
|
||||
@router.get('/colors', response_model=ThemeColorsResponse)
|
||||
async def get_theme_colors(
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
@@ -415,7 +456,7 @@ async def get_theme_colors(
|
||||
return ThemeColorsResponse(**DEFAULT_THEME_COLORS)
|
||||
|
||||
|
||||
@router.patch("/colors", response_model=ThemeColorsResponse)
|
||||
@router.patch('/colors', response_model=ThemeColorsResponse)
|
||||
async def update_theme_colors(
|
||||
payload: ThemeColorsUpdate,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
@@ -438,22 +479,19 @@ async def update_theme_colors(
|
||||
# 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}"
|
||||
)
|
||||
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())}")
|
||||
logger.info('Admin updated theme colors', telegram_id=admin.telegram_id, value=list(update_data.keys()))
|
||||
|
||||
return ThemeColorsResponse(**current_colors)
|
||||
|
||||
|
||||
@router.post("/colors/reset", response_model=ThemeColorsResponse)
|
||||
@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),
|
||||
@@ -462,17 +500,17 @@ async def reset_theme_colors(
|
||||
# Save default colors
|
||||
await set_setting_value(db, THEME_COLORS_KEY, json.dumps(DEFAULT_THEME_COLORS))
|
||||
|
||||
logger.info(f"Admin {admin.telegram_id} reset theme colors to defaults")
|
||||
logger.info('Admin reset theme colors to defaults', telegram_id=admin.telegram_id)
|
||||
|
||||
return ThemeColorsResponse(**DEFAULT_THEME_COLORS)
|
||||
|
||||
|
||||
# ============ Enabled Themes Routes ============
|
||||
|
||||
DEFAULT_ENABLED_THEMES = {"dark": True, "light": True}
|
||||
DEFAULT_ENABLED_THEMES = {'dark': True, 'light': True}
|
||||
|
||||
|
||||
@router.get("/themes", response_model=EnabledThemesResponse)
|
||||
@router.get('/themes', response_model=EnabledThemesResponse)
|
||||
async def get_enabled_themes(
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
@@ -492,7 +530,7 @@ async def get_enabled_themes(
|
||||
return EnabledThemesResponse(**DEFAULT_ENABLED_THEMES)
|
||||
|
||||
|
||||
@router.patch("/themes", response_model=EnabledThemesResponse)
|
||||
@router.patch('/themes', response_model=EnabledThemesResponse)
|
||||
async def update_enabled_themes(
|
||||
payload: EnabledThemesUpdate,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
@@ -514,23 +552,21 @@ async def update_enabled_themes(
|
||||
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"
|
||||
)
|
||||
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}")
|
||||
logger.info('Admin updated enabled themes', telegram_id=admin.telegram_id, current_themes=current_themes)
|
||||
|
||||
return EnabledThemesResponse(**current_themes)
|
||||
|
||||
|
||||
# ============ Animation Routes ============
|
||||
|
||||
@router.get("/animation", response_model=AnimationEnabledResponse)
|
||||
|
||||
@router.get('/animation', response_model=AnimationEnabledResponse)
|
||||
async def get_animation_enabled(
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
@@ -541,14 +577,14 @@ async def get_animation_enabled(
|
||||
animation_value = await get_setting_value(db, ANIMATION_ENABLED_KEY)
|
||||
|
||||
if animation_value is not None:
|
||||
enabled = animation_value.lower() == "true"
|
||||
enabled = animation_value.lower() == 'true'
|
||||
return AnimationEnabledResponse(enabled=enabled)
|
||||
|
||||
# Default: enabled
|
||||
return AnimationEnabledResponse(enabled=True)
|
||||
|
||||
|
||||
@router.patch("/animation", response_model=AnimationEnabledResponse)
|
||||
@router.patch('/animation', response_model=AnimationEnabledResponse)
|
||||
async def update_animation_enabled(
|
||||
payload: AnimationEnabledUpdate,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
@@ -557,14 +593,15 @@ async def update_animation_enabled(
|
||||
"""Update animation enabled setting. Admin only."""
|
||||
await set_setting_value(db, ANIMATION_ENABLED_KEY, str(payload.enabled).lower())
|
||||
|
||||
logger.info(f"Admin {admin.telegram_id} set animation enabled: {payload.enabled}")
|
||||
logger.info('Admin set animation enabled', telegram_id=admin.telegram_id, enabled=payload.enabled)
|
||||
|
||||
return AnimationEnabledResponse(enabled=payload.enabled)
|
||||
|
||||
|
||||
# ============ Fullscreen Routes ============
|
||||
|
||||
@router.get("/fullscreen", response_model=FullscreenEnabledResponse)
|
||||
|
||||
@router.get('/fullscreen', response_model=FullscreenEnabledResponse)
|
||||
async def get_fullscreen_enabled(
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
@@ -575,14 +612,14 @@ async def get_fullscreen_enabled(
|
||||
fullscreen_value = await get_setting_value(db, FULLSCREEN_ENABLED_KEY)
|
||||
|
||||
if fullscreen_value is not None:
|
||||
enabled = fullscreen_value.lower() == "true"
|
||||
enabled = fullscreen_value.lower() == 'true'
|
||||
return FullscreenEnabledResponse(enabled=enabled)
|
||||
|
||||
# Default: disabled
|
||||
return FullscreenEnabledResponse(enabled=False)
|
||||
|
||||
|
||||
@router.patch("/fullscreen", response_model=FullscreenEnabledResponse)
|
||||
@router.patch('/fullscreen', response_model=FullscreenEnabledResponse)
|
||||
async def update_fullscreen_enabled(
|
||||
payload: FullscreenEnabledUpdate,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
@@ -591,6 +628,142 @@ async def update_fullscreen_enabled(
|
||||
"""Update fullscreen enabled setting. Admin only."""
|
||||
await set_setting_value(db, FULLSCREEN_ENABLED_KEY, str(payload.enabled).lower())
|
||||
|
||||
logger.info(f"Admin {admin.telegram_id} set fullscreen enabled: {payload.enabled}")
|
||||
logger.info('Admin set fullscreen enabled', telegram_id=admin.telegram_id, enabled=payload.enabled)
|
||||
|
||||
return FullscreenEnabledResponse(enabled=payload.enabled)
|
||||
|
||||
|
||||
# ============ 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('Admin set email auth enabled', telegram_id=admin.telegram_id, 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('Admin updated analytics counters', telegram_id=admin.telegram_id)
|
||||
|
||||
# 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('Admin set lite mode enabled', telegram_id=admin.telegram_id, enabled=payload.enabled)
|
||||
|
||||
return LiteModeEnabledResponse(enabled=payload.enabled)
|
||||
|
||||
+116
-112
@@ -1,47 +1,50 @@
|
||||
"""Contests routes for cabinet - user participation in games/contests."""
|
||||
|
||||
import logging
|
||||
import random
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Optional, Dict, Any
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import User, SubscriptionStatus
|
||||
from app.database.crud.contest import (
|
||||
create_attempt,
|
||||
get_active_rounds,
|
||||
get_attempt,
|
||||
create_attempt,
|
||||
increment_winner_count,
|
||||
)
|
||||
from app.database.crud.subscription import get_subscription_by_user_id, extend_subscription
|
||||
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_QUEST,
|
||||
GAME_LOCKS,
|
||||
GAME_CIPHER,
|
||||
GAME_SERVER,
|
||||
GAME_BLITZ,
|
||||
GAME_EMOJI,
|
||||
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"])
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/contests', tags=['Cabinet Contests'])
|
||||
|
||||
|
||||
# ============ Schemas ============
|
||||
|
||||
|
||||
class ContestInfo(BaseModel):
|
||||
"""Contest/game info."""
|
||||
|
||||
id: int
|
||||
slug: str
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
description: str | None = None
|
||||
prize_type: str
|
||||
prize_value: str
|
||||
is_available: bool
|
||||
@@ -50,28 +53,32 @@ class ContestInfo(BaseModel):
|
||||
|
||||
class ContestGameData(BaseModel):
|
||||
"""Data for playing a contest game."""
|
||||
|
||||
round_id: int
|
||||
game_type: str
|
||||
game_data: Dict[str, Any]
|
||||
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: Optional[str] = None
|
||||
prize_value: Optional[str] = None
|
||||
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:
|
||||
@@ -84,55 +91,57 @@ def _user_allowed(subscription) -> bool:
|
||||
|
||||
async def _award_prize(db: AsyncSession, user_id: int, prize_type: str, prize_value: str) -> str:
|
||||
"""Award prize to winner."""
|
||||
if prize_type == "days":
|
||||
if prize_type == 'days':
|
||||
try:
|
||||
days = int(prize_value)
|
||||
except ValueError:
|
||||
return "Error: invalid prize value"
|
||||
return 'Error: invalid prize value'
|
||||
|
||||
subscription = await get_subscription_by_user_id(db, user_id)
|
||||
if not subscription:
|
||||
return "Error: subscription not found"
|
||||
return 'Error: subscription not found'
|
||||
|
||||
subscription.end_date = subscription.end_date + timedelta(days=days)
|
||||
subscription.updated_at = datetime.utcnow()
|
||||
subscription.updated_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
logger.info(f"🎁 Extended subscription for user {user_id} by {days} days (contest prize)")
|
||||
return f"Subscription extended by {days} days"
|
||||
logger.info('🎁 Extended subscription for user by days (contest prize)', user_id=user_id, days=days)
|
||||
return f'Subscription extended by {days} days'
|
||||
|
||||
elif prize_type == "balance":
|
||||
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"
|
||||
return 'Error: invalid prize value'
|
||||
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
return "Error: user not found"
|
||||
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.info('🎁 Added to balance for user (contest prize)', amount=amount, user_id=user_id)
|
||||
return f'Balance increased by {amount}'
|
||||
|
||||
else:
|
||||
logger.warning(f"Unknown prize type: {prize_type}")
|
||||
return f"Prize type '{prize_type}' not supported"
|
||||
logger.warning('Unknown prize type', 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)
|
||||
@router.get('/count', response_model=ContestsCountResponse)
|
||||
async def get_contests_count(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
@@ -151,7 +160,7 @@ async def get_contests_count(
|
||||
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 ""
|
||||
tpl_slug = rnd.template.slug if rnd.template else ''
|
||||
if tpl_slug in seen_templates:
|
||||
continue
|
||||
seen_templates.add(tpl_slug)
|
||||
@@ -164,7 +173,7 @@ async def get_contests_count(
|
||||
return ContestsCountResponse(count=count)
|
||||
|
||||
|
||||
@router.get("", response_model=List[ContestInfo])
|
||||
@router.get('', response_model=list[ContestInfo])
|
||||
async def get_contests(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
@@ -175,7 +184,7 @@ async def get_contests(
|
||||
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",
|
||||
detail='Contests are only available for users with active or trial subscriptions',
|
||||
)
|
||||
|
||||
active_rounds = await get_active_rounds(db)
|
||||
@@ -185,7 +194,7 @@ async def get_contests(
|
||||
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 ""
|
||||
tpl_slug = rnd.template.slug if rnd.template else ''
|
||||
if tpl_slug not in unique_templates:
|
||||
unique_templates[tpl_slug] = rnd
|
||||
|
||||
@@ -194,21 +203,23 @@ async def get_contests(
|
||||
# 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,
|
||||
))
|
||||
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)
|
||||
@router.get('/{round_id}', response_model=ContestGameData)
|
||||
async def get_contest_game(
|
||||
round_id: int,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
@@ -220,7 +231,7 @@ async def get_contest_game(
|
||||
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",
|
||||
detail='Contests are only available for users with active or trial subscriptions',
|
||||
)
|
||||
|
||||
active_rounds = await get_active_rounds(db)
|
||||
@@ -229,13 +240,13 @@ async def get_contest_game(
|
||||
if not round_obj:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Contest round not found or already finished",
|
||||
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",
|
||||
detail='This contest is disabled',
|
||||
)
|
||||
|
||||
# Check if already played
|
||||
@@ -243,80 +254,80 @@ async def get_contest_game(
|
||||
if attempt:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="You have already played this round",
|
||||
detail='You have already played this round',
|
||||
)
|
||||
|
||||
tpl = round_obj.template
|
||||
game_type = tpl.slug
|
||||
game_data = {}
|
||||
instructions = ""
|
||||
instructions = ''
|
||||
|
||||
if game_type == GAME_QUEST:
|
||||
rows = round_obj.payload.get("rows", 3)
|
||||
cols = round_obj.payload.get("cols", 3)
|
||||
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,
|
||||
'rows': rows,
|
||||
'cols': cols,
|
||||
'secret': secret,
|
||||
'grid_size': rows * cols,
|
||||
}
|
||||
instructions = "Select one of the nodes in the grid. Find the hidden server!"
|
||||
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)
|
||||
total = round_obj.payload.get('total', 20)
|
||||
secret = random.randint(0, total - 1)
|
||||
game_data = {
|
||||
"total": total,
|
||||
"secret": secret,
|
||||
'total': total,
|
||||
'secret': secret,
|
||||
}
|
||||
instructions = "Find the unlocked button among the locks!"
|
||||
instructions = 'Find the unlocked button among the locks!'
|
||||
|
||||
elif game_type == GAME_SERVER:
|
||||
flags = round_obj.payload.get("flags") or []
|
||||
flags = round_obj.payload.get('flags') or []
|
||||
shuffled_flags = flags.copy()
|
||||
random.shuffle(shuffled_flags)
|
||||
game_data = {
|
||||
"flags": shuffled_flags,
|
||||
'flags': shuffled_flags,
|
||||
}
|
||||
instructions = "Choose a server by clicking on a flag!"
|
||||
instructions = 'Choose a server by clicking on a flag!'
|
||||
|
||||
elif game_type == GAME_CIPHER:
|
||||
question = round_obj.payload.get("question", "")
|
||||
question = round_obj.payload.get('question', '')
|
||||
game_data = {
|
||||
"question": question,
|
||||
"input_type": "text",
|
||||
'question': question,
|
||||
'input_type': 'text',
|
||||
}
|
||||
instructions = "Decrypt the cipher and enter the answer!"
|
||||
instructions = 'Decrypt the cipher and enter the answer!'
|
||||
|
||||
elif game_type == GAME_EMOJI:
|
||||
question = round_obj.payload.get("question", "🤔")
|
||||
question = round_obj.payload.get('question', '🤔')
|
||||
emoji_list = question.split()
|
||||
random.shuffle(emoji_list)
|
||||
game_data = {
|
||||
"question": " ".join(emoji_list),
|
||||
"input_type": "text",
|
||||
'question': ' '.join(emoji_list),
|
||||
'input_type': 'text',
|
||||
}
|
||||
instructions = "Guess the service by emojis!"
|
||||
instructions = 'Guess the service by emojis!'
|
||||
|
||||
elif game_type == GAME_ANAGRAM:
|
||||
letters = round_obj.payload.get("letters", "")
|
||||
letters = round_obj.payload.get('letters', '')
|
||||
game_data = {
|
||||
"letters": letters,
|
||||
"input_type": "text",
|
||||
'letters': letters,
|
||||
'input_type': 'text',
|
||||
}
|
||||
instructions = "Make a word from the given letters!"
|
||||
instructions = 'Make a word from the given letters!'
|
||||
|
||||
elif game_type == GAME_BLITZ:
|
||||
game_data = {
|
||||
"button_text": "I'm here!",
|
||||
'button_text': "I'm here!",
|
||||
}
|
||||
instructions = "Click the button as fast as you can!"
|
||||
instructions = 'Click the button as fast as you can!'
|
||||
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Unknown contest type",
|
||||
detail='Unknown contest type',
|
||||
)
|
||||
|
||||
return ContestGameData(
|
||||
@@ -327,7 +338,7 @@ async def get_contest_game(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{round_id}/answer", response_model=ContestResult)
|
||||
@router.post('/{round_id}/answer', response_model=ContestResult)
|
||||
async def submit_contest_answer(
|
||||
round_id: int,
|
||||
request: ContestAnswerRequest,
|
||||
@@ -340,7 +351,7 @@ async def submit_contest_answer(
|
||||
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",
|
||||
detail='Contests are only available for users with active or trial subscriptions',
|
||||
)
|
||||
|
||||
active_rounds = await get_active_rounds(db)
|
||||
@@ -349,7 +360,7 @@ async def submit_contest_answer(
|
||||
if not round_obj:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Contest round not found or already finished",
|
||||
detail='Contest round not found or already finished',
|
||||
)
|
||||
|
||||
# Check if already played
|
||||
@@ -357,7 +368,7 @@ async def submit_contest_answer(
|
||||
if attempt:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="You have already played this round",
|
||||
detail='You have already played this round',
|
||||
)
|
||||
|
||||
tpl = round_obj.template
|
||||
@@ -366,14 +377,14 @@ async def submit_contest_answer(
|
||||
|
||||
# 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 ""
|
||||
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("_")
|
||||
parts = answer.split('_')
|
||||
if len(parts) >= 2:
|
||||
idx = int(parts[0])
|
||||
secret = int(parts[1])
|
||||
@@ -382,38 +393,31 @@ async def submit_contest_answer(
|
||||
is_winner = False
|
||||
|
||||
elif tpl.slug == GAME_BLITZ:
|
||||
is_winner = answer.lower() == "blitz"
|
||||
is_winner = answer.lower() == 'blitz'
|
||||
|
||||
elif tpl.slug in {GAME_CIPHER, GAME_EMOJI, GAME_ANAGRAM}:
|
||||
correct = (round_obj.payload.get("answer") or "").upper()
|
||||
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
|
||||
)
|
||||
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}",
|
||||
message=f'🎉 Congratulations! You won! {prize_text}',
|
||||
prize_type=tpl.prize_type,
|
||||
prize_value=tpl.prize_value,
|
||||
)
|
||||
else:
|
||||
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),
|
||||
)
|
||||
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),
|
||||
)
|
||||
|
||||
+92
-56
@@ -1,31 +1,55 @@
|
||||
"""Info pages routes for cabinet - FAQ, rules, privacy policy, etc."""
|
||||
|
||||
import logging
|
||||
from typing import List, Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import User
|
||||
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 app.database.crud.rules import get_rules_by_language, get_current_rules_content
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_cabinet_user, get_optional_cabinet_user
|
||||
from ..dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/info", tags=["Cabinet Info"])
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/info', tags=['Cabinet Info'])
|
||||
|
||||
_LANGUAGE_META: dict[str, tuple[str, str]] = {
|
||||
'ru': ('Русский', '🇷🇺'),
|
||||
'en': ('English', '🇬🇧'),
|
||||
'ua': ('Українська', '🇺🇦'),
|
||||
'zh': ('中文', '🇨🇳'),
|
||||
'fa': ('فارسی', '🇮🇷'),
|
||||
}
|
||||
|
||||
|
||||
def _normalize_language_code(value: str | None) -> str:
|
||||
return (value or '').strip().lower().split('-', 1)[0]
|
||||
|
||||
|
||||
def _get_available_language_codes() -> list[str]:
|
||||
codes: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for code in settings.get_available_languages():
|
||||
normalized = _normalize_language_code(code)
|
||||
if not normalized or normalized in seen:
|
||||
continue
|
||||
seen.add(normalized)
|
||||
codes.append(normalized)
|
||||
return codes
|
||||
|
||||
|
||||
# ============ Schemas ============
|
||||
|
||||
|
||||
class FaqPageResponse(BaseModel):
|
||||
"""FAQ page."""
|
||||
|
||||
id: int
|
||||
title: str
|
||||
content: str
|
||||
@@ -34,44 +58,50 @@ class FaqPageResponse(BaseModel):
|
||||
|
||||
class RulesResponse(BaseModel):
|
||||
"""Service rules."""
|
||||
|
||||
content: str
|
||||
updated_at: Optional[str] = None
|
||||
updated_at: str | None = None
|
||||
|
||||
|
||||
class PrivacyPolicyResponse(BaseModel):
|
||||
"""Privacy policy."""
|
||||
|
||||
content: str
|
||||
updated_at: Optional[str] = None
|
||||
updated_at: str | None = None
|
||||
|
||||
|
||||
class PublicOfferResponse(BaseModel):
|
||||
"""Public offer."""
|
||||
|
||||
content: str
|
||||
updated_at: Optional[str] = None
|
||||
updated_at: str | None = None
|
||||
|
||||
|
||||
class ServiceInfoResponse(BaseModel):
|
||||
"""General service info."""
|
||||
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
support_email: Optional[str] = None
|
||||
support_telegram: Optional[str] = None
|
||||
website: Optional[str] = None
|
||||
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: Optional[str] = None
|
||||
support_username: Optional[str] = None
|
||||
support_url: str | None = None
|
||||
support_username: str | None = None
|
||||
|
||||
|
||||
# ============ Routes ============
|
||||
|
||||
@router.get("/faq", response_model=List[FaqPageResponse])
|
||||
|
||||
@router.get('/faq', response_model=list[FaqPageResponse])
|
||||
async def get_faq_pages(
|
||||
language: str = Query("ru", min_length=2, max_length=10),
|
||||
language: str = Query('ru', min_length=2, max_length=10),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get list of FAQ pages."""
|
||||
@@ -87,17 +117,17 @@ async def get_faq_pages(
|
||||
FaqPageResponse(
|
||||
id=page.id,
|
||||
title=page.title,
|
||||
content=page.content or "",
|
||||
content=page.content or '',
|
||||
order=page.display_order or 0,
|
||||
)
|
||||
for page in pages
|
||||
]
|
||||
|
||||
|
||||
@router.get("/faq/{page_id}", response_model=FaqPageResponse)
|
||||
@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),
|
||||
language: str = Query('ru', min_length=2, max_length=10),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get a specific FAQ page by ID."""
|
||||
@@ -113,24 +143,24 @@ async def get_faq_page(
|
||||
if not page:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="FAQ page not found",
|
||||
detail='FAQ page not found',
|
||||
)
|
||||
|
||||
return FaqPageResponse(
|
||||
id=page.id,
|
||||
title=page.title,
|
||||
content=page.content or "",
|
||||
content=page.content or '',
|
||||
order=page.display_order or 0,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/rules", response_model=RulesResponse)
|
||||
@router.get('/rules', response_model=RulesResponse)
|
||||
async def get_rules(
|
||||
language: str = Query("ru", min_length=2, max_length=10),
|
||||
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()
|
||||
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)
|
||||
@@ -144,9 +174,9 @@ async def get_rules(
|
||||
return RulesResponse(content=content, updated_at=updated_at)
|
||||
|
||||
|
||||
@router.get("/privacy-policy", response_model=PrivacyPolicyResponse)
|
||||
@router.get('/privacy-policy', response_model=PrivacyPolicyResponse)
|
||||
async def get_privacy_policy(
|
||||
language: str = Query("ru", min_length=2, max_length=10),
|
||||
language: str = Query('ru', min_length=2, max_length=10),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get privacy policy."""
|
||||
@@ -167,9 +197,9 @@ async def get_privacy_policy(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/public-offer", response_model=PublicOfferResponse)
|
||||
@router.get('/public-offer', response_model=PublicOfferResponse)
|
||||
async def get_public_offer(
|
||||
language: str = Query("ru", min_length=2, max_length=10),
|
||||
language: str = Query('ru', min_length=2, max_length=10),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get public offer."""
|
||||
@@ -190,7 +220,7 @@ async def get_public_offer(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/service", response_model=ServiceInfoResponse)
|
||||
@router.get('/service', response_model=ServiceInfoResponse)
|
||||
async def get_service_info():
|
||||
"""Get general service information."""
|
||||
return ServiceInfoResponse(
|
||||
@@ -202,50 +232,56 @@ async def get_service_info():
|
||||
)
|
||||
|
||||
|
||||
@router.get("/languages")
|
||||
@router.get('/languages')
|
||||
async def get_available_languages():
|
||||
"""Get list of available languages."""
|
||||
codes = _get_available_language_codes()
|
||||
default_language = _normalize_language_code(getattr(settings, 'DEFAULT_LANGUAGE', 'ru') or 'ru')
|
||||
|
||||
return {
|
||||
"languages": [
|
||||
{"code": "ru", "name": "Русский", "flag": "🇷🇺"},
|
||||
{"code": "en", "name": "English", "flag": "🇬🇧"},
|
||||
'languages': [
|
||||
{
|
||||
'code': code,
|
||||
'name': _LANGUAGE_META.get(code, (code.upper(), '🌐'))[0],
|
||||
'flag': _LANGUAGE_META.get(code, (code.upper(), '🌐'))[1],
|
||||
}
|
||||
for code in codes
|
||||
],
|
||||
"default": getattr(settings, 'DEFAULT_LANGUAGE', 'ru') or 'ru',
|
||||
'default': default_language,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/user/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"}
|
||||
return {'language': user.language or 'ru'}
|
||||
|
||||
|
||||
@router.patch("/user/language")
|
||||
@router.patch('/user/language')
|
||||
async def update_user_language(
|
||||
request: Dict[str, str],
|
||||
request: dict[str, str],
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update user's language preference."""
|
||||
language = request.get("language", "ru")
|
||||
|
||||
valid_languages = ["ru", "en"]
|
||||
if language not in valid_languages:
|
||||
requested_language = _normalize_language_code(request.get('language', 'ru'))
|
||||
available_languages = _get_available_language_codes()
|
||||
if requested_language not in available_languages:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid language. Supported: {', '.join(valid_languages)}",
|
||||
detail=f'Invalid language. Supported: {", ".join(available_languages)}',
|
||||
)
|
||||
|
||||
user.language = language
|
||||
user.language = requested_language
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
return {"language": user.language}
|
||||
return {'language': user.language}
|
||||
|
||||
|
||||
@router.get("/support-config", response_model=SupportConfigResponse)
|
||||
@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)
|
||||
@@ -255,15 +291,15 @@ async def get_support_config():
|
||||
# - "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":
|
||||
if support_mode == 'tickets':
|
||||
tickets_enabled = True
|
||||
support_type = "tickets"
|
||||
elif support_mode == "contact":
|
||||
support_type = 'tickets'
|
||||
elif support_mode == 'contact':
|
||||
tickets_enabled = False
|
||||
support_type = "profile"
|
||||
support_type = 'profile'
|
||||
else: # both
|
||||
tickets_enabled = True
|
||||
support_type = "tickets"
|
||||
support_type = 'tickets'
|
||||
|
||||
return SupportConfigResponse(
|
||||
tickets_enabled=tickets_enabled,
|
||||
|
||||
+41
-36
@@ -1,35 +1,35 @@
|
||||
"""Media upload/download routes for cabinet tickets."""
|
||||
|
||||
import logging
|
||||
import mimetypes
|
||||
from typing import Optional
|
||||
|
||||
import structlog
|
||||
from aiogram import Bot
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
from aiogram.types import BufferedInputFile
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, Response, UploadFile, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
from ..dependencies import get_current_cabinet_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/media", tags=["Cabinet Media"])
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
ALLOWED_MEDIA_TYPES = {"photo", "video", "document"}
|
||||
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: Optional[str] = None
|
||||
file_unique_id: str | None = None
|
||||
media_url: str
|
||||
|
||||
|
||||
@@ -45,31 +45,31 @@ def _resolve_target_chat_id() -> int:
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="No chat configured for file uploads",
|
||||
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))
|
||||
return str(request.url_for('cabinet_download_media', file_id=file_id))
|
||||
|
||||
|
||||
@router.post("/upload", response_model=MediaUploadResponse, status_code=status.HTTP_201_CREATED)
|
||||
@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"),
|
||||
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()
|
||||
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)}",
|
||||
detail=f'Unsupported media type. Allowed: {", ".join(ALLOWED_MEDIA_TYPES)}',
|
||||
)
|
||||
|
||||
# Read and validate file
|
||||
@@ -77,26 +77,26 @@ async def upload_media(
|
||||
if not file_bytes:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="File is empty",
|
||||
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",
|
||||
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 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",
|
||||
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")
|
||||
upload = BufferedInputFile(file_bytes, filename=file.filename or 'upload')
|
||||
|
||||
bot = Bot(
|
||||
token=settings.BOT_TOKEN,
|
||||
@@ -104,13 +104,13 @@ async def upload_media(
|
||||
)
|
||||
|
||||
try:
|
||||
if media_type_normalized == "photo":
|
||||
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":
|
||||
elif media_type_normalized == 'video':
|
||||
message = await bot.send_video(
|
||||
chat_id=target_chat_id,
|
||||
video=upload,
|
||||
@@ -125,27 +125,32 @@ async def upload_media(
|
||||
|
||||
media_url = _build_media_url(request, media.file_id)
|
||||
|
||||
logger.info(f"User {user.telegram_id} uploaded {media_type_normalized}: {media.file_id}")
|
||||
logger.info(
|
||||
'User uploaded',
|
||||
telegram_id=user.telegram_id,
|
||||
media_type_normalized=media_type_normalized,
|
||||
file_id=media.file_id,
|
||||
)
|
||||
|
||||
return MediaUploadResponse(
|
||||
media_type=media_type_normalized,
|
||||
file_id=media.file_id,
|
||||
file_unique_id=getattr(media, "file_unique_id", None),
|
||||
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}")
|
||||
logger.error('Failed to upload media for user', telegram_id=user.telegram_id, error=error)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to upload media",
|
||||
detail='Failed to upload media',
|
||||
) from error
|
||||
finally:
|
||||
await bot.session.close()
|
||||
|
||||
|
||||
@router.get("/{file_id}", name="cabinet_download_media")
|
||||
@router.get('/{file_id}', name='cabinet_download_media')
|
||||
async def download_media(
|
||||
file_id: str,
|
||||
) -> Response:
|
||||
@@ -163,34 +168,34 @@ async def download_media(
|
||||
if not file.file_path:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Media file not found",
|
||||
detail='Media file not found',
|
||||
)
|
||||
|
||||
buffer = await bot.download_file(file.file_path)
|
||||
|
||||
if hasattr(buffer, "seek"):
|
||||
if hasattr(buffer, 'seek'):
|
||||
buffer.seek(0)
|
||||
|
||||
content = buffer.read() if hasattr(buffer, "read") else bytes(buffer)
|
||||
filename = file.file_path.split("/")[-1]
|
||||
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"
|
||||
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
|
||||
'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}")
|
||||
logger.error('Failed to download media', file_id=file_id, error=error)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to download media",
|
||||
detail='Failed to download media',
|
||||
) from error
|
||||
finally:
|
||||
await bot.session.close()
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"""Notification settings routes for cabinet."""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, Any
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -12,15 +12,18 @@ 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"])
|
||||
logger = structlog.get_logger(__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
|
||||
@@ -33,36 +36,38 @@ class NotificationSettingsResponse(BaseModel):
|
||||
|
||||
class NotificationSettingsUpdate(BaseModel):
|
||||
"""Update notification settings."""
|
||||
subscription_expiry_enabled: Optional[bool] = None
|
||||
subscription_expiry_days: Optional[int] = Field(None, ge=1, le=30)
|
||||
traffic_warning_enabled: Optional[bool] = None
|
||||
traffic_warning_percent: Optional[int] = Field(None, ge=50, le=99)
|
||||
balance_low_enabled: Optional[bool] = None
|
||||
balance_low_threshold: Optional[int] = Field(None, ge=0)
|
||||
news_enabled: Optional[bool] = None
|
||||
promo_offers_enabled: Optional[bool] = None
|
||||
|
||||
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]:
|
||||
|
||||
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),
|
||||
'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]:
|
||||
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)
|
||||
|
||||
@@ -75,7 +80,8 @@ def _update_notification_settings(user: User, updates: Dict[str, Any]) -> Dict[s
|
||||
|
||||
# ============ Routes ============
|
||||
|
||||
@router.get("", response_model=NotificationSettingsResponse)
|
||||
|
||||
@router.get('', response_model=NotificationSettingsResponse)
|
||||
async def get_notification_settings(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
):
|
||||
@@ -84,7 +90,7 @@ async def get_notification_settings(
|
||||
return NotificationSettingsResponse(**settings)
|
||||
|
||||
|
||||
@router.patch("", response_model=NotificationSettingsResponse)
|
||||
@router.patch('', response_model=NotificationSettingsResponse)
|
||||
async def update_notification_settings(
|
||||
request: NotificationSettingsUpdate,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
@@ -106,7 +112,7 @@ async def update_notification_settings(
|
||||
user.notification_settings = {}
|
||||
|
||||
user.notification_settings = new_settings
|
||||
user.updated_at = datetime.utcnow()
|
||||
user.updated_at = datetime.now(UTC)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
@@ -114,7 +120,7 @@ async def update_notification_settings(
|
||||
return NotificationSettingsResponse(**new_settings)
|
||||
|
||||
|
||||
@router.post("/test")
|
||||
@router.post('/test')
|
||||
async def send_test_notification(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
):
|
||||
@@ -122,12 +128,12 @@ async def send_test_notification(
|
||||
# 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.",
|
||||
'success': True,
|
||||
'message': 'Test notification request received. You will receive a test message shortly.',
|
||||
}
|
||||
|
||||
|
||||
@router.get("/history")
|
||||
@router.get('/history')
|
||||
async def get_notification_history(
|
||||
limit: int = 20,
|
||||
offset: int = 0,
|
||||
@@ -138,8 +144,8 @@ async def get_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,
|
||||
'notifications': [],
|
||||
'total': 0,
|
||||
'limit': limit,
|
||||
'offset': offset,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
"""OAuth 2.0 authentication routes for cabinet."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.user import (
|
||||
create_user_by_oauth,
|
||||
get_user_by_email,
|
||||
get_user_by_oauth_provider,
|
||||
set_user_oauth_provider_id,
|
||||
)
|
||||
from app.database.models import User
|
||||
|
||||
from ..auth.oauth_providers import (
|
||||
OAuthUserInfo,
|
||||
generate_oauth_state,
|
||||
get_provider,
|
||||
validate_oauth_state,
|
||||
)
|
||||
from ..dependencies import get_cabinet_db
|
||||
from ..schemas.auth import AuthResponse
|
||||
from .auth import _create_auth_response, _process_campaign_bonus, _store_refresh_token
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/auth/oauth', tags=['Cabinet OAuth'])
|
||||
|
||||
|
||||
async def _finalize_oauth_login(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
provider: str,
|
||||
campaign_slug: str | None = None,
|
||||
) -> AuthResponse:
|
||||
"""Update last login, create tokens, store refresh token."""
|
||||
user.cabinet_last_login = datetime.now(UTC)
|
||||
await db.commit()
|
||||
auth_response = _create_auth_response(user)
|
||||
await _store_refresh_token(db, user.id, auth_response.refresh_token, device_info=f'oauth:{provider}')
|
||||
auth_response.campaign_bonus = await _process_campaign_bonus(db, user, campaign_slug)
|
||||
if auth_response.campaign_bonus:
|
||||
from .auth import _user_to_response
|
||||
|
||||
auth_response.user = _user_to_response(user)
|
||||
return auth_response
|
||||
|
||||
|
||||
# --- Schemas ---
|
||||
|
||||
|
||||
class OAuthProviderInfo(BaseModel):
|
||||
name: str
|
||||
display_name: str
|
||||
|
||||
|
||||
class OAuthProvidersResponse(BaseModel):
|
||||
providers: list[OAuthProviderInfo]
|
||||
|
||||
|
||||
class OAuthAuthorizeResponse(BaseModel):
|
||||
authorize_url: str
|
||||
state: str
|
||||
|
||||
|
||||
class OAuthCallbackRequest(BaseModel):
|
||||
code: str = Field(..., description='Authorization code from provider')
|
||||
state: str = Field(..., description='CSRF state token')
|
||||
campaign_slug: str | None = Field(
|
||||
None, min_length=1, max_length=64, pattern=r'^[a-zA-Z0-9_-]+$', description='Campaign slug from web link'
|
||||
)
|
||||
|
||||
|
||||
# --- Endpoints ---
|
||||
|
||||
|
||||
@router.get('/providers', response_model=OAuthProvidersResponse)
|
||||
async def get_oauth_providers():
|
||||
"""Get list of enabled OAuth providers."""
|
||||
providers_config = settings.get_oauth_providers_config()
|
||||
providers = [
|
||||
OAuthProviderInfo(name=name, display_name=cfg['display_name'])
|
||||
for name, cfg in providers_config.items()
|
||||
if cfg['enabled']
|
||||
]
|
||||
return OAuthProvidersResponse(providers=providers)
|
||||
|
||||
|
||||
@router.get('/{provider}/authorize', response_model=OAuthAuthorizeResponse)
|
||||
async def get_oauth_authorize_url(provider: str):
|
||||
"""Get authorization URL for an OAuth provider."""
|
||||
oauth_provider = get_provider(provider)
|
||||
if not oauth_provider:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'OAuth provider "{provider}" is not enabled',
|
||||
)
|
||||
|
||||
state = await generate_oauth_state(provider)
|
||||
authorize_url = oauth_provider.get_authorization_url(state)
|
||||
|
||||
return OAuthAuthorizeResponse(authorize_url=authorize_url, state=state)
|
||||
|
||||
|
||||
@router.post('/{provider}/callback', response_model=AuthResponse)
|
||||
async def oauth_callback(
|
||||
provider: str,
|
||||
request: OAuthCallbackRequest,
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Handle OAuth callback: exchange code, find/create user, return JWT."""
|
||||
# 1. Validate CSRF state
|
||||
if not await validate_oauth_state(request.state, provider):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Invalid or expired OAuth state',
|
||||
)
|
||||
|
||||
# 2. Get provider instance
|
||||
oauth_provider = get_provider(provider)
|
||||
if not oauth_provider:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'OAuth provider "{provider}" is not enabled',
|
||||
)
|
||||
|
||||
# 3. Exchange code for tokens
|
||||
try:
|
||||
token_data = await oauth_provider.exchange_code(request.code)
|
||||
except Exception as exc:
|
||||
logger.error('OAuth code exchange failed for', provider=provider, exc=exc)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Failed to exchange authorization code',
|
||||
) from exc
|
||||
|
||||
# 4. Fetch user info from provider
|
||||
try:
|
||||
user_info: OAuthUserInfo = await oauth_provider.get_user_info(token_data)
|
||||
except Exception as exc:
|
||||
logger.error('OAuth user info fetch failed for', provider=provider, exc=exc)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Failed to fetch user information from provider',
|
||||
) from exc
|
||||
|
||||
# 5. Find user by provider ID
|
||||
user = await get_user_by_oauth_provider(db, provider, user_info.provider_id)
|
||||
if user:
|
||||
logger.info('OAuth login via for existing user', provider=provider, user_id=user.id)
|
||||
return await _finalize_oauth_login(db, user, provider, request.campaign_slug)
|
||||
|
||||
# 6. Find user by email (if verified) and link provider
|
||||
if user_info.email and user_info.email_verified:
|
||||
user = await get_user_by_email(db, user_info.email)
|
||||
if user:
|
||||
await set_user_oauth_provider_id(db, user, provider, user_info.provider_id)
|
||||
logger.info('OAuth login via linked to existing email user', provider=provider, user_id=user.id)
|
||||
return await _finalize_oauth_login(db, user, provider, request.campaign_slug)
|
||||
|
||||
# 7. Create new user
|
||||
user = await create_user_by_oauth(
|
||||
db=db,
|
||||
provider=provider,
|
||||
provider_id=user_info.provider_id,
|
||||
email=user_info.email if user_info.email_verified else None,
|
||||
email_verified=user_info.email_verified,
|
||||
first_name=user_info.first_name,
|
||||
last_name=user_info.last_name,
|
||||
username=user_info.username,
|
||||
)
|
||||
logger.info('OAuth new user created via with id', provider=provider, user_id=user.id)
|
||||
return await _finalize_oauth_login(db, user, provider, request.campaign_slug)
|
||||
@@ -0,0 +1,162 @@
|
||||
"""User-facing partner application routes for cabinet."""
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import AdvertisingCampaign, User
|
||||
from app.services.partner_application_service import partner_application_service
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
from ..schemas.partners import (
|
||||
PartnerApplicationInfo,
|
||||
PartnerApplicationRequest,
|
||||
PartnerCampaignInfo,
|
||||
PartnerStatusResponse,
|
||||
)
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/referral/partner', tags=['Cabinet Partner'])
|
||||
|
||||
|
||||
def _get_campaign_deep_link(start_parameter: str) -> str | None:
|
||||
"""Generate Telegram deep link for campaign."""
|
||||
bot_username = settings.get_bot_username()
|
||||
if bot_username:
|
||||
return f'https://t.me/{bot_username}?start={start_parameter}'
|
||||
return None
|
||||
|
||||
|
||||
def _get_campaign_web_link(start_parameter: str) -> str | None:
|
||||
"""Generate web link for campaign."""
|
||||
base_url = (settings.MINIAPP_CUSTOM_URL or '').rstrip('/')
|
||||
if base_url:
|
||||
return f'{base_url}/?campaign={start_parameter}'
|
||||
return None
|
||||
|
||||
|
||||
@router.get('/status', response_model=PartnerStatusResponse)
|
||||
async def get_partner_status(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get partner status and latest application for current user."""
|
||||
latest_app = await partner_application_service.get_latest_application(db, user.id)
|
||||
|
||||
app_info = None
|
||||
if latest_app:
|
||||
app_info = PartnerApplicationInfo(
|
||||
id=latest_app.id,
|
||||
status=latest_app.status,
|
||||
company_name=latest_app.company_name,
|
||||
website_url=latest_app.website_url,
|
||||
telegram_channel=latest_app.telegram_channel,
|
||||
description=latest_app.description,
|
||||
expected_monthly_referrals=latest_app.expected_monthly_referrals,
|
||||
admin_comment=latest_app.admin_comment,
|
||||
approved_commission_percent=latest_app.approved_commission_percent,
|
||||
created_at=latest_app.created_at,
|
||||
processed_at=latest_app.processed_at,
|
||||
)
|
||||
|
||||
commission = user.referral_commission_percent
|
||||
if commission is None and user.is_partner:
|
||||
commission = settings.REFERRAL_COMMISSION_PERCENT
|
||||
|
||||
# Fetch campaigns assigned to this partner
|
||||
campaigns: list[PartnerCampaignInfo] = []
|
||||
if user.is_partner:
|
||||
result = await db.execute(
|
||||
select(AdvertisingCampaign).where(
|
||||
AdvertisingCampaign.partner_user_id == user.id,
|
||||
AdvertisingCampaign.is_active.is_(True),
|
||||
)
|
||||
)
|
||||
for c in result.scalars().all():
|
||||
campaigns.append(
|
||||
PartnerCampaignInfo(
|
||||
id=c.id,
|
||||
name=c.name,
|
||||
start_parameter=c.start_parameter,
|
||||
bonus_type=c.bonus_type,
|
||||
balance_bonus_kopeks=c.balance_bonus_kopeks or 0,
|
||||
subscription_duration_days=c.subscription_duration_days,
|
||||
subscription_traffic_gb=c.subscription_traffic_gb,
|
||||
deep_link=_get_campaign_deep_link(c.start_parameter),
|
||||
web_link=_get_campaign_web_link(c.start_parameter),
|
||||
)
|
||||
)
|
||||
|
||||
return PartnerStatusResponse(
|
||||
partner_status=user.partner_status,
|
||||
commission_percent=commission,
|
||||
latest_application=app_info,
|
||||
campaigns=campaigns,
|
||||
)
|
||||
|
||||
|
||||
@router.post('/apply', response_model=PartnerApplicationInfo)
|
||||
async def apply_for_partner(
|
||||
request: PartnerApplicationRequest,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Submit partner application."""
|
||||
application, error = await partner_application_service.submit_application(
|
||||
db,
|
||||
user_id=user.id,
|
||||
company_name=request.company_name,
|
||||
website_url=request.website_url,
|
||||
telegram_channel=request.telegram_channel,
|
||||
description=request.description,
|
||||
expected_monthly_referrals=request.expected_monthly_referrals,
|
||||
)
|
||||
|
||||
if not application:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=error,
|
||||
)
|
||||
|
||||
# Уведомляем админов о новой заявке
|
||||
try:
|
||||
from aiogram import Bot
|
||||
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
if getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) and settings.BOT_TOKEN:
|
||||
bot = Bot(token=settings.BOT_TOKEN)
|
||||
try:
|
||||
notification_service = AdminNotificationService(bot)
|
||||
await notification_service.send_partner_application_notification(
|
||||
user=user,
|
||||
application_data={
|
||||
'company_name': request.company_name,
|
||||
'telegram_channel': request.telegram_channel,
|
||||
'website_url': request.website_url,
|
||||
'description': request.description,
|
||||
'expected_monthly_referrals': request.expected_monthly_referrals,
|
||||
},
|
||||
)
|
||||
finally:
|
||||
await bot.session.close()
|
||||
except Exception as e:
|
||||
logger.error('Failed to send admin notification for partner application', error=e)
|
||||
|
||||
return PartnerApplicationInfo(
|
||||
id=application.id,
|
||||
status=application.status,
|
||||
company_name=application.company_name,
|
||||
website_url=application.website_url,
|
||||
telegram_channel=application.telegram_channel,
|
||||
description=application.description,
|
||||
expected_monthly_referrals=application.expected_monthly_referrals,
|
||||
admin_comment=application.admin_comment,
|
||||
approved_commission_percent=application.approved_commission_percent,
|
||||
created_at=application.created_at,
|
||||
processed_at=application.processed_at,
|
||||
)
|
||||
+60
-48
@@ -1,34 +1,36 @@
|
||||
"""Polls routes for cabinet - user participation in polls/surveys."""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import List, Optional, Dict, Any
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import User, Poll, PollResponse, PollQuestion
|
||||
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 app.config import settings
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/polls", tags=["Cabinet Polls"])
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/polls', tags=['Cabinet Polls'])
|
||||
|
||||
|
||||
# ============ Schemas ============
|
||||
|
||||
|
||||
class PollOptionResponse(BaseModel):
|
||||
"""Poll option."""
|
||||
|
||||
id: int
|
||||
text: str
|
||||
order: int
|
||||
@@ -36,26 +38,29 @@ class PollOptionResponse(BaseModel):
|
||||
|
||||
class PollQuestionResponse(BaseModel):
|
||||
"""Poll question with options."""
|
||||
|
||||
id: int
|
||||
text: str
|
||||
order: int
|
||||
options: List[PollOptionResponse]
|
||||
options: list[PollOptionResponse]
|
||||
|
||||
|
||||
class PollInfo(BaseModel):
|
||||
"""Poll info for user."""
|
||||
|
||||
id: int
|
||||
response_id: int
|
||||
title: str
|
||||
description: Optional[str] = None
|
||||
description: str | None = None
|
||||
total_questions: int
|
||||
answered_questions: int
|
||||
is_completed: bool
|
||||
reward_amount: Optional[int] = None
|
||||
reward_amount: int | None = None
|
||||
|
||||
|
||||
class PollStartResponse(BaseModel):
|
||||
"""Response when starting a poll."""
|
||||
|
||||
response_id: int
|
||||
current_question_index: int
|
||||
total_questions: int
|
||||
@@ -64,22 +69,25 @@ class PollStartResponse(BaseModel):
|
||||
|
||||
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: Optional[PollQuestionResponse] = None
|
||||
current_question_index: Optional[int] = None
|
||||
next_question: PollQuestionResponse | None = None
|
||||
current_question_index: int | None = None
|
||||
total_questions: int
|
||||
reward_granted: Optional[int] = None
|
||||
message: Optional[str] = None
|
||||
reward_granted: int | None = None
|
||||
message: str | None = None
|
||||
|
||||
|
||||
# ============ Helpers ============
|
||||
|
||||
|
||||
def _question_to_response(question: PollQuestion) -> PollQuestionResponse:
|
||||
"""Convert question model to response."""
|
||||
options = [
|
||||
@@ -100,12 +108,14 @@ def _question_to_response(question: PollQuestion) -> PollQuestionResponse:
|
||||
|
||||
# ============ Routes ============
|
||||
|
||||
|
||||
class PollsCountResponse(BaseModel):
|
||||
"""Count of available polls."""
|
||||
|
||||
count: int
|
||||
|
||||
|
||||
@router.get("/count", response_model=PollsCountResponse)
|
||||
@router.get('/count', response_model=PollsCountResponse)
|
||||
async def get_polls_count(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
@@ -120,7 +130,7 @@ async def get_polls_count(
|
||||
return PollsCountResponse(count=len(responses))
|
||||
|
||||
|
||||
@router.get("", response_model=List[PollInfo])
|
||||
@router.get('', response_model=list[PollInfo])
|
||||
async def get_available_polls(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
@@ -151,21 +161,23 @@ async def get_available_polls(
|
||||
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,
|
||||
))
|
||||
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)
|
||||
@router.get('/{response_id}', response_model=PollInfo)
|
||||
async def get_poll_details(
|
||||
response_id: int,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
@@ -177,13 +189,13 @@ async def get_poll_details(
|
||||
if not response or response.user_id != user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Poll not found",
|
||||
detail='Poll not found',
|
||||
)
|
||||
|
||||
if not response.poll:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Poll data not available",
|
||||
detail='Poll data not available',
|
||||
)
|
||||
|
||||
answered_count = len(response.answers) if response.answers else 0
|
||||
@@ -206,7 +218,7 @@ async def get_poll_details(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{response_id}/start", response_model=PollStartResponse)
|
||||
@router.post('/{response_id}/start', response_model=PollStartResponse)
|
||||
async def start_poll(
|
||||
response_id: int,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
@@ -218,24 +230,24 @@ async def start_poll(
|
||||
if not response or response.user_id != user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Poll 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",
|
||||
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",
|
||||
detail='Poll is not available',
|
||||
)
|
||||
|
||||
# Mark as started if not already
|
||||
if not response.started_at:
|
||||
response.started_at = datetime.utcnow()
|
||||
response.started_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
|
||||
# Get next unanswered question
|
||||
@@ -244,7 +256,7 @@ async def start_poll(
|
||||
if not question:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="No questions available",
|
||||
detail='No questions available',
|
||||
)
|
||||
|
||||
return PollStartResponse(
|
||||
@@ -255,7 +267,7 @@ async def start_poll(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{response_id}/questions/{question_id}/answer", response_model=AnswerResponse)
|
||||
@router.post('/{response_id}/questions/{question_id}/answer', response_model=AnswerResponse)
|
||||
async def answer_question(
|
||||
response_id: int,
|
||||
question_id: int,
|
||||
@@ -269,19 +281,19 @@ async def answer_question(
|
||||
if not response or response.user_id != user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Poll 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",
|
||||
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",
|
||||
detail='Poll is not available',
|
||||
)
|
||||
|
||||
# Find the question
|
||||
@@ -289,7 +301,7 @@ async def answer_question(
|
||||
if not question:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Question not found",
|
||||
detail='Question not found',
|
||||
)
|
||||
|
||||
# Validate option
|
||||
@@ -297,7 +309,7 @@ async def answer_question(
|
||||
if not option:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid answer option",
|
||||
detail='Invalid answer option',
|
||||
)
|
||||
|
||||
# Record the answer
|
||||
@@ -310,13 +322,13 @@ async def answer_question(
|
||||
|
||||
# Refresh to get updated answers
|
||||
try:
|
||||
await db.refresh(response, attribute_names=["answers"])
|
||||
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",
|
||||
detail='Failed to process answer',
|
||||
)
|
||||
|
||||
# Get next question
|
||||
@@ -334,15 +346,15 @@ async def answer_question(
|
||||
)
|
||||
|
||||
# Poll completed
|
||||
response.completed_at = datetime.utcnow()
|
||||
response.completed_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
|
||||
# Award reward if any
|
||||
reward_amount = await reward_user_for_poll(db, response)
|
||||
|
||||
message = "Thank you for completing the poll!"
|
||||
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."
|
||||
message += f' Reward of {settings.format_price(reward_amount)} has been added to your balance.'
|
||||
|
||||
return AnswerResponse(
|
||||
success=True,
|
||||
|
||||
+166
-57
@@ -1,84 +1,118 @@
|
||||
"""Promo offers routes for cabinet - personal discounts and offers."""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Optional, Dict, Any
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import and_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, and_
|
||||
|
||||
from app.database.models import User, DiscountOffer
|
||||
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 app.config import settings
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/promo", tags=["Cabinet Promo"])
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/promo', tags=['Cabinet Promo'])
|
||||
|
||||
|
||||
# ============ Schemas ============
|
||||
|
||||
|
||||
class PromoOfferInfo(BaseModel):
|
||||
"""Promo offer info."""
|
||||
|
||||
id: int
|
||||
notification_type: str
|
||||
discount_percent: Optional[int] = None
|
||||
discount_percent: int | None = None
|
||||
effect_type: str
|
||||
expires_at: datetime
|
||||
is_active: bool
|
||||
is_claimed: bool
|
||||
claimed_at: Optional[datetime] = None
|
||||
extra_data: Optional[Dict[str, Any]] = None
|
||||
claimed_at: datetime | None = None
|
||||
extra_data: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class ActiveDiscountInfo(BaseModel):
|
||||
"""User's active discount info."""
|
||||
|
||||
discount_percent: int
|
||||
source: Optional[str] = None
|
||||
expires_at: Optional[datetime] = None
|
||||
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: Optional[int] = None
|
||||
expires_at: Optional[datetime] = None
|
||||
discount_percent: int | None = None
|
||||
expires_at: datetime | None = None
|
||||
|
||||
|
||||
class PromoGroupDiscounts(BaseModel):
|
||||
"""User's promo group discounts."""
|
||||
group_name: Optional[str] = None
|
||||
|
||||
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] = {}
|
||||
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])
|
||||
|
||||
@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()
|
||||
now = datetime.now(UTC)
|
||||
|
||||
result = await db.execute(
|
||||
select(DiscountOffer)
|
||||
@@ -95,9 +129,9 @@ async def get_promo_offers(
|
||||
return [
|
||||
PromoOfferInfo(
|
||||
id=offer.id,
|
||||
notification_type=offer.notification_type or "",
|
||||
notification_type=offer.notification_type or '',
|
||||
discount_percent=offer.discount_percent,
|
||||
effect_type=offer.effect_type or "percent_discount",
|
||||
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,
|
||||
@@ -108,7 +142,7 @@ async def get_promo_offers(
|
||||
]
|
||||
|
||||
|
||||
@router.get("/active-discount", response_model=ActiveDiscountInfo)
|
||||
@router.get('/active-discount', response_model=ActiveDiscountInfo)
|
||||
async def get_active_discount(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
):
|
||||
@@ -117,7 +151,7 @@ async def get_active_discount(
|
||||
expires_at = user.promo_offer_discount_expires_at
|
||||
source = user.promo_offer_discount_source
|
||||
|
||||
now = datetime.utcnow()
|
||||
now = datetime.now(UTC)
|
||||
is_active = discount_percent > 0 and (expires_at is None or expires_at > now)
|
||||
|
||||
return ActiveDiscountInfo(
|
||||
@@ -128,13 +162,13 @@ async def get_active_discount(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/group-discounts", response_model=PromoGroupDiscounts)
|
||||
@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_groups"])
|
||||
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
|
||||
|
||||
@@ -143,7 +177,7 @@ async def get_promo_group_discounts(
|
||||
|
||||
# Get period discounts
|
||||
period_discounts = {}
|
||||
raw_period_discounts = getattr(promo_group, "period_discounts", None)
|
||||
raw_period_discounts = getattr(promo_group, 'period_discounts', None)
|
||||
if isinstance(raw_period_discounts, dict):
|
||||
for key, value in raw_period_discounts.items():
|
||||
try:
|
||||
@@ -160,7 +194,82 @@ async def get_promo_group_discounts(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/claim", response_model=ClaimOfferResponse)
|
||||
@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),
|
||||
@@ -172,15 +281,15 @@ async def claim_promo_offer(
|
||||
if not offer or offer.user_id != user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Offer not found",
|
||||
detail='Offer not found',
|
||||
)
|
||||
|
||||
now = datetime.utcnow()
|
||||
now = datetime.now(UTC)
|
||||
|
||||
if offer.claimed_at is not None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="This offer has already been claimed",
|
||||
detail='This offer has already been claimed',
|
||||
)
|
||||
|
||||
if not offer.is_active or offer.expires_at <= now:
|
||||
@@ -188,14 +297,14 @@ async def claim_promo_offer(
|
||||
await db.commit()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="This offer has expired",
|
||||
detail='This offer has expired',
|
||||
)
|
||||
|
||||
effect_type = (offer.effect_type or "percent_discount").lower()
|
||||
effect_type = (offer.effect_type or 'percent_discount').lower()
|
||||
|
||||
# Handle test access offers
|
||||
if effect_type == "test_access":
|
||||
await db.refresh(user, ["subscription"])
|
||||
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,
|
||||
@@ -204,29 +313,29 @@ async def claim_promo_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",
|
||||
'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"),
|
||||
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,
|
||||
'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'}",
|
||||
message=f'Test access activated until {expires_at.strftime("%Y-%m-%d %H:%M") if expires_at else "unlimited"}',
|
||||
expires_at=expires_at,
|
||||
)
|
||||
|
||||
@@ -235,7 +344,7 @@ async def claim_promo_offer(
|
||||
if discount_percent <= 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid offer",
|
||||
detail='Invalid offer',
|
||||
)
|
||||
|
||||
user.promo_offer_discount_percent = discount_percent
|
||||
@@ -244,10 +353,10 @@ async def claim_promo_offer(
|
||||
|
||||
# Calculate expiration
|
||||
extra_data = offer.extra_data or {}
|
||||
raw_duration = extra_data.get("active_discount_hours")
|
||||
template_id = extra_data.get("template_id")
|
||||
raw_duration = extra_data.get('active_discount_hours')
|
||||
template_id = extra_data.get('template_id')
|
||||
|
||||
if raw_duration in (None, "") and 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):
|
||||
@@ -271,26 +380,26 @@ async def claim_promo_offer(
|
||||
db,
|
||||
offer,
|
||||
details={
|
||||
"context": "discount_claim",
|
||||
"discount_percent": discount_percent,
|
||||
"discount_expires_at": discount_expires_at.isoformat() if discount_expires_at else None,
|
||||
'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 = ""
|
||||
expires_text = ''
|
||||
if discount_expires_at:
|
||||
expires_text = f" Valid until {discount_expires_at.strftime('%Y-%m-%d %H:%M')}"
|
||||
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}",
|
||||
message=f'Discount of {discount_percent}% activated!{expires_text}',
|
||||
discount_percent=discount_percent,
|
||||
expires_at=discount_expires_at,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/active-discount")
|
||||
@router.delete('/active-discount')
|
||||
async def clear_active_discount(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
@@ -299,8 +408,8 @@ async def clear_active_discount(
|
||||
user.promo_offer_discount_percent = 0
|
||||
user.promo_offer_discount_source = None
|
||||
user.promo_offer_discount_expires_at = None
|
||||
user.updated_at = datetime.utcnow()
|
||||
user.updated_at = datetime.now(UTC)
|
||||
|
||||
await db.commit()
|
||||
|
||||
return {"message": "Active discount cleared"}
|
||||
return {'message': 'Active discount cleared'}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
"""Promo code routes for cabinet."""
|
||||
|
||||
import logging
|
||||
from typing import Dict, Any
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -12,18 +10,21 @@ 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"])
|
||||
logger = structlog.get_logger(__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")
|
||||
|
||||
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
|
||||
@@ -31,7 +32,16 @@ class PromocodeActivateResponse(BaseModel):
|
||||
bonus_description: str | None = None
|
||||
|
||||
|
||||
@router.post("/activate", response_model=PromocodeActivateResponse)
|
||||
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),
|
||||
@@ -40,36 +50,72 @@ async def activate_promocode(
|
||||
"""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()
|
||||
)
|
||||
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
|
||||
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",
|
||||
message='Promo code activated successfully',
|
||||
balance_before=balance_before_rubles,
|
||||
balance_after=balance_after_rubles,
|
||||
bonus_description=result.get("description"),
|
||||
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",
|
||||
"user_not_found": "User not found",
|
||||
"server_error": "Server error occurred",
|
||||
'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")
|
||||
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,
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
"""Referral program routes for cabinet."""
|
||||
|
||||
import logging
|
||||
import math
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import desc, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, desc
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import User, ReferralEarning
|
||||
from app.config import settings
|
||||
from app.database.models import AdvertisingCampaign, ReferralEarning, User
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
from ..schemas.referral import (
|
||||
ReferralEarningResponse,
|
||||
ReferralEarningsListResponse,
|
||||
ReferralInfoResponse,
|
||||
ReferralItemResponse,
|
||||
ReferralListResponse,
|
||||
ReferralEarningResponse,
|
||||
ReferralEarningsListResponse,
|
||||
ReferralTermsResponse,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/referral", tags=["Cabinet Referral"])
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/referral', tags=['Cabinet Referral'])
|
||||
|
||||
|
||||
@router.get("", response_model=ReferralInfoResponse)
|
||||
@router.get('', response_model=ReferralInfoResponse)
|
||||
async def get_referral_info(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
@@ -49,9 +49,8 @@ async def get_referral_info(
|
||||
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_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
|
||||
@@ -62,11 +61,11 @@ async def get_referral_info(
|
||||
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}"
|
||||
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_code=user.referral_code or '',
|
||||
referral_link=referral_link,
|
||||
total_referrals=total_referrals,
|
||||
active_referrals=active_referrals,
|
||||
@@ -76,10 +75,10 @@ async def get_referral_info(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/list", response_model=ReferralListResponse)
|
||||
@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"),
|
||||
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),
|
||||
):
|
||||
@@ -122,10 +121,10 @@ async def get_referral_list(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/earnings", response_model=ReferralEarningsListResponse)
|
||||
@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"),
|
||||
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),
|
||||
):
|
||||
@@ -138,9 +137,8 @@ async def get_referral_earnings(
|
||||
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_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
|
||||
@@ -152,22 +150,39 @@ async def get_referral_earnings(
|
||||
result = await db.execute(query)
|
||||
earnings = result.scalars().all()
|
||||
|
||||
# Batch-fetch referral users to avoid N+1
|
||||
referral_ids = list({e.referral_id for e in earnings if e.referral_id})
|
||||
if referral_ids:
|
||||
referral_users_result = await db.execute(select(User).where(User.id.in_(referral_ids)))
|
||||
referral_users_map = {u.id: u for u in referral_users_result.scalars().all()}
|
||||
else:
|
||||
referral_users_map = {}
|
||||
|
||||
# Batch-fetch campaigns to avoid N+1
|
||||
campaign_ids = list({e.campaign_id for e in earnings if e.campaign_id})
|
||||
if campaign_ids:
|
||||
campaigns_result = await db.execute(select(AdvertisingCampaign).where(AdvertisingCampaign.id.in_(campaign_ids)))
|
||||
campaigns_map = {c.id: c for c in campaigns_result.scalars().all()}
|
||||
else:
|
||||
campaigns_map = {}
|
||||
|
||||
items = []
|
||||
for e in earnings:
|
||||
# Get referral user info
|
||||
referral_query = select(User).where(User.id == e.referral_id)
|
||||
referral_result = await db.execute(referral_query)
|
||||
referral_user = referral_result.scalar_one_or_none()
|
||||
referral_user = referral_users_map.get(e.referral_id) if e.referral_id else None
|
||||
campaign = campaigns_map.get(e.campaign_id) if e.campaign_id else None
|
||||
|
||||
items.append(ReferralEarningResponse(
|
||||
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,
|
||||
))
|
||||
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,
|
||||
campaign_name=campaign.name if campaign else None,
|
||||
created_at=e.created_at,
|
||||
)
|
||||
)
|
||||
|
||||
pages = math.ceil(total / per_page) if total > 0 else 1
|
||||
|
||||
@@ -182,7 +197,7 @@ async def get_referral_earnings(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/terms", response_model=ReferralTermsResponse)
|
||||
@router.get('/terms', response_model=ReferralTermsResponse)
|
||||
async def get_referral_terms():
|
||||
"""Get referral program terms."""
|
||||
return ReferralTermsResponse(
|
||||
@@ -194,4 +209,5 @@ async def get_referral_terms():
|
||||
first_topup_bonus_rubles=settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS / 100,
|
||||
inviter_bonus_kopeks=settings.REFERRAL_INVITER_BONUS_KOPEKS,
|
||||
inviter_bonus_rubles=settings.REFERRAL_INVITER_BONUS_KOPEKS / 100,
|
||||
partner_section_visible=settings.REFERRAL_PARTNER_SECTION_VISIBLE,
|
||||
)
|
||||
|
||||
+2419
-986
File diff suppressed because it is too large
Load Diff
@@ -1,35 +1,35 @@
|
||||
"""Ticket notifications routes for cabinet."""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import User
|
||||
from app.database.crud.ticket_notification import TicketNotificationCRUD
|
||||
from app.database.models import User
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_cabinet_user, get_current_admin_user
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user, get_current_cabinet_user
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/tickets/notifications", tags=["Cabinet Ticket Notifications"])
|
||||
admin_router = APIRouter(prefix="/admin/tickets/notifications", tags=["Cabinet Admin Ticket Notifications"])
|
||||
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: Optional[str] = None
|
||||
message: str | None = None
|
||||
is_read: bool
|
||||
created_at: datetime
|
||||
read_at: Optional[datetime] = None
|
||||
read_at: datetime | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -37,19 +37,21 @@ class TicketNotificationResponse(BaseModel):
|
||||
|
||||
class TicketNotificationListResponse(BaseModel):
|
||||
"""List of ticket notifications."""
|
||||
items: List[TicketNotificationResponse]
|
||||
|
||||
items: list[TicketNotificationResponse]
|
||||
unread_count: int
|
||||
|
||||
|
||||
class UnreadCountResponse(BaseModel):
|
||||
"""Unread notifications count."""
|
||||
|
||||
unread_count: int
|
||||
|
||||
|
||||
# User endpoints
|
||||
@router.get("", response_model=TicketNotificationListResponse)
|
||||
@router.get('', response_model=TicketNotificationListResponse)
|
||||
async def get_user_notifications(
|
||||
unread_only: bool = Query(False, description="Only return unread 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),
|
||||
@@ -67,7 +69,7 @@ async def get_user_notifications(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/unread-count", response_model=UnreadCountResponse)
|
||||
@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),
|
||||
@@ -77,7 +79,7 @@ async def get_user_unread_count(
|
||||
return UnreadCountResponse(unread_count=count)
|
||||
|
||||
|
||||
@router.post("/{notification_id}/read")
|
||||
@router.post('/{notification_id}/read')
|
||||
async def mark_notification_as_read(
|
||||
notification_id: int,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
@@ -89,7 +91,7 @@ async def mark_notification_as_read(
|
||||
if not notification:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Notification not found",
|
||||
detail='Notification not found',
|
||||
)
|
||||
|
||||
# Check ownership: notification must belong to user and not be an admin notification
|
||||
@@ -100,36 +102,34 @@ async def mark_notification_as_read(
|
||||
)
|
||||
|
||||
await TicketNotificationCRUD.mark_as_read(db, notification_id)
|
||||
return {"success": True}
|
||||
return {'success': True}
|
||||
|
||||
|
||||
@router.post("/read-all")
|
||||
@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}
|
||||
return {'success': True, 'marked_count': count}
|
||||
|
||||
|
||||
@router.post("/ticket/{ticket_id}/read")
|
||||
@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}
|
||||
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)
|
||||
@admin_router.get('', response_model=TicketNotificationListResponse)
|
||||
async def get_admin_notifications(
|
||||
unread_only: bool = Query(False, description="Only return unread 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),
|
||||
@@ -147,7 +147,7 @@ async def get_admin_notifications(
|
||||
)
|
||||
|
||||
|
||||
@admin_router.get("/unread-count", response_model=UnreadCountResponse)
|
||||
@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),
|
||||
@@ -157,7 +157,7 @@ async def get_admin_unread_count(
|
||||
return UnreadCountResponse(unread_count=count)
|
||||
|
||||
|
||||
@admin_router.post("/{notification_id}/read")
|
||||
@admin_router.post('/{notification_id}/read')
|
||||
async def mark_admin_notification_as_read(
|
||||
notification_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
@@ -169,38 +169,36 @@ async def mark_admin_notification_as_read(
|
||||
if not notification:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Notification 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",
|
||||
detail='This is not an admin notification',
|
||||
)
|
||||
|
||||
await TicketNotificationCRUD.mark_as_read(db, notification_id)
|
||||
return {"success": True}
|
||||
return {'success': True}
|
||||
|
||||
|
||||
@admin_router.post("/read-all")
|
||||
@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}
|
||||
return {'success': True, 'marked_count': count}
|
||||
|
||||
|
||||
@admin_router.post("/ticket/{ticket_id}/read")
|
||||
@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}
|
||||
count = await TicketNotificationCRUD.mark_ticket_notifications_as_read(db, ticket_id, admin.id, is_admin=True)
|
||||
return {'success': True, 'marked_count': count}
|
||||
|
||||
@@ -1,41 +1,41 @@
|
||||
"""Support tickets routes for cabinet."""
|
||||
|
||||
import logging
|
||||
import math
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import desc, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, desc
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import User, Ticket, TicketMessage
|
||||
from app.config import settings
|
||||
from app.handlers.tickets import notify_admins_about_new_ticket, notify_admins_about_ticket_reply
|
||||
from app.database.crud.ticket_notification import TicketNotificationCRUD
|
||||
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 (
|
||||
TicketResponse,
|
||||
TicketCreateRequest,
|
||||
TicketDetailResponse,
|
||||
TicketListResponse,
|
||||
TicketMessageResponse,
|
||||
TicketCreateRequest,
|
||||
TicketMessageCreateRequest,
|
||||
TicketMessageResponse,
|
||||
TicketResponse,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/tickets", tags=["Cabinet Tickets"])
|
||||
logger = structlog.get_logger(__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 "",
|
||||
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,
|
||||
@@ -56,9 +56,9 @@ def _ticket_to_response(ticket: Ticket, include_last_message: bool = True) -> Ti
|
||||
|
||||
return TicketResponse(
|
||||
id=ticket.id,
|
||||
title=ticket.title or f"Ticket #{ticket.id}",
|
||||
title=ticket.title or f'Ticket #{ticket.id}',
|
||||
status=ticket.status,
|
||||
priority=ticket.priority or "normal",
|
||||
priority=ticket.priority or 'normal',
|
||||
created_at=ticket.created_at,
|
||||
updated_at=ticket.updated_at or ticket.created_at,
|
||||
closed_at=ticket.closed_at,
|
||||
@@ -67,11 +67,11 @@ def _ticket_to_response(ticket: Ticket, include_last_message: bool = True) -> Ti
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=TicketListResponse)
|
||||
@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: Optional[str] = Query(None, alias="status", description="Filter by status"),
|
||||
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),
|
||||
):
|
||||
@@ -80,15 +80,11 @@ async def get_tickets(
|
||||
if not settings.is_support_tickets_enabled():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Support tickets are disabled",
|
||||
detail='Support tickets are disabled',
|
||||
)
|
||||
|
||||
# Base query
|
||||
query = (
|
||||
select(Ticket)
|
||||
.where(Ticket.user_id == user.id)
|
||||
.options(selectinload(Ticket.messages))
|
||||
)
|
||||
query = select(Ticket).where(Ticket.user_id == user.id).options(selectinload(Ticket.messages))
|
||||
|
||||
# Filter by status
|
||||
if status_filter:
|
||||
@@ -121,7 +117,7 @@ async def get_tickets(
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=TicketDetailResponse)
|
||||
@router.post('', response_model=TicketDetailResponse)
|
||||
async def create_ticket(
|
||||
request: TicketCreateRequest,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
@@ -132,17 +128,17 @@ async def create_ticket(
|
||||
if not settings.is_support_tickets_enabled():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Support tickets are disabled",
|
||||
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(),
|
||||
status='open',
|
||||
priority='normal',
|
||||
created_at=datetime.now(UTC),
|
||||
updated_at=datetime.now(UTC),
|
||||
)
|
||||
db.add(ticket)
|
||||
await db.flush()
|
||||
@@ -156,19 +152,19 @@ async def create_ticket(
|
||||
media_type=request.media_type,
|
||||
media_file_id=request.media_file_id,
|
||||
media_caption=request.media_caption,
|
||||
created_at=datetime.utcnow(),
|
||||
created_at=datetime.now(UTC),
|
||||
)
|
||||
db.add(message)
|
||||
await db.commit()
|
||||
|
||||
# Refresh to get relationships
|
||||
await db.refresh(ticket, ["messages"])
|
||||
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}")
|
||||
logger.error('Error notifying admins about new ticket from cabinet', error=e)
|
||||
|
||||
# Уведомить админов в кабинете
|
||||
try:
|
||||
@@ -177,7 +173,7 @@ async def create_ticket(
|
||||
# Отправить WebSocket уведомление
|
||||
await notify_admins_new_ticket(ticket.id, ticket.title, user.id)
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating cabinet notification for new ticket: {e}")
|
||||
logger.error('Error creating cabinet notification for new ticket', error=e)
|
||||
|
||||
messages = [_message_to_response(m) for m in ticket.messages]
|
||||
|
||||
@@ -185,16 +181,16 @@ async def create_ticket(
|
||||
id=ticket.id,
|
||||
title=ticket.title,
|
||||
status=ticket.status,
|
||||
priority=ticket.priority or "normal",
|
||||
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,
|
||||
is_reply_blocked=ticket.is_reply_blocked if hasattr(ticket, 'is_reply_blocked') else False,
|
||||
messages=messages,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{ticket_id}", response_model=TicketDetailResponse)
|
||||
@router.get('/{ticket_id}', response_model=TicketDetailResponse)
|
||||
async def get_ticket(
|
||||
ticket_id: int,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
@@ -202,9 +198,7 @@ async def get_ticket(
|
||||
):
|
||||
"""Get ticket with all messages."""
|
||||
query = (
|
||||
select(Ticket)
|
||||
.where(Ticket.id == ticket_id, Ticket.user_id == user.id)
|
||||
.options(selectinload(Ticket.messages))
|
||||
select(Ticket).where(Ticket.id == ticket_id, Ticket.user_id == user.id).options(selectinload(Ticket.messages))
|
||||
)
|
||||
|
||||
result = await db.execute(query)
|
||||
@@ -213,7 +207,7 @@ async def get_ticket(
|
||||
if not ticket:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Ticket not found",
|
||||
detail='Ticket not found',
|
||||
)
|
||||
|
||||
messages = sorted(ticket.messages or [], key=lambda m: m.created_at)
|
||||
@@ -221,18 +215,18 @@ async def get_ticket(
|
||||
|
||||
return TicketDetailResponse(
|
||||
id=ticket.id,
|
||||
title=ticket.title or f"Ticket #{ticket.id}",
|
||||
title=ticket.title or f'Ticket #{ticket.id}',
|
||||
status=ticket.status,
|
||||
priority=ticket.priority or "normal",
|
||||
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,
|
||||
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)
|
||||
@router.post('/{ticket_id}/messages', response_model=TicketMessageResponse)
|
||||
async def add_ticket_message(
|
||||
ticket_id: int,
|
||||
request: TicketMessageCreateRequest,
|
||||
@@ -248,21 +242,21 @@ async def add_ticket_message(
|
||||
if not ticket:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Ticket not found",
|
||||
detail='Ticket not found',
|
||||
)
|
||||
|
||||
# Check if ticket is closed
|
||||
if ticket.status == "closed":
|
||||
if ticket.status == 'closed':
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Cannot add message to closed ticket",
|
||||
detail='Cannot add message to closed ticket',
|
||||
)
|
||||
|
||||
# Check if replies are blocked
|
||||
if hasattr(ticket, "is_reply_blocked") and ticket.is_reply_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",
|
||||
detail='Replies to this ticket are blocked',
|
||||
)
|
||||
|
||||
# Create message with optional media
|
||||
@@ -274,14 +268,14 @@ async def add_ticket_message(
|
||||
media_type=request.media_type,
|
||||
media_file_id=request.media_file_id,
|
||||
media_caption=request.media_caption,
|
||||
created_at=datetime.utcnow(),
|
||||
created_at=datetime.now(UTC),
|
||||
)
|
||||
db.add(message)
|
||||
|
||||
# Update ticket status and timestamp
|
||||
if ticket.status == "answered":
|
||||
ticket.status = "pending"
|
||||
ticket.updated_at = datetime.utcnow()
|
||||
if ticket.status == 'answered':
|
||||
ticket.status = 'pending'
|
||||
ticket.updated_at = datetime.now(UTC)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(message)
|
||||
@@ -290,7 +284,7 @@ async def add_ticket_message(
|
||||
try:
|
||||
await notify_admins_about_ticket_reply(ticket, request.message, db)
|
||||
except Exception as e:
|
||||
logger.error(f"Error notifying admins about ticket reply from cabinet: {e}")
|
||||
logger.error('Error notifying admins about ticket reply from cabinet', error=e)
|
||||
|
||||
# Уведомить админов в кабинете
|
||||
try:
|
||||
@@ -299,8 +293,8 @@ async def add_ticket_message(
|
||||
)
|
||||
if notification:
|
||||
# Отправить WebSocket уведомление
|
||||
await notify_admins_ticket_reply(ticket.id, (request.message or "")[:100], user.id)
|
||||
await notify_admins_ticket_reply(ticket.id, (request.message or '')[:100], user.id)
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating cabinet notification for user reply: {e}")
|
||||
logger.error('Error creating cabinet notification for user reply', error=e)
|
||||
|
||||
return _message_to_response(message)
|
||||
|
||||
+401
-63
@@ -4,17 +4,17 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Dict, Set
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from app.database.crud.user import get_user_by_id
|
||||
from app.config import settings
|
||||
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__)
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -24,9 +24,9 @@ class CabinetConnectionManager:
|
||||
|
||||
def __init__(self):
|
||||
# user_id -> set of websocket connections
|
||||
self._user_connections: Dict[int, Set[WebSocket]] = {}
|
||||
self._user_connections: dict[int, set[WebSocket]] = {}
|
||||
# admin user_ids -> set of websocket connections
|
||||
self._admin_connections: Dict[int, Set[WebSocket]] = {}
|
||||
self._admin_connections: dict[int, set[WebSocket]] = {}
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def connect(self, websocket: WebSocket, user_id: int, is_admin: bool) -> None:
|
||||
@@ -42,8 +42,10 @@ class CabinetConnectionManager:
|
||||
self._admin_connections[user_id].add(websocket)
|
||||
|
||||
logger.debug(
|
||||
"Cabinet WS connected: user_id=%d, is_admin=%s, total_users=%d",
|
||||
user_id, is_admin, len(self._user_connections)
|
||||
'Cabinet WS connected: user_id is_admin total_users',
|
||||
user_id=user_id,
|
||||
is_admin=is_admin,
|
||||
user_connections_count=len(self._user_connections),
|
||||
)
|
||||
|
||||
async def disconnect(self, websocket: WebSocket, user_id: int) -> None:
|
||||
@@ -59,7 +61,7 @@ class CabinetConnectionManager:
|
||||
if not self._admin_connections[user_id]:
|
||||
del self._admin_connections[user_id]
|
||||
|
||||
logger.debug("Cabinet WS disconnected: user_id=%d", user_id)
|
||||
logger.debug('Cabinet WS disconnected: user_id', user_id=user_id)
|
||||
|
||||
async def send_to_user(self, user_id: int, message: dict) -> None:
|
||||
"""Отправить сообщение конкретному пользователю."""
|
||||
@@ -77,7 +79,7 @@ class CabinetConnectionManager:
|
||||
try:
|
||||
await ws.send_text(data)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to send to user %d: %s", user_id, e)
|
||||
logger.warning('Failed to send to user', user_id=user_id, e=e)
|
||||
disconnected.add(ws)
|
||||
|
||||
# Cleanup disconnected
|
||||
@@ -93,20 +95,17 @@ class CabinetConnectionManager:
|
||||
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()
|
||||
]
|
||||
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]] = {}
|
||||
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)
|
||||
logger.warning('Failed to send to admin', user_id=user_id, e=e)
|
||||
if user_id not in disconnected_by_user:
|
||||
disconnected_by_user[user_id] = set()
|
||||
disconnected_by_user[user_id].add(ws)
|
||||
@@ -133,55 +132,61 @@ async def verify_cabinet_ws_token(token: str) -> tuple[int | None, bool]:
|
||||
if not token:
|
||||
return None, False
|
||||
|
||||
payload = get_token_payload(token, expected_type="access")
|
||||
payload = get_token_payload(token, expected_type='access')
|
||||
if not payload:
|
||||
return None, False
|
||||
|
||||
try:
|
||||
user_id = int(payload.get("sub"))
|
||||
user_id = int(payload.get('sub'))
|
||||
except (TypeError, ValueError):
|
||||
return None, False
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user or user.status != "active":
|
||||
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(user.telegram_id)
|
||||
return user_id, is_admin
|
||||
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', e=str(e)[:200])
|
||||
return None, False
|
||||
|
||||
|
||||
@router.websocket("/ws")
|
||||
@router.websocket('/ws')
|
||||
async def cabinet_websocket_endpoint(websocket: WebSocket):
|
||||
"""WebSocket endpoint для real-time уведомлений кабинета."""
|
||||
client_host = websocket.client.host if websocket.client else "unknown"
|
||||
client_host = websocket.client.host if websocket.client else 'unknown'
|
||||
|
||||
# Получаем токен из query params
|
||||
token = websocket.query_params.get("token")
|
||||
token = websocket.query_params.get('token')
|
||||
|
||||
if not token:
|
||||
logger.debug("Cabinet WS: No token from %s", client_host)
|
||||
logger.debug('Cabinet WS: No token from', client_host=client_host)
|
||||
# Принимаем и сразу закрываем с кодом ошибки
|
||||
await websocket.accept()
|
||||
await websocket.close(code=1008, reason="Unauthorized: No token")
|
||||
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)
|
||||
logger.debug('Cabinet WS: Invalid token from', client_host=client_host)
|
||||
# Принимаем и сразу закрываем с кодом ошибки
|
||||
await websocket.accept()
|
||||
await websocket.close(code=1008, reason="Unauthorized: Invalid token")
|
||||
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)
|
||||
logger.debug('Cabinet WS accepted: user_id is_admin', user_id=user_id, is_admin=is_admin)
|
||||
except Exception as e:
|
||||
logger.error("Cabinet WS: Failed to accept from %s: %s", client_host, e)
|
||||
logger.error('Cabinet WS: Failed to accept from', client_host=client_host, e=e)
|
||||
return
|
||||
|
||||
# Регистрируем подключение
|
||||
@@ -189,11 +194,13 @@ async def cabinet_websocket_endpoint(websocket: WebSocket):
|
||||
|
||||
try:
|
||||
# Приветственное сообщение
|
||||
await websocket.send_json({
|
||||
"type": "connected",
|
||||
"user_id": user_id,
|
||||
"is_admin": is_admin,
|
||||
})
|
||||
await websocket.send_json(
|
||||
{
|
||||
'type': 'connected',
|
||||
'user_id': user_id,
|
||||
'is_admin': is_admin,
|
||||
}
|
||||
)
|
||||
|
||||
# Обрабатываем входящие сообщения
|
||||
while True:
|
||||
@@ -202,21 +209,21 @@ async def cabinet_websocket_endpoint(websocket: WebSocket):
|
||||
message = json.loads(data)
|
||||
|
||||
# Ping/pong для keepalive
|
||||
if message.get("type") == "ping":
|
||||
await websocket.send_json({"type": "pong"})
|
||||
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)
|
||||
logger.warning('Cabinet WS: Invalid JSON from user', user_id=user_id)
|
||||
except WebSocketDisconnect:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.exception("Cabinet WS error for user %d: %s", user_id, e)
|
||||
logger.exception('Cabinet WS error for user', user_id=user_id, e=e)
|
||||
break
|
||||
|
||||
except WebSocketDisconnect:
|
||||
logger.debug("Cabinet WS disconnected: user_id=%d", user_id)
|
||||
logger.debug('Cabinet WS disconnected: user_id', user_id=user_id)
|
||||
except Exception as e:
|
||||
logger.exception("Cabinet WS error: %s", e)
|
||||
logger.exception('Cabinet WS error', e=e)
|
||||
finally:
|
||||
await cabinet_ws_manager.disconnect(websocket, user_id)
|
||||
|
||||
@@ -224,28 +231,359 @@ async def cabinet_websocket_endpoint(websocket: WebSocket):
|
||||
# Функции для отправки уведомлений (используются из других модулей)
|
||||
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,
|
||||
})
|
||||
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,
|
||||
})
|
||||
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,
|
||||
})
|
||||
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,
|
||||
},
|
||||
)
|
||||
|
||||
+69
-63
@@ -1,41 +1,43 @@
|
||||
"""
|
||||
API роуты колеса удачи для пользователей.
|
||||
"""
|
||||
import logging
|
||||
|
||||
import math
|
||||
import time
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.database.crud.wheel import (
|
||||
get_or_create_wheel_config,
|
||||
get_wheel_prizes,
|
||||
get_user_spins_today,
|
||||
get_user_spin_history,
|
||||
)
|
||||
from app.services.wheel_service import wheel_service
|
||||
import httpx
|
||||
import structlog
|
||||
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 (
|
||||
WheelConfigResponse,
|
||||
WheelPrizeDisplay,
|
||||
SpinAvailabilityResponse,
|
||||
SpinHistoryItem,
|
||||
SpinHistoryResponse,
|
||||
SpinRequest,
|
||||
SpinResultResponse,
|
||||
SpinHistoryResponse,
|
||||
SpinHistoryItem,
|
||||
WheelConfigResponse,
|
||||
WheelPrizeDisplay,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/wheel", tags=["Fortune Wheel"])
|
||||
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
|
||||
|
||||
|
||||
@router.get("/config", response_model=WheelConfigResponse)
|
||||
logger = structlog.get_logger(__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),
|
||||
@@ -78,7 +80,7 @@ async def get_wheel_config(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/availability", response_model=SpinAvailabilityResponse)
|
||||
@router.get('/availability', response_model=SpinAvailabilityResponse)
|
||||
async def check_spin_availability(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
@@ -99,7 +101,7 @@ async def check_spin_availability(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/spin", response_model=SpinResultResponse)
|
||||
@router.post('/spin', response_model=SpinResultResponse)
|
||||
async def spin_wheel(
|
||||
request: SpinRequest,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
@@ -130,7 +132,7 @@ async def spin_wheel(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/history", response_model=SpinHistoryResponse)
|
||||
@router.get('/history', response_model=SpinHistoryResponse)
|
||||
async def get_spin_history(
|
||||
page: int = 1,
|
||||
per_page: int = 20,
|
||||
@@ -138,8 +140,7 @@ async def get_spin_history(
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Получить историю спинов пользователя."""
|
||||
if page < 1:
|
||||
page = 1
|
||||
page = max(page, 1)
|
||||
if per_page < 1 or per_page > 100:
|
||||
per_page = 20
|
||||
|
||||
@@ -150,24 +151,26 @@ async def get_spin_history(
|
||||
items = []
|
||||
for spin in spins:
|
||||
# Получаем emoji и color из приза, если он есть
|
||||
emoji = "🎁"
|
||||
color = "#3B82F6"
|
||||
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,
|
||||
))
|
||||
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
|
||||
|
||||
@@ -182,11 +185,12 @@ async def get_spin_history(
|
||||
|
||||
class StarsInvoiceResponse(BaseModel):
|
||||
"""Ответ с ссылкой на Stars invoice."""
|
||||
|
||||
invoice_url: str
|
||||
stars_amount: int
|
||||
|
||||
|
||||
@router.post("/stars-invoice", response_model=StarsInvoiceResponse)
|
||||
@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),
|
||||
@@ -200,13 +204,13 @@ async def create_stars_invoice(
|
||||
if not config.is_enabled:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Колесо удачи недоступно",
|
||||
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 не включена",
|
||||
detail='Оплата Stars не включена',
|
||||
)
|
||||
|
||||
# Проверяем лимит спинов
|
||||
@@ -214,7 +218,7 @@ async def create_stars_invoice(
|
||||
if config.daily_spin_limit > 0 and spins_today >= config.daily_spin_limit:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Достигнут дневной лимит спинов",
|
||||
detail='Достигнут дневной лимит спинов',
|
||||
)
|
||||
|
||||
# Проверяем наличие призов
|
||||
@@ -222,41 +226,43 @@ async def create_stars_invoice(
|
||||
if not prizes:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Призы не настроены",
|
||||
detail='Призы не настроены',
|
||||
)
|
||||
|
||||
stars_amount = config.spin_cost_stars
|
||||
payload = f"wheel_spin_{user.id}_{int(time.time())}"
|
||||
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"
|
||||
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}],
|
||||
'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}")
|
||||
if not result.get('ok'):
|
||||
logger.error('Telegram API error', result=result)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Ошибка создания инвойса",
|
||||
detail='Ошибка создания инвойса',
|
||||
)
|
||||
|
||||
invoice_url = result["result"]
|
||||
logger.info(f"Created Stars invoice for wheel spin: user={user.id}, stars={stars_amount}")
|
||||
invoice_url = result['result']
|
||||
logger.info(
|
||||
'Created Stars invoice for wheel spin: user=, stars', user_id=user.id, stars_amount=stars_amount
|
||||
)
|
||||
|
||||
return StarsInvoiceResponse(
|
||||
invoice_url=invoice_url,
|
||||
@@ -264,8 +270,8 @@ async def create_stars_invoice(
|
||||
)
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"HTTP error creating invoice: {e}")
|
||||
logger.error('HTTP error creating invoice', error=e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Ошибка соединения с Telegram",
|
||||
detail='Ошибка соединения с Telegram',
|
||||
)
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
"""User-facing withdrawal routes for cabinet."""
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import desc, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User, WithdrawalRequest, WithdrawalRequestStatus
|
||||
from app.services.referral_withdrawal_service import referral_withdrawal_service
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
from ..schemas.withdrawals import (
|
||||
WithdrawalBalanceResponse,
|
||||
WithdrawalCreateRequest,
|
||||
WithdrawalCreateResponse,
|
||||
WithdrawalItemResponse,
|
||||
WithdrawalListResponse,
|
||||
)
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/referral/withdrawal', tags=['Cabinet Withdrawal'])
|
||||
|
||||
|
||||
@router.get('/balance', response_model=WithdrawalBalanceResponse)
|
||||
async def get_withdrawal_balance(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get withdrawal balance stats for current user."""
|
||||
can_request, reason, stats = await referral_withdrawal_service.can_request_withdrawal(db, user.id)
|
||||
|
||||
return WithdrawalBalanceResponse(
|
||||
total_earned=stats['total_earned'],
|
||||
referral_spent=stats['referral_spent'],
|
||||
withdrawn=stats['withdrawn'],
|
||||
pending=stats['pending'],
|
||||
available_referral=stats['available_referral'],
|
||||
available_total=stats['available_total'],
|
||||
only_referral_mode=stats['only_referral_mode'],
|
||||
min_amount_kopeks=settings.REFERRAL_WITHDRAWAL_MIN_AMOUNT_KOPEKS,
|
||||
is_withdrawal_enabled=settings.is_referral_withdrawal_enabled(),
|
||||
can_request=can_request,
|
||||
cannot_request_reason=reason if not can_request else None,
|
||||
requisites_text=settings.REFERRAL_WITHDRAWAL_REQUISITES_TEXT,
|
||||
)
|
||||
|
||||
|
||||
@router.post('/create', response_model=WithdrawalCreateResponse)
|
||||
async def create_withdrawal(
|
||||
request: WithdrawalCreateRequest,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Create a withdrawal request."""
|
||||
withdrawal, error = await referral_withdrawal_service.create_withdrawal_request(
|
||||
db,
|
||||
user_id=user.id,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
payment_details=request.payment_details,
|
||||
)
|
||||
|
||||
if not withdrawal:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=error,
|
||||
)
|
||||
|
||||
# Уведомляем админов о запросе на вывод
|
||||
try:
|
||||
from aiogram import Bot
|
||||
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
if getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) and settings.BOT_TOKEN:
|
||||
bot = Bot(token=settings.BOT_TOKEN)
|
||||
try:
|
||||
notification_service = AdminNotificationService(bot)
|
||||
await notification_service.send_withdrawal_request_notification(
|
||||
user=user,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
payment_details=request.payment_details,
|
||||
)
|
||||
finally:
|
||||
await bot.session.close()
|
||||
except Exception as e:
|
||||
logger.error('Failed to send admin notification for withdrawal request', error=e)
|
||||
|
||||
return WithdrawalCreateResponse(
|
||||
id=withdrawal.id,
|
||||
amount_kopeks=withdrawal.amount_kopeks,
|
||||
status=withdrawal.status,
|
||||
)
|
||||
|
||||
|
||||
@router.get('/history', response_model=WithdrawalListResponse)
|
||||
async def get_withdrawal_history(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get user's withdrawal request history."""
|
||||
count_result = await db.execute(
|
||||
select(func.count()).select_from(WithdrawalRequest).where(WithdrawalRequest.user_id == user.id)
|
||||
)
|
||||
total = count_result.scalar() or 0
|
||||
|
||||
result = await db.execute(
|
||||
select(WithdrawalRequest)
|
||||
.where(WithdrawalRequest.user_id == user.id)
|
||||
.order_by(desc(WithdrawalRequest.created_at))
|
||||
.limit(50)
|
||||
)
|
||||
requests = result.scalars().all()
|
||||
|
||||
items = [
|
||||
WithdrawalItemResponse(
|
||||
id=r.id,
|
||||
amount_kopeks=r.amount_kopeks,
|
||||
amount_rubles=r.amount_kopeks / 100,
|
||||
status=r.status,
|
||||
payment_details=r.payment_details,
|
||||
admin_comment=r.admin_comment,
|
||||
created_at=r.created_at,
|
||||
processed_at=r.processed_at,
|
||||
)
|
||||
for r in requests
|
||||
]
|
||||
|
||||
return WithdrawalListResponse(items=items, total=total)
|
||||
|
||||
|
||||
@router.post('/{request_id}/cancel')
|
||||
async def cancel_withdrawal(
|
||||
request_id: int,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Cancel a pending withdrawal request."""
|
||||
result = await db.execute(
|
||||
select(WithdrawalRequest)
|
||||
.where(
|
||||
WithdrawalRequest.id == request_id,
|
||||
WithdrawalRequest.user_id == user.id,
|
||||
)
|
||||
.with_for_update()
|
||||
)
|
||||
withdrawal = result.scalar_one_or_none()
|
||||
|
||||
if not withdrawal:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Заявка не найдена',
|
||||
)
|
||||
|
||||
if withdrawal.status != WithdrawalRequestStatus.PENDING.value:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Можно отменить только заявку в ожидании',
|
||||
)
|
||||
|
||||
withdrawal.status = WithdrawalRequestStatus.CANCELLED.value
|
||||
await db.commit()
|
||||
|
||||
return {'success': True}
|
||||
@@ -1,88 +1,89 @@
|
||||
"""Cabinet Pydantic schemas."""
|
||||
|
||||
from .auth import (
|
||||
TelegramAuthRequest,
|
||||
TelegramWidgetAuthRequest,
|
||||
AuthResponse,
|
||||
EmailLoginRequest,
|
||||
EmailRegisterRequest,
|
||||
EmailVerifyRequest,
|
||||
EmailLoginRequest,
|
||||
RefreshTokenRequest,
|
||||
PasswordForgotRequest,
|
||||
PasswordResetRequest,
|
||||
RefreshTokenRequest,
|
||||
TelegramAuthRequest,
|
||||
TelegramWidgetAuthRequest,
|
||||
TokenResponse,
|
||||
UserResponse,
|
||||
AuthResponse,
|
||||
)
|
||||
from .subscription import (
|
||||
SubscriptionResponse,
|
||||
TrafficPurchaseInfo,
|
||||
RenewalOptionResponse,
|
||||
RenewalRequest,
|
||||
TrafficPackageResponse,
|
||||
TrafficPurchaseRequest,
|
||||
DevicePurchaseRequest,
|
||||
AutopayUpdateRequest,
|
||||
)
|
||||
from .balance import (
|
||||
BalanceResponse,
|
||||
TransactionResponse,
|
||||
TransactionListResponse,
|
||||
PaymentMethodResponse,
|
||||
TopUpRequest,
|
||||
TopUpResponse,
|
||||
TransactionListResponse,
|
||||
TransactionResponse,
|
||||
)
|
||||
from .referral import (
|
||||
ReferralEarningResponse,
|
||||
ReferralInfoResponse,
|
||||
ReferralListResponse,
|
||||
ReferralEarningResponse,
|
||||
ReferralTermsResponse,
|
||||
)
|
||||
from .subscription import (
|
||||
AutopayUpdateRequest,
|
||||
DevicePurchaseRequest,
|
||||
RenewalOptionResponse,
|
||||
RenewalRequest,
|
||||
SubscriptionResponse,
|
||||
TrafficPackageResponse,
|
||||
TrafficPurchaseInfo,
|
||||
TrafficPurchaseRequest,
|
||||
)
|
||||
from .tickets import (
|
||||
TicketResponse,
|
||||
TicketListResponse,
|
||||
TicketMessageResponse,
|
||||
TicketCreateRequest,
|
||||
TicketListResponse,
|
||||
TicketMessageCreateRequest,
|
||||
TicketMessageResponse,
|
||||
TicketResponse,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
# Auth
|
||||
"TelegramAuthRequest",
|
||||
"TelegramWidgetAuthRequest",
|
||||
"EmailRegisterRequest",
|
||||
"EmailVerifyRequest",
|
||||
"EmailLoginRequest",
|
||||
"RefreshTokenRequest",
|
||||
"PasswordForgotRequest",
|
||||
"PasswordResetRequest",
|
||||
"TokenResponse",
|
||||
"UserResponse",
|
||||
"AuthResponse",
|
||||
# Subscription
|
||||
"SubscriptionResponse",
|
||||
"TrafficPurchaseInfo",
|
||||
"RenewalOptionResponse",
|
||||
"RenewalRequest",
|
||||
"TrafficPackageResponse",
|
||||
"TrafficPurchaseRequest",
|
||||
"DevicePurchaseRequest",
|
||||
"AutopayUpdateRequest",
|
||||
'AuthResponse',
|
||||
'AutopayUpdateRequest',
|
||||
# Balance
|
||||
"BalanceResponse",
|
||||
"TransactionResponse",
|
||||
"TransactionListResponse",
|
||||
"PaymentMethodResponse",
|
||||
"TopUpRequest",
|
||||
"TopUpResponse",
|
||||
'BalanceResponse',
|
||||
'DevicePurchaseRequest',
|
||||
'EmailLoginRequest',
|
||||
'EmailRegisterRequest',
|
||||
'EmailVerifyRequest',
|
||||
'PasswordForgotRequest',
|
||||
'PasswordResetRequest',
|
||||
'PaymentMethodResponse',
|
||||
'ReferralEarningResponse',
|
||||
# Referral
|
||||
"ReferralInfoResponse",
|
||||
"ReferralListResponse",
|
||||
"ReferralEarningResponse",
|
||||
"ReferralTermsResponse",
|
||||
'ReferralInfoResponse',
|
||||
'ReferralListResponse',
|
||||
'ReferralTermsResponse',
|
||||
'RefreshTokenRequest',
|
||||
'RenewalOptionResponse',
|
||||
'RenewalRequest',
|
||||
# Subscription
|
||||
'SubscriptionResponse',
|
||||
# Auth
|
||||
'TelegramAuthRequest',
|
||||
'TelegramWidgetAuthRequest',
|
||||
'TicketCreateRequest',
|
||||
'TicketListResponse',
|
||||
'TicketMessageCreateRequest',
|
||||
'TicketMessageResponse',
|
||||
# Tickets
|
||||
"TicketResponse",
|
||||
"TicketListResponse",
|
||||
"TicketMessageResponse",
|
||||
"TicketCreateRequest",
|
||||
"TicketMessageCreateRequest",
|
||||
'TicketResponse',
|
||||
'TokenResponse',
|
||||
'TopUpRequest',
|
||||
'TopUpResponse',
|
||||
'TrafficPackageResponse',
|
||||
'TrafficPurchaseInfo',
|
||||
'TrafficPurchaseRequest',
|
||||
'TransactionListResponse',
|
||||
'TransactionResponse',
|
||||
'UserResponse',
|
||||
]
|
||||
|
||||
+100
-27
@@ -1,90 +1,163 @@
|
||||
"""Authentication schemas for cabinet."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
|
||||
|
||||
class TelegramAuthRequest(BaseModel):
|
||||
"""Request for Telegram WebApp initData authentication."""
|
||||
init_data: str = Field(..., description="Telegram WebApp initData string")
|
||||
|
||||
init_data: str = Field(..., description='Telegram WebApp initData string')
|
||||
campaign_slug: str | None = Field(
|
||||
None, min_length=1, max_length=64, pattern=r'^[a-zA-Z0-9_-]+$', description='Campaign slug from web link'
|
||||
)
|
||||
|
||||
|
||||
class TelegramWidgetAuthRequest(BaseModel):
|
||||
"""Request for Telegram Login Widget authentication."""
|
||||
id: int = Field(..., description="Telegram user ID")
|
||||
|
||||
id: int = Field(..., description='Telegram user ID')
|
||||
first_name: str = Field(..., description="User's first name")
|
||||
last_name: Optional[str] = Field(None, description="User's last name")
|
||||
username: Optional[str] = Field(None, description="User's username")
|
||||
photo_url: Optional[str] = Field(None, description="User's photo URL")
|
||||
auth_date: int = Field(..., description="Unix timestamp of authentication")
|
||||
hash: str = Field(..., description="Authentication hash")
|
||||
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')
|
||||
campaign_slug: str | None = Field(
|
||||
None, min_length=1, max_length=64, pattern=r'^[a-zA-Z0-9_-]+$', description='Campaign slug from web link'
|
||||
)
|
||||
|
||||
|
||||
class EmailRegisterRequest(BaseModel):
|
||||
"""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)")
|
||||
|
||||
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")
|
||||
|
||||
token: str = Field(..., description='Email verification token')
|
||||
campaign_slug: str | None = Field(
|
||||
None, min_length=1, max_length=64, pattern=r'^[a-zA-Z0-9_-]+$', description='Campaign slug from web link'
|
||||
)
|
||||
|
||||
|
||||
class EmailLoginRequest(BaseModel):
|
||||
"""Request to login with email and password."""
|
||||
email: EmailStr = Field(..., description="Email address")
|
||||
password: str = Field(..., description="Password")
|
||||
|
||||
email: EmailStr = Field(..., description='Email address')
|
||||
password: str = Field(..., description='Password')
|
||||
campaign_slug: str | None = Field(
|
||||
None, min_length=1, max_length=64, pattern=r'^[a-zA-Z0-9_-]+$', description='Campaign slug from web link'
|
||||
)
|
||||
|
||||
|
||||
class RefreshTokenRequest(BaseModel):
|
||||
"""Request to refresh access token."""
|
||||
refresh_token: str = Field(..., description="Refresh token")
|
||||
|
||||
refresh_token: str = Field(..., description='Refresh token')
|
||||
|
||||
|
||||
class PasswordForgotRequest(BaseModel):
|
||||
"""Request to initiate password reset."""
|
||||
email: EmailStr = Field(..., description="Email address")
|
||||
|
||||
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)")
|
||||
|
||||
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")
|
||||
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
|
||||
username: Optional[str] = None
|
||||
first_name: Optional[str] = None
|
||||
last_name: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
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: Optional[str] = None
|
||||
language: str = "ru"
|
||||
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 CampaignBonusInfo(BaseModel):
|
||||
"""Info about campaign bonus applied during auth."""
|
||||
|
||||
campaign_name: str
|
||||
bonus_type: str
|
||||
balance_kopeks: int = 0
|
||||
subscription_days: int | None = None
|
||||
tariff_name: str | None = None
|
||||
|
||||
|
||||
class AuthResponse(BaseModel):
|
||||
"""Full authentication response with tokens and user."""
|
||||
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str = "bearer"
|
||||
token_type: str = 'bearer'
|
||||
expires_in: int
|
||||
user: UserResponse
|
||||
campaign_bonus: CampaignBonusInfo | None = None
|
||||
|
||||
|
||||
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')
|
||||
|
||||
@@ -1,27 +1,30 @@
|
||||
"""Balance and payment schemas for cabinet."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional, List, Dict, Any
|
||||
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: Optional[str] = None
|
||||
payment_method: Optional[str] = None
|
||||
description: str | None = None
|
||||
payment_method: str | None = None
|
||||
is_completed: bool
|
||||
created_at: datetime
|
||||
completed_at: Optional[datetime] = None
|
||||
completed_at: datetime | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -29,7 +32,8 @@ class TransactionResponse(BaseModel):
|
||||
|
||||
class TransactionListResponse(BaseModel):
|
||||
"""Paginated transaction list."""
|
||||
items: List[TransactionResponse]
|
||||
|
||||
items: list[TransactionResponse]
|
||||
total: int
|
||||
page: int
|
||||
per_page: int
|
||||
@@ -38,46 +42,52 @@ class TransactionListResponse(BaseModel):
|
||||
|
||||
class PaymentOptionResponse(BaseModel):
|
||||
"""Payment method option (e.g. Platega sub-methods)."""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
description: str | None = None
|
||||
|
||||
|
||||
class PaymentMethodResponse(BaseModel):
|
||||
"""Available payment method."""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
description: str | None = None
|
||||
min_amount_kopeks: int
|
||||
max_amount_kopeks: int
|
||||
is_available: bool = True
|
||||
options: Optional[List[Dict[str, Any]]] = None
|
||||
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: Optional[str] = Field(None, description="Payment option (e.g. Platega method code)")
|
||||
|
||||
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: Optional[datetime] = None
|
||||
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)")
|
||||
|
||||
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
|
||||
@@ -85,6 +95,7 @@ class StarsInvoiceResponse(BaseModel):
|
||||
|
||||
class PendingPaymentResponse(BaseModel):
|
||||
"""Pending payment details for manual verification."""
|
||||
|
||||
id: int
|
||||
method: str
|
||||
method_display: str
|
||||
@@ -97,11 +108,11 @@ class PendingPaymentResponse(BaseModel):
|
||||
is_paid: bool
|
||||
is_checkable: bool
|
||||
created_at: datetime
|
||||
expires_at: Optional[datetime] = None
|
||||
payment_url: Optional[str] = None
|
||||
user_id: Optional[int] = None
|
||||
user_telegram_id: Optional[int] = None
|
||||
user_username: Optional[str] = None
|
||||
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
|
||||
@@ -109,7 +120,8 @@ class PendingPaymentResponse(BaseModel):
|
||||
|
||||
class PendingPaymentListResponse(BaseModel):
|
||||
"""Paginated list of pending payments."""
|
||||
items: List[PendingPaymentResponse]
|
||||
|
||||
items: list[PendingPaymentResponse]
|
||||
total: int
|
||||
page: int
|
||||
per_page: int
|
||||
@@ -118,9 +130,10 @@ class PendingPaymentListResponse(BaseModel):
|
||||
|
||||
class ManualCheckResponse(BaseModel):
|
||||
"""Response after manual payment status check."""
|
||||
|
||||
success: bool
|
||||
message: str
|
||||
payment: Optional[PendingPaymentResponse] = None
|
||||
payment: PendingPaymentResponse | None = None
|
||||
status_changed: bool = False
|
||||
old_status: Optional[str] = None
|
||||
new_status: Optional[str] = None
|
||||
old_status: str | None = None
|
||||
new_status: str | None = None
|
||||
|
||||
@@ -1,22 +1,27 @@
|
||||
"""Schemas for Ban System integration in cabinet."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Optional, Dict, Any
|
||||
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
|
||||
@@ -28,48 +33,53 @@ class BanSystemStatsResponse(BaseModel):
|
||||
agents_online: int = 0
|
||||
agents_total: int = 0
|
||||
panel_connected: bool = False
|
||||
uptime_seconds: Optional[int] = None
|
||||
uptime_seconds: int | None = None
|
||||
|
||||
|
||||
# === Users ===
|
||||
|
||||
|
||||
class BanUserIPInfo(BaseModel):
|
||||
"""User IP address information."""
|
||||
|
||||
ip: str
|
||||
first_seen: Optional[datetime] = None
|
||||
last_seen: Optional[datetime] = None
|
||||
node: Optional[str] = None
|
||||
first_seen: datetime | None = None
|
||||
last_seen: datetime | None = None
|
||||
node: str | None = None
|
||||
request_count: int = 0
|
||||
country_code: Optional[str] = None
|
||||
country_name: Optional[str] = None
|
||||
city: Optional[str] = None
|
||||
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: Optional[str] = None
|
||||
dest_port: Optional[int] = None
|
||||
protocol: Optional[str] = None
|
||||
action: Optional[str] = None
|
||||
node: Optional[str] = None
|
||||
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: Optional[int] = None
|
||||
limit: int | None = None
|
||||
is_over_limit: bool = False
|
||||
blocked_count: int = 0
|
||||
last_seen: Optional[datetime] = None
|
||||
last_seen: datetime | None = None
|
||||
|
||||
|
||||
class BanUsersListResponse(BaseModel):
|
||||
"""Paginated list of users."""
|
||||
users: List[BanUserListItem] = []
|
||||
|
||||
users: list[BanUserListItem] = []
|
||||
total: int = 0
|
||||
offset: int = 0
|
||||
limit: int = 50
|
||||
@@ -77,83 +87,95 @@ class BanUsersListResponse(BaseModel):
|
||||
|
||||
class BanUserDetailResponse(BaseModel):
|
||||
"""Detailed user information."""
|
||||
|
||||
email: str
|
||||
unique_ip_count: int = 0
|
||||
total_requests: int = 0
|
||||
limit: Optional[int] = None
|
||||
limit: int | None = None
|
||||
is_over_limit: bool = False
|
||||
blocked_count: int = 0
|
||||
ips: List[BanUserIPInfo] = []
|
||||
recent_requests: List[BanUserRequestLog] = []
|
||||
network_type: Optional[str] = None # wifi, mobile, mixed
|
||||
ips: list[BanUserIPInfo] = []
|
||||
recent_requests: list[BanUserRequestLog] = []
|
||||
network_type: str | None = None # wifi, mobile, mixed
|
||||
|
||||
|
||||
# === Punishments (Bans) ===
|
||||
|
||||
|
||||
class BanPunishmentItem(BaseModel):
|
||||
"""Punishment/ban entry."""
|
||||
id: Optional[int] = None
|
||||
|
||||
id: int | None = None
|
||||
user_id: str
|
||||
uuid: Optional[str] = None
|
||||
uuid: str | None = None
|
||||
username: str
|
||||
reason: Optional[str] = None
|
||||
reason: str | None = None
|
||||
punished_at: datetime
|
||||
enable_at: Optional[datetime] = None
|
||||
enable_at: datetime | None = None
|
||||
ip_count: int = 0
|
||||
limit: int = 0
|
||||
enabled: bool = False
|
||||
enabled_at: Optional[datetime] = None
|
||||
node_name: Optional[str] = None
|
||||
enabled_at: datetime | None = None
|
||||
node_name: str | None = None
|
||||
|
||||
|
||||
class BanPunishmentsListResponse(BaseModel):
|
||||
"""List of active punishments."""
|
||||
punishments: List[BanPunishmentItem] = []
|
||||
|
||||
punishments: list[BanPunishmentItem] = []
|
||||
total: int = 0
|
||||
|
||||
|
||||
class BanHistoryResponse(BaseModel):
|
||||
"""Punishment history."""
|
||||
items: List[BanPunishmentItem] = []
|
||||
|
||||
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: Optional[str] = Field(None, max_length=500)
|
||||
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: Optional[str] = None
|
||||
address: str | None = None
|
||||
is_connected: bool = False
|
||||
last_seen: Optional[datetime] = None
|
||||
last_seen: datetime | None = None
|
||||
users_count: int = 0
|
||||
agent_stats: Optional[Dict[str, Any]] = None
|
||||
agent_stats: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class BanNodesListResponse(BaseModel):
|
||||
"""List of nodes."""
|
||||
nodes: List[BanNodeItem] = []
|
||||
|
||||
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
|
||||
@@ -166,13 +188,14 @@ class BanAgentItem(BaseModel):
|
||||
dedup_skipped: int = 0
|
||||
filter_checked: int = 0
|
||||
filter_filtered: int = 0
|
||||
health: str = "unknown" # healthy, warning, critical
|
||||
health: str = 'unknown' # healthy, warning, critical
|
||||
is_online: bool = False
|
||||
last_report: Optional[datetime] = None
|
||||
last_report: datetime | None = None
|
||||
|
||||
|
||||
class BanAgentsSummary(BaseModel):
|
||||
"""Agents summary statistics."""
|
||||
|
||||
total_agents: int = 0
|
||||
online_agents: int = 0
|
||||
total_sent: int = 0
|
||||
@@ -185,16 +208,19 @@ class BanAgentsSummary(BaseModel):
|
||||
|
||||
class BanAgentsListResponse(BaseModel):
|
||||
"""List of agents."""
|
||||
agents: List[BanAgentItem] = []
|
||||
summary: Optional[BanAgentsSummary] = None
|
||||
|
||||
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
|
||||
@@ -204,22 +230,24 @@ class BanTrafficStats(BaseModel):
|
||||
|
||||
class BanTrafficUserItem(BaseModel):
|
||||
"""User traffic information."""
|
||||
|
||||
username: str
|
||||
email: Optional[str] = None
|
||||
email: str | None = None
|
||||
total_bytes: int = 0
|
||||
upload_bytes: int = 0
|
||||
download_bytes: int = 0
|
||||
limit_bytes: Optional[int] = None
|
||||
limit_bytes: int | None = None
|
||||
is_over_limit: bool = False
|
||||
|
||||
|
||||
class BanTrafficViolationItem(BaseModel):
|
||||
"""Traffic limit violation entry."""
|
||||
id: Optional[int] = None
|
||||
|
||||
id: int | None = None
|
||||
username: str
|
||||
email: Optional[str] = None
|
||||
email: str | None = None
|
||||
violation_type: str
|
||||
description: Optional[str] = None
|
||||
description: str | None = None
|
||||
bytes_used: int = 0
|
||||
bytes_limit: int = 0
|
||||
detected_at: datetime
|
||||
@@ -228,100 +256,117 @@ class BanTrafficViolationItem(BaseModel):
|
||||
|
||||
class BanTrafficViolationsResponse(BaseModel):
|
||||
"""List of traffic violations."""
|
||||
violations: List[BanTrafficViolationItem] = []
|
||||
|
||||
violations: list[BanTrafficViolationItem] = []
|
||||
total: int = 0
|
||||
|
||||
|
||||
class BanTrafficTopItem(BaseModel):
|
||||
"""Top user by traffic."""
|
||||
|
||||
username: str
|
||||
bytes_total: int = 0
|
||||
bytes_limit: Optional[int] = None
|
||||
bytes_limit: int | None = None
|
||||
over_limit: bool = False
|
||||
|
||||
|
||||
class BanTrafficResponse(BaseModel):
|
||||
"""Full traffic statistics response."""
|
||||
|
||||
enabled: bool = False
|
||||
stats: Optional[Dict[str, Any]] = None
|
||||
top_users: List[BanTrafficTopItem] = []
|
||||
recent_violations: List[BanTrafficViolationItem] = []
|
||||
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: Optional[int] = None
|
||||
max_value: Optional[int] = None
|
||||
min_value: int | None = None
|
||||
max_value: int | None = None
|
||||
editable: bool = True
|
||||
description: Optional[str] = None
|
||||
category: Optional[str] = None
|
||||
description: str | None = None
|
||||
category: str | None = None
|
||||
|
||||
|
||||
class BanSettingsResponse(BaseModel):
|
||||
"""All settings response."""
|
||||
settings: List[BanSettingDefinition] = []
|
||||
|
||||
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: Optional[Dict[str, Any]] = None
|
||||
top_violators: List[BanReportTopViolator] = []
|
||||
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: Optional[str] = None
|
||||
details: Optional[Dict[str, Any]] = None
|
||||
message: str | None = None
|
||||
details: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class BanHealthResponse(BaseModel):
|
||||
"""Health status response."""
|
||||
|
||||
status: str # healthy, degraded, unhealthy
|
||||
uptime: Optional[int] = None
|
||||
components: List[BanHealthComponent] = []
|
||||
uptime: int | None = None
|
||||
components: list[BanHealthComponent] = []
|
||||
|
||||
|
||||
class BanHealthDetailedResponse(BaseModel):
|
||||
"""Detailed health response."""
|
||||
|
||||
status: str
|
||||
uptime: Optional[int] = None
|
||||
components: Dict[str, Any] = {}
|
||||
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
|
||||
@@ -331,10 +376,11 @@ class BanAgentHistoryItem(BaseModel):
|
||||
|
||||
class BanAgentHistoryResponse(BaseModel):
|
||||
"""Agent history response."""
|
||||
|
||||
node: str
|
||||
hours: int = 24
|
||||
records: int = 0
|
||||
delta: Optional[Dict[str, Any]] = None
|
||||
first: Optional[Dict[str, Any]] = None
|
||||
last: Optional[Dict[str, Any]] = None
|
||||
history: List[BanAgentHistoryItem] = []
|
||||
delta: dict[str, Any] | None = None
|
||||
first: dict[str, Any] | None = None
|
||||
last: dict[str, Any] | None = None
|
||||
history: list[BanAgentHistoryItem] = []
|
||||
|
||||
@@ -1,23 +1,31 @@
|
||||
"""Pydantic schemas for cabinet broadcasts."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
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: Optional[int] = None
|
||||
group: Optional[str] = None # basic, subscription, traffic, registration, source, activity
|
||||
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
|
||||
@@ -26,15 +34,18 @@ class TariffFilter(BaseModel):
|
||||
|
||||
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
|
||||
|
||||
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}
|
||||
@@ -43,13 +54,16 @@ class TariffForBroadcast(BaseModel):
|
||||
|
||||
class BroadcastTariffsResponse(BaseModel):
|
||||
"""Response with tariffs for filtering."""
|
||||
tariffs: List[TariffForBroadcast]
|
||||
|
||||
tariffs: list[TariffForBroadcast]
|
||||
|
||||
|
||||
# ============ Buttons ============
|
||||
|
||||
|
||||
class BroadcastButton(BaseModel):
|
||||
"""Single broadcast button."""
|
||||
|
||||
key: str
|
||||
label: str
|
||||
default: bool = False
|
||||
@@ -57,56 +71,70 @@ class BroadcastButton(BaseModel):
|
||||
|
||||
class BroadcastButtonsResponse(BaseModel):
|
||||
"""Response with available buttons."""
|
||||
buttons: List[BroadcastButton]
|
||||
|
||||
buttons: list[BroadcastButton]
|
||||
|
||||
|
||||
# ============ Media ============
|
||||
|
||||
|
||||
class BroadcastMediaRequest(BaseModel):
|
||||
"""Media attachment for broadcast."""
|
||||
type: str = Field(..., pattern=r"^(photo|video|document)$")
|
||||
|
||||
type: str = Field(..., pattern=r'^(photo|video|document)$')
|
||||
file_id: str
|
||||
caption: Optional[str] = None
|
||||
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: Optional[BroadcastMediaRequest] = None
|
||||
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
|
||||
message_text: str | None = None
|
||||
has_media: bool
|
||||
media_type: Optional[str] = None
|
||||
media_file_id: Optional[str] = None
|
||||
media_caption: Optional[str] = None
|
||||
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
|
||||
blocked_count: int = 0
|
||||
status: str # queued|in_progress|completed|partial|failed|cancelled|cancelling
|
||||
admin_id: Optional[int] = None
|
||||
admin_name: Optional[str] = None
|
||||
admin_id: int | None = None
|
||||
admin_name: str | None = None
|
||||
created_at: datetime
|
||||
completed_at: Optional[datetime] = None
|
||||
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]
|
||||
|
||||
items: list[BroadcastResponse]
|
||||
total: int
|
||||
limit: int
|
||||
offset: int
|
||||
@@ -114,12 +142,69 @@ class BroadcastListResponse(BaseModel):
|
||||
|
||||
# ============ 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
|
||||
|
||||
@@ -1,21 +1,24 @@
|
||||
"""Schemas for advertising campaigns management in cabinet."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Optional, Literal
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
CampaignBonusType = Literal["balance", "subscription", "none", "tariff"]
|
||||
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
|
||||
@@ -24,6 +27,8 @@ class CampaignListItem(BaseModel):
|
||||
registrations_count: int
|
||||
total_revenue_kopeks: int = 0
|
||||
conversion_rate: float = 0.0
|
||||
partner_user_id: int | None = None
|
||||
partner_name: str | None = None
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
@@ -32,12 +37,14 @@ class CampaignListItem(BaseModel):
|
||||
|
||||
class CampaignListResponse(BaseModel):
|
||||
"""Response with list of campaigns."""
|
||||
campaigns: List[CampaignListItem]
|
||||
|
||||
campaigns: list[CampaignListItem]
|
||||
total: int
|
||||
|
||||
|
||||
class CampaignDetailResponse(BaseModel):
|
||||
"""Detailed campaign response."""
|
||||
|
||||
id: int
|
||||
name: str
|
||||
start_parameter: str
|
||||
@@ -47,20 +54,24 @@ class CampaignDetailResponse(BaseModel):
|
||||
balance_bonus_kopeks: int = 0
|
||||
balance_bonus_rubles: float = 0.0
|
||||
# Subscription bonus
|
||||
subscription_duration_days: Optional[int] = None
|
||||
subscription_traffic_gb: Optional[int] = None
|
||||
subscription_device_limit: Optional[int] = None
|
||||
subscription_squads: List[str] = Field(default_factory=list)
|
||||
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: Optional[int] = None
|
||||
tariff_duration_days: Optional[int] = None
|
||||
tariff: Optional[TariffInfo] = None
|
||||
tariff_id: int | None = None
|
||||
tariff_duration_days: int | None = None
|
||||
tariff: TariffInfo | None = None
|
||||
# Partner
|
||||
partner_user_id: int | None = None
|
||||
partner_name: str | None = None
|
||||
# Meta
|
||||
created_by: Optional[int] = None
|
||||
created_by: int | None = None
|
||||
created_at: datetime
|
||||
updated_at: Optional[datetime] = None
|
||||
updated_at: datetime | None = None
|
||||
# Deep link
|
||||
deep_link: Optional[str] = None
|
||||
deep_link: str | None = None
|
||||
web_link: str | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -68,42 +79,49 @@ class CampaignDetailResponse(BaseModel):
|
||||
|
||||
class CampaignCreateRequest(BaseModel):
|
||||
"""Request to create a campaign."""
|
||||
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
start_parameter: str = Field(..., min_length=1, max_length=100, pattern=r"^[a-zA-Z0-9_-]+$")
|
||||
start_parameter: str = Field(..., min_length=1, max_length=64, pattern=r'^[a-zA-Z0-9_-]+$')
|
||||
bonus_type: CampaignBonusType
|
||||
is_active: bool = True
|
||||
# Balance bonus
|
||||
balance_bonus_kopeks: int = Field(0, ge=0)
|
||||
# Subscription bonus
|
||||
subscription_duration_days: Optional[int] = Field(None, ge=1)
|
||||
subscription_traffic_gb: Optional[int] = Field(None, ge=0)
|
||||
subscription_device_limit: Optional[int] = Field(None, ge=1)
|
||||
subscription_squads: List[str] = Field(default_factory=list)
|
||||
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: Optional[int] = None
|
||||
tariff_duration_days: Optional[int] = Field(None, ge=1)
|
||||
tariff_id: int | None = None
|
||||
tariff_duration_days: int | None = Field(None, ge=1)
|
||||
# Partner
|
||||
partner_user_id: int | None = None
|
||||
|
||||
|
||||
class CampaignUpdateRequest(BaseModel):
|
||||
"""Request to update a campaign."""
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||
start_parameter: Optional[str] = Field(None, min_length=1, max_length=100, pattern=r"^[a-zA-Z0-9_-]+$")
|
||||
bonus_type: Optional[CampaignBonusType] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
name: str | None = Field(None, min_length=1, max_length=255)
|
||||
start_parameter: str | None = Field(None, min_length=1, max_length=64, pattern=r'^[a-zA-Z0-9_-]+$')
|
||||
bonus_type: CampaignBonusType | None = None
|
||||
is_active: bool | None = None
|
||||
# Balance bonus
|
||||
balance_bonus_kopeks: Optional[int] = Field(None, ge=0)
|
||||
balance_bonus_kopeks: int | None = Field(None, ge=0)
|
||||
# Subscription bonus
|
||||
subscription_duration_days: Optional[int] = Field(None, ge=1)
|
||||
subscription_traffic_gb: Optional[int] = Field(None, ge=0)
|
||||
subscription_device_limit: Optional[int] = Field(None, ge=1)
|
||||
subscription_squads: Optional[List[str]] = None
|
||||
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: Optional[int] = None
|
||||
tariff_duration_days: Optional[int] = Field(None, ge=1)
|
||||
tariff_id: int | None = None
|
||||
tariff_duration_days: int | None = Field(None, ge=1)
|
||||
# Partner
|
||||
partner_user_id: int | None = None
|
||||
|
||||
|
||||
class CampaignToggleResponse(BaseModel):
|
||||
"""Response after toggling campaign."""
|
||||
|
||||
id: int
|
||||
is_active: bool
|
||||
message: str
|
||||
@@ -111,6 +129,7 @@ class CampaignToggleResponse(BaseModel):
|
||||
|
||||
class CampaignStatisticsResponse(BaseModel):
|
||||
"""Detailed campaign statistics."""
|
||||
|
||||
id: int
|
||||
name: str
|
||||
start_parameter: str
|
||||
@@ -121,7 +140,7 @@ class CampaignStatisticsResponse(BaseModel):
|
||||
balance_issued_kopeks: int = 0
|
||||
balance_issued_rubles: float = 0.0
|
||||
subscription_issued: int = 0
|
||||
last_registration: Optional[datetime] = None
|
||||
last_registration: datetime | None = None
|
||||
# Revenue stats
|
||||
total_revenue_kopeks: int = 0
|
||||
total_revenue_rubles: float = 0.0
|
||||
@@ -137,21 +156,23 @@ class CampaignStatisticsResponse(BaseModel):
|
||||
conversion_rate: float = 0.0
|
||||
trial_conversion_rate: float = 0.0
|
||||
# Deep link
|
||||
deep_link: Optional[str] = None
|
||||
deep_link: str | None = None
|
||||
web_link: str | None = None
|
||||
|
||||
|
||||
class CampaignRegistrationItem(BaseModel):
|
||||
"""Campaign registration item."""
|
||||
|
||||
id: int
|
||||
user_id: int
|
||||
telegram_id: int
|
||||
username: Optional[str] = None
|
||||
first_name: Optional[str] = None
|
||||
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: Optional[int] = None
|
||||
tariff_id: Optional[int] = None
|
||||
tariff_duration_days: Optional[int] = None
|
||||
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
|
||||
@@ -164,7 +185,8 @@ class CampaignRegistrationItem(BaseModel):
|
||||
|
||||
class CampaignRegistrationsResponse(BaseModel):
|
||||
"""Response with campaign registrations."""
|
||||
registrations: List[CampaignRegistrationItem]
|
||||
|
||||
registrations: list[CampaignRegistrationItem]
|
||||
total: int
|
||||
page: int
|
||||
per_page: int
|
||||
@@ -172,6 +194,7 @@ class CampaignRegistrationsResponse(BaseModel):
|
||||
|
||||
class CampaignsOverviewResponse(BaseModel):
|
||||
"""Overview of all campaigns."""
|
||||
|
||||
total: int
|
||||
active: int
|
||||
inactive: int
|
||||
@@ -182,9 +205,18 @@ class CampaignsOverviewResponse(BaseModel):
|
||||
total_tariff_issued: int = 0
|
||||
|
||||
|
||||
class AvailablePartnerItem(BaseModel):
|
||||
"""Partner item for campaign partner selector."""
|
||||
|
||||
user_id: int
|
||||
username: str | None = None
|
||||
first_name: str | None = None
|
||||
|
||||
|
||||
class ServerSquadInfo(BaseModel):
|
||||
"""Server squad info for campaign selection."""
|
||||
|
||||
id: int
|
||||
squad_uuid: str
|
||||
display_name: str
|
||||
country_code: Optional[str] = None
|
||||
country_code: str | None = None
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
"""Partner system schemas for cabinet."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# ==================== User-facing ====================
|
||||
|
||||
|
||||
class PartnerApplicationRequest(BaseModel):
|
||||
"""Request to apply for partner status."""
|
||||
|
||||
company_name: str | None = Field(None, max_length=255)
|
||||
website_url: str | None = Field(None, max_length=500)
|
||||
telegram_channel: str | None = Field(None, max_length=255)
|
||||
description: str | None = Field(None, max_length=2000)
|
||||
expected_monthly_referrals: int | None = Field(None, ge=0)
|
||||
|
||||
|
||||
class PartnerApplicationInfo(BaseModel):
|
||||
"""Application info for the user."""
|
||||
|
||||
id: int
|
||||
status: str
|
||||
company_name: str | None = None
|
||||
website_url: str | None = None
|
||||
telegram_channel: str | None = None
|
||||
description: str | None = None
|
||||
expected_monthly_referrals: int | None = None
|
||||
admin_comment: str | None = None
|
||||
approved_commission_percent: int | None = None
|
||||
created_at: datetime
|
||||
processed_at: datetime | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class PartnerCampaignInfo(BaseModel):
|
||||
"""Campaign info visible to the partner."""
|
||||
|
||||
id: int
|
||||
name: str
|
||||
start_parameter: str
|
||||
bonus_type: str
|
||||
balance_bonus_kopeks: int = 0
|
||||
subscription_duration_days: int | None = None
|
||||
subscription_traffic_gb: int | None = None
|
||||
deep_link: str | None = None
|
||||
web_link: str | None = None
|
||||
|
||||
|
||||
class PartnerStatusResponse(BaseModel):
|
||||
"""Partner status for current user."""
|
||||
|
||||
partner_status: str
|
||||
commission_percent: int | None = None
|
||||
latest_application: PartnerApplicationInfo | None = None
|
||||
campaigns: list[PartnerCampaignInfo] = []
|
||||
|
||||
|
||||
# ==================== Admin-facing ====================
|
||||
|
||||
|
||||
class AdminPartnerApplicationItem(BaseModel):
|
||||
"""Partner application in admin list."""
|
||||
|
||||
id: int
|
||||
user_id: int
|
||||
username: str | None = None
|
||||
first_name: str | None = None
|
||||
telegram_id: int | None = None
|
||||
company_name: str | None = None
|
||||
website_url: str | None = None
|
||||
telegram_channel: str | None = None
|
||||
description: str | None = None
|
||||
expected_monthly_referrals: int | None = None
|
||||
status: str
|
||||
admin_comment: str | None = None
|
||||
approved_commission_percent: int | None = None
|
||||
created_at: datetime
|
||||
processed_at: datetime | None = None
|
||||
|
||||
|
||||
class AdminPartnerApplicationsResponse(BaseModel):
|
||||
"""List of partner applications."""
|
||||
|
||||
items: list[AdminPartnerApplicationItem]
|
||||
total: int
|
||||
|
||||
|
||||
class AdminApproveRequest(BaseModel):
|
||||
"""Request to approve a partner application."""
|
||||
|
||||
commission_percent: int = Field(..., ge=1, le=100)
|
||||
comment: str | None = Field(None, max_length=2000)
|
||||
|
||||
|
||||
class AdminRejectRequest(BaseModel):
|
||||
"""Request to reject a partner application."""
|
||||
|
||||
comment: str | None = Field(None, max_length=2000)
|
||||
|
||||
|
||||
class AdminPartnerItem(BaseModel):
|
||||
"""Partner in admin list."""
|
||||
|
||||
user_id: int
|
||||
username: str | None = None
|
||||
first_name: str | None = None
|
||||
telegram_id: int | None = None
|
||||
commission_percent: int | None = None
|
||||
total_referrals: int = 0
|
||||
total_earnings_kopeks: int = 0
|
||||
balance_kopeks: int = 0
|
||||
partner_status: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class AdminPartnerListResponse(BaseModel):
|
||||
"""List of partners for admin."""
|
||||
|
||||
items: list[AdminPartnerItem]
|
||||
total: int
|
||||
|
||||
|
||||
class CampaignSummary(BaseModel):
|
||||
"""Campaign summary for partner detail."""
|
||||
|
||||
id: int
|
||||
name: str
|
||||
start_parameter: str
|
||||
is_active: bool
|
||||
|
||||
|
||||
class AdminPartnerDetailResponse(BaseModel):
|
||||
"""Detailed partner info for admin."""
|
||||
|
||||
user_id: int
|
||||
username: str | None = None
|
||||
first_name: str | None = None
|
||||
telegram_id: int | None = None
|
||||
commission_percent: int | None = None
|
||||
partner_status: str
|
||||
balance_kopeks: int = 0
|
||||
total_referrals: int = 0
|
||||
paid_referrals: int = 0
|
||||
active_referrals: int = 0
|
||||
earnings_all_time: int = 0
|
||||
earnings_today: int = 0
|
||||
earnings_week: int = 0
|
||||
earnings_month: int = 0
|
||||
conversion_to_paid: float = 0.0
|
||||
campaigns: list[CampaignSummary] = []
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class AdminUpdateCommissionRequest(BaseModel):
|
||||
"""Request to update partner commission."""
|
||||
|
||||
commission_percent: int = Field(..., ge=1, le=100)
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Pydantic schemas for cabinet pinned messages."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class PinnedMessageMedia(BaseModel):
|
||||
type: str = Field(pattern=r'^(photo|video)$')
|
||||
file_id: str = Field(..., min_length=1, max_length=255)
|
||||
|
||||
|
||||
class PinnedMessageCreateRequest(BaseModel):
|
||||
content: str = Field(..., min_length=1, max_length=4000)
|
||||
media: PinnedMessageMedia | None = None
|
||||
send_before_menu: bool = True
|
||||
send_on_every_start: bool = True
|
||||
broadcast: bool = False
|
||||
|
||||
|
||||
class PinnedMessageUpdateRequest(BaseModel):
|
||||
content: str | None = Field(None, max_length=4000)
|
||||
send_before_menu: bool | None = None
|
||||
send_on_every_start: bool | None = None
|
||||
media: PinnedMessageMedia | None = None
|
||||
|
||||
|
||||
class PinnedMessageSettingsRequest(BaseModel):
|
||||
send_before_menu: bool | None = None
|
||||
send_on_every_start: bool | None = None
|
||||
|
||||
|
||||
class PinnedMessageResponse(BaseModel):
|
||||
id: int
|
||||
content: str | None
|
||||
media_type: str | None = None
|
||||
media_file_id: str | None = None
|
||||
send_before_menu: bool
|
||||
send_on_every_start: bool
|
||||
is_active: bool
|
||||
created_by: int | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime | None = None
|
||||
|
||||
|
||||
class PinnedMessageBroadcastResponse(BaseModel):
|
||||
message: PinnedMessageResponse
|
||||
sent_count: int
|
||||
failed_count: int
|
||||
|
||||
|
||||
class PinnedMessageUnpinResponse(BaseModel):
|
||||
unpinned_count: int
|
||||
failed_count: int
|
||||
was_active: bool
|
||||
|
||||
|
||||
class PinnedMessageListResponse(BaseModel):
|
||||
items: list[PinnedMessageResponse]
|
||||
total: int
|
||||
limit: int
|
||||
offset: int
|
||||
@@ -1,12 +1,13 @@
|
||||
"""Referral program schemas for cabinet."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class ReferralInfoResponse(BaseModel):
|
||||
"""Referral program info for current user."""
|
||||
|
||||
referral_code: str
|
||||
referral_link: str
|
||||
total_referrals: int
|
||||
@@ -18,9 +19,10 @@ class ReferralInfoResponse(BaseModel):
|
||||
|
||||
class ReferralItemResponse(BaseModel):
|
||||
"""Single referral info."""
|
||||
|
||||
id: int
|
||||
username: Optional[str] = None
|
||||
first_name: Optional[str] = None
|
||||
username: str | None = None
|
||||
first_name: str | None = None
|
||||
created_at: datetime
|
||||
has_subscription: bool
|
||||
has_paid: bool
|
||||
@@ -28,7 +30,8 @@ class ReferralItemResponse(BaseModel):
|
||||
|
||||
class ReferralListResponse(BaseModel):
|
||||
"""Paginated referral list."""
|
||||
items: List[ReferralItemResponse]
|
||||
|
||||
items: list[ReferralItemResponse]
|
||||
total: int
|
||||
page: int
|
||||
per_page: int
|
||||
@@ -37,12 +40,14 @@ class ReferralListResponse(BaseModel):
|
||||
|
||||
class ReferralEarningResponse(BaseModel):
|
||||
"""Referral earning history item."""
|
||||
|
||||
id: int
|
||||
amount_kopeks: int
|
||||
amount_rubles: float
|
||||
reason: str
|
||||
referral_username: Optional[str] = None
|
||||
referral_first_name: Optional[str] = None
|
||||
referral_username: str | None = None
|
||||
referral_first_name: str | None = None
|
||||
campaign_name: str | None = None
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
@@ -51,7 +56,8 @@ class ReferralEarningResponse(BaseModel):
|
||||
|
||||
class ReferralEarningsListResponse(BaseModel):
|
||||
"""Paginated referral earnings list."""
|
||||
items: List[ReferralEarningResponse]
|
||||
|
||||
items: list[ReferralEarningResponse]
|
||||
total: int
|
||||
total_amount_kopeks: int
|
||||
total_amount_rubles: float
|
||||
@@ -62,6 +68,7 @@ class ReferralEarningsListResponse(BaseModel):
|
||||
|
||||
class ReferralTermsResponse(BaseModel):
|
||||
"""Referral program terms."""
|
||||
|
||||
is_enabled: bool
|
||||
commission_percent: int
|
||||
minimum_topup_kopeks: int
|
||||
@@ -70,3 +77,4 @@ class ReferralTermsResponse(BaseModel):
|
||||
first_topup_bonus_rubles: float
|
||||
inviter_bonus_kopeks: int
|
||||
inviter_bonus_rubles: float
|
||||
partner_section_visible: bool = True
|
||||
|
||||
@@ -1,33 +1,38 @@
|
||||
"""Schemas for RemnaWave management in cabinet admin panel."""
|
||||
|
||||
from datetime import datetime, time
|
||||
from typing import Any, Dict, List, Literal, Optional
|
||||
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: Optional[str] = None
|
||||
status_code: Optional[int] = None
|
||||
system_info: Optional[Dict[str, Any]] = None
|
||||
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: Optional[str] = None
|
||||
connection: Optional[ConnectionStatus] = None
|
||||
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
|
||||
@@ -40,6 +45,7 @@ class SystemSummary(BaseModel):
|
||||
|
||||
class ServerInfo(BaseModel):
|
||||
"""Server hardware info."""
|
||||
|
||||
cpu_cores: int
|
||||
cpu_physical_cores: int
|
||||
memory_total: int
|
||||
@@ -51,6 +57,7 @@ class ServerInfo(BaseModel):
|
||||
|
||||
class Bandwidth(BaseModel):
|
||||
"""Realtime bandwidth statistics."""
|
||||
|
||||
realtime_download: int
|
||||
realtime_upload: int
|
||||
realtime_total: int
|
||||
@@ -58,13 +65,15 @@ class Bandwidth(BaseModel):
|
||||
|
||||
class TrafficPeriod(BaseModel):
|
||||
"""Traffic statistics for a period."""
|
||||
|
||||
current: int
|
||||
previous: int
|
||||
difference: Optional[str] = None
|
||||
difference: str | None = None
|
||||
|
||||
|
||||
class TrafficPeriods(BaseModel):
|
||||
"""Traffic statistics for multiple periods."""
|
||||
|
||||
last_2_days: TrafficPeriod
|
||||
last_7_days: TrafficPeriod
|
||||
last_30_days: TrafficPeriod
|
||||
@@ -74,190 +83,212 @@ class TrafficPeriods(BaseModel):
|
||||
|
||||
class SystemStatsResponse(BaseModel):
|
||||
"""Full system statistics response."""
|
||||
|
||||
system: SystemSummary
|
||||
users_by_status: Dict[str, int]
|
||||
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: Optional[datetime] = None
|
||||
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: Optional[str] = None
|
||||
country_code: str | None = None
|
||||
is_connected: bool
|
||||
is_disabled: bool
|
||||
is_node_online: bool
|
||||
is_xray_running: bool
|
||||
users_online: Optional[int] = None
|
||||
traffic_used_bytes: Optional[int] = None
|
||||
traffic_limit_bytes: Optional[int] = None
|
||||
last_status_change: Optional[datetime] = None
|
||||
last_status_message: Optional[str] = None
|
||||
xray_uptime: Optional[str] = None
|
||||
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: Optional[int] = None
|
||||
notify_percent: Optional[int] = None
|
||||
traffic_reset_day: int | None = None
|
||||
notify_percent: int | None = None
|
||||
consumption_multiplier: float = 1.0
|
||||
cpu_count: Optional[int] = None
|
||||
cpu_model: Optional[str] = None
|
||||
total_ram: Optional[str] = None
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
provider_uuid: Optional[str] = None
|
||||
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]
|
||||
|
||||
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]
|
||||
nodes: list[NodeInfo]
|
||||
|
||||
|
||||
class NodeStatisticsResponse(BaseModel):
|
||||
"""Node statistics with usage history."""
|
||||
|
||||
node: NodeInfo
|
||||
realtime: Optional[Dict[str, Any]] = None
|
||||
usage_history: List[Dict[str, Any]] = Field(default_factory=list)
|
||||
last_updated: Optional[datetime] = None
|
||||
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)
|
||||
|
||||
items: list[dict[str, Any]] = Field(default_factory=list)
|
||||
|
||||
|
||||
class NodeActionRequest(BaseModel):
|
||||
"""Request to perform node action."""
|
||||
action: Literal["enable", "disable", "restart"]
|
||||
|
||||
action: Literal['enable', 'disable', 'restart']
|
||||
|
||||
|
||||
class NodeActionResponse(BaseModel):
|
||||
"""Response after node action."""
|
||||
|
||||
success: bool
|
||||
message: Optional[str] = None
|
||||
is_disabled: Optional[bool] = None
|
||||
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)
|
||||
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)
|
||||
inbounds: list[dict[str, Any]] = Field(default_factory=list)
|
||||
# Local DB info
|
||||
local_id: Optional[int] = None
|
||||
display_name: Optional[str] = None
|
||||
country_code: Optional[str] = None
|
||||
is_available: Optional[bool] = None
|
||||
is_trial_eligible: Optional[bool] = None
|
||||
price_kopeks: Optional[int] = None
|
||||
max_users: Optional[int] = None
|
||||
current_users: Optional[int] = None
|
||||
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]
|
||||
|
||||
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)
|
||||
inbounds: list[dict[str, Any]] = Field(default_factory=list)
|
||||
# Local DB info if synced
|
||||
local_id: Optional[int] = None
|
||||
display_name: Optional[str] = None
|
||||
country_code: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
is_available: Optional[bool] = None
|
||||
is_trial_eligible: Optional[bool] = None
|
||||
price_kopeks: Optional[int] = None
|
||||
max_users: Optional[int] = None
|
||||
current_users: Optional[int] = None
|
||||
sort_order: Optional[int] = None
|
||||
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)
|
||||
inbound_uuids: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class SquadUpdateRequest(BaseModel):
|
||||
"""Request to update a squad."""
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||
inbound_uuids: Optional[List[str]] = None
|
||||
|
||||
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: Optional[str] = None
|
||||
inbound_uuids: Optional[List[str]] = None
|
||||
|
||||
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: Optional[str] = None
|
||||
data: Optional[Dict[str, Any]] = None
|
||||
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: Optional[int] = None
|
||||
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
|
||||
@@ -270,83 +301,96 @@ class MigrationStats(BaseModel):
|
||||
|
||||
class MigrationResponse(BaseModel):
|
||||
"""Response after migration."""
|
||||
|
||||
success: bool
|
||||
message: Optional[str] = None
|
||||
error: Optional[str] = None
|
||||
data: Optional[MigrationStats] = None
|
||||
message: str | None = None
|
||||
error: str | None = None
|
||||
data: MigrationStats | None = None
|
||||
|
||||
|
||||
# ============ Inbounds ============
|
||||
|
||||
|
||||
class InboundInfo(BaseModel):
|
||||
"""Inbound information."""
|
||||
|
||||
uuid: str
|
||||
tag: str
|
||||
type: Optional[str] = None
|
||||
network: Optional[str] = None
|
||||
security: Optional[str] = None
|
||||
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)
|
||||
|
||||
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: Optional[datetime] = None
|
||||
times: list[str] = Field(default_factory=list) # HH:MM format
|
||||
next_run: datetime | None = None
|
||||
is_running: bool = False
|
||||
last_run_started_at: Optional[datetime] = None
|
||||
last_run_finished_at: Optional[datetime] = None
|
||||
last_run_success: Optional[bool] = None
|
||||
last_run_reason: Optional[str] = None
|
||||
last_run_error: Optional[str] = None
|
||||
last_user_stats: Optional[Dict[str, Any]] = None
|
||||
last_server_stats: Optional[Dict[str, Any]] = None
|
||||
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: Optional[bool] = None
|
||||
error: Optional[str] = None
|
||||
user_stats: Optional[Dict[str, Any]] = None
|
||||
server_stats: Optional[Dict[str, Any]] = None
|
||||
reason: Optional[str] = None
|
||||
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"
|
||||
|
||||
mode: Literal['all', 'new_only', 'update_only'] = 'all'
|
||||
|
||||
|
||||
class SyncResponse(BaseModel):
|
||||
"""Response after sync operation."""
|
||||
|
||||
success: bool
|
||||
message: Optional[str] = None
|
||||
data: Optional[Dict[str, Any]] = None
|
||||
message: str | None = None
|
||||
data: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class SyncRecommendations(BaseModel):
|
||||
"""Sync recommendations."""
|
||||
|
||||
success: bool
|
||||
message: Optional[str] = None
|
||||
data: Optional[Dict[str, Any]] = None
|
||||
message: str | None = None
|
||||
data: dict[str, Any] | None = None
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
"""Schemas for server management in cabinet."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class PromoGroupInfo(BaseModel):
|
||||
"""Promo group info for server."""
|
||||
|
||||
id: int
|
||||
name: str
|
||||
is_selected: bool = False
|
||||
@@ -14,16 +15,17 @@ class PromoGroupInfo(BaseModel):
|
||||
|
||||
class ServerListItem(BaseModel):
|
||||
"""Server item for list view."""
|
||||
|
||||
id: int
|
||||
squad_uuid: str
|
||||
display_name: str
|
||||
original_name: Optional[str] = None
|
||||
country_code: Optional[str] = None
|
||||
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: Optional[int] = None
|
||||
max_users: int | None = None
|
||||
current_users: int
|
||||
sort_order: int
|
||||
is_full: bool
|
||||
@@ -36,32 +38,34 @@ class ServerListItem(BaseModel):
|
||||
|
||||
class ServerListResponse(BaseModel):
|
||||
"""Response with list of servers."""
|
||||
servers: List[ServerListItem]
|
||||
|
||||
servers: list[ServerListItem]
|
||||
total: int
|
||||
|
||||
|
||||
class ServerDetailResponse(BaseModel):
|
||||
"""Detailed server response."""
|
||||
|
||||
id: int
|
||||
squad_uuid: str
|
||||
display_name: str
|
||||
original_name: Optional[str] = None
|
||||
country_code: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
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: Optional[int] = None
|
||||
max_users: int | None = None
|
||||
current_users: int
|
||||
sort_order: int
|
||||
is_full: bool
|
||||
availability_status: str
|
||||
promo_groups: List[PromoGroupInfo]
|
||||
promo_groups: list[PromoGroupInfo]
|
||||
active_subscriptions: int
|
||||
tariffs_using: List[str] # Names of tariffs using this server
|
||||
tariffs_using: list[str] # Names of tariffs using this server
|
||||
created_at: datetime
|
||||
updated_at: Optional[datetime] = None
|
||||
updated_at: datetime | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -69,19 +73,21 @@ class ServerDetailResponse(BaseModel):
|
||||
|
||||
class ServerUpdateRequest(BaseModel):
|
||||
"""Request to update a server."""
|
||||
display_name: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||
description: Optional[str] = None
|
||||
country_code: Optional[str] = Field(None, max_length=5)
|
||||
is_available: Optional[bool] = None
|
||||
is_trial_eligible: Optional[bool] = None
|
||||
price_kopeks: Optional[int] = Field(None, ge=0)
|
||||
max_users: Optional[int] = Field(None, ge=0)
|
||||
sort_order: Optional[int] = Field(None, ge=0)
|
||||
promo_group_ids: Optional[List[int]] = None
|
||||
|
||||
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
|
||||
@@ -89,6 +95,7 @@ class ServerToggleResponse(BaseModel):
|
||||
|
||||
class ServerTrialToggleResponse(BaseModel):
|
||||
"""Response after toggling trial eligibility."""
|
||||
|
||||
id: int
|
||||
is_trial_eligible: bool
|
||||
message: str
|
||||
@@ -96,18 +103,20 @@ class ServerTrialToggleResponse(BaseModel):
|
||||
|
||||
class ServerStatsResponse(BaseModel):
|
||||
"""Server statistics."""
|
||||
|
||||
id: int
|
||||
display_name: str
|
||||
squad_uuid: str
|
||||
current_users: int
|
||||
max_users: Optional[int]
|
||||
max_users: int | None
|
||||
active_subscriptions: int
|
||||
trial_subscriptions: int
|
||||
usage_percent: Optional[float] = None
|
||||
usage_percent: float | None = None
|
||||
|
||||
|
||||
class ServerSyncResponse(BaseModel):
|
||||
"""Response after syncing with RemnaWave."""
|
||||
|
||||
created: int
|
||||
updated: int
|
||||
removed: int
|
||||
@@ -116,4 +125,5 @@ class ServerSyncResponse(BaseModel):
|
||||
|
||||
class ServerSyncRequest(BaseModel):
|
||||
"""Request to sync servers."""
|
||||
|
||||
force: bool = False # Force sync even if recently synced
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
"""Subscription schemas for cabinet."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class ServerInfo(BaseModel):
|
||||
"""Server info for display."""
|
||||
|
||||
uuid: str
|
||||
name: str
|
||||
country_code: Optional[str] = None
|
||||
country_code: str | None = None
|
||||
|
||||
|
||||
class TrafficPurchaseInfo(BaseModel):
|
||||
"""Purchased traffic package info."""
|
||||
|
||||
id: int
|
||||
traffic_gb: int
|
||||
expires_at: datetime
|
||||
@@ -22,8 +24,9 @@ class TrafficPurchaseInfo(BaseModel):
|
||||
progress_percent: float
|
||||
|
||||
|
||||
class SubscriptionResponse(BaseModel):
|
||||
class SubscriptionData(BaseModel):
|
||||
"""User subscription data."""
|
||||
|
||||
id: int
|
||||
status: str
|
||||
is_trial: bool
|
||||
@@ -32,48 +35,63 @@ class SubscriptionResponse(BaseModel):
|
||||
days_left: int
|
||||
hours_left: int = 0
|
||||
minutes_left: int = 0
|
||||
time_left_display: str = "" # Human readable format like "2д 5ч" or "5ч 30м"
|
||||
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
|
||||
connected_squads: list[str] = []
|
||||
servers: list[ServerInfo] = [] # Server display info
|
||||
autopay_enabled: bool
|
||||
autopay_days_before: int
|
||||
subscription_url: Optional[str] = None
|
||||
subscription_url: str | None = None
|
||||
hide_subscription_link: bool = False # Скрывать ли отображение ссылки (но кнопки работают)
|
||||
is_active: bool
|
||||
is_expired: bool
|
||||
traffic_purchases: List[TrafficPurchaseInfo] = []
|
||||
traffic_purchases: list[TrafficPurchaseInfo] = []
|
||||
# Daily tariff fields
|
||||
is_daily: bool = False
|
||||
is_daily_paused: bool = False
|
||||
daily_price_kopeks: Optional[int] = None
|
||||
next_daily_charge_at: Optional[datetime] = None # When next daily charge will happen
|
||||
tariff_id: Optional[int] = None
|
||||
tariff_name: Optional[str] = None
|
||||
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
|
||||
traffic_reset_mode: str | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# Backward compatibility alias
|
||||
SubscriptionResponse = SubscriptionData
|
||||
|
||||
|
||||
class SubscriptionStatusResponse(BaseModel):
|
||||
"""Response for subscription status endpoint - handles users with and without subscription."""
|
||||
|
||||
has_subscription: bool
|
||||
subscription: SubscriptionData | None = None
|
||||
|
||||
|
||||
class RenewalOptionResponse(BaseModel):
|
||||
"""Available subscription renewal option."""
|
||||
|
||||
period_days: int
|
||||
price_kopeks: int
|
||||
price_rubles: float
|
||||
discount_percent: int = 0
|
||||
original_price_kopeks: Optional[int] = None
|
||||
original_price_kopeks: int | None = None
|
||||
|
||||
|
||||
class RenewalRequest(BaseModel):
|
||||
"""Request to renew subscription."""
|
||||
period_days: int = Field(..., description="Renewal period in days")
|
||||
|
||||
period_days: int = Field(..., description='Renewal period in days')
|
||||
|
||||
|
||||
class TrafficPackageResponse(BaseModel):
|
||||
"""Available traffic package."""
|
||||
|
||||
gb: int
|
||||
price_kopeks: int
|
||||
price_rubles: float
|
||||
@@ -82,22 +100,26 @@ class TrafficPackageResponse(BaseModel):
|
||||
|
||||
class TrafficPurchaseRequest(BaseModel):
|
||||
"""Request to purchase additional traffic."""
|
||||
gb: int = Field(..., ge=0, description="GB to purchase (0 = unlimited)")
|
||||
|
||||
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")
|
||||
|
||||
devices: int = Field(..., ge=1, description='Number of additional devices')
|
||||
|
||||
|
||||
class AutopayUpdateRequest(BaseModel):
|
||||
"""Request to update autopay settings."""
|
||||
|
||||
enabled: bool
|
||||
days_before: Optional[int] = Field(None, ge=1, le=30, description="Days before expiration to charge")
|
||||
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
|
||||
@@ -105,29 +127,34 @@ class TrialInfoResponse(BaseModel):
|
||||
requires_payment: bool = False
|
||||
price_kopeks: int = 0
|
||||
price_rubles: float = 0.0
|
||||
reason_unavailable: Optional[str] = None
|
||||
reason_unavailable: str | None = None
|
||||
|
||||
|
||||
# ============ Purchase Options Schemas ============
|
||||
|
||||
|
||||
class PurchaseSelectionRequest(BaseModel):
|
||||
"""User's selection for subscription purchase."""
|
||||
period_id: Optional[str] = Field(None, description="Period ID like 'days:30'")
|
||||
period_days: Optional[int] = Field(None, description="Period in days")
|
||||
traffic_value: Optional[int] = Field(None, description="Traffic in GB (0 = unlimited)")
|
||||
servers: Optional[List[str]] = Field(default_factory=list, description="Server UUIDs")
|
||||
devices: Optional[int] = Field(None, description="Device limit")
|
||||
|
||||
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: Optional[int] = Field(None, ge=0, description="Custom traffic in GB (for custom_traffic_enabled tariffs)")
|
||||
|
||||
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)')
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
"""Schemas for tariff management in cabinet."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Optional, Dict
|
||||
|
||||
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: Optional[float] = None
|
||||
|
||||
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)
|
||||
@@ -19,21 +20,24 @@ class PeriodPrice(BaseModel):
|
||||
|
||||
class ServerTrafficLimit(BaseModel):
|
||||
"""Traffic limit for a specific server."""
|
||||
traffic_limit_gb: int = Field(0, ge=0, description="0 = use default tariff limit")
|
||||
|
||||
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: Optional[str] = None
|
||||
country_code: str | None = None
|
||||
is_selected: bool = False
|
||||
traffic_limit_gb: Optional[int] = None # Индивидуальный лимит для сервера
|
||||
traffic_limit_gb: int | None = None # Индивидуальный лимит для сервера
|
||||
|
||||
|
||||
class PromoGroupInfo(BaseModel):
|
||||
"""Promo group info for tariff."""
|
||||
|
||||
id: int
|
||||
name: str
|
||||
is_selected: bool = False
|
||||
@@ -41,9 +45,10 @@ class PromoGroupInfo(BaseModel):
|
||||
|
||||
class TariffListItem(BaseModel):
|
||||
"""Tariff item for list view."""
|
||||
|
||||
id: int
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
description: str | None = None
|
||||
is_active: bool
|
||||
is_trial_available: bool
|
||||
is_daily: bool = False
|
||||
@@ -63,32 +68,34 @@ class TariffListItem(BaseModel):
|
||||
|
||||
class TariffListResponse(BaseModel):
|
||||
"""Response with list of tariffs."""
|
||||
tariffs: List[TariffListItem]
|
||||
|
||||
tariffs: list[TariffListItem]
|
||||
total: int
|
||||
|
||||
|
||||
class TariffDetailResponse(BaseModel):
|
||||
"""Detailed tariff response."""
|
||||
|
||||
id: int
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
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)
|
||||
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: Optional[int] = None
|
||||
max_device_limit: Optional[int] = None
|
||||
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]
|
||||
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
|
||||
@@ -104,9 +111,9 @@ class TariffDetailResponse(BaseModel):
|
||||
is_daily: bool = False
|
||||
daily_price_kopeks: int = 0
|
||||
# Режим сброса трафика
|
||||
traffic_reset_mode: Optional[str] = None # DAY, WEEK, MONTH, NO_RESET, None = глобальная настройка
|
||||
traffic_reset_mode: str | None = None # DAY, WEEK, MONTH, NO_RESET, None = глобальная настройка
|
||||
created_at: datetime
|
||||
updated_at: Optional[datetime] = None
|
||||
updated_at: datetime | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -114,22 +121,25 @@ class TariffDetailResponse(BaseModel):
|
||||
|
||||
class TariffCreateRequest(BaseModel):
|
||||
"""Request to create a tariff."""
|
||||
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
description: Optional[str] = None
|
||||
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)
|
||||
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")
|
||||
traffic_limit_gb: int = Field(0, ge=0, description='0 = unlimited')
|
||||
device_limit: int = Field(1, ge=1)
|
||||
device_price_kopeks: Optional[int] = Field(None, ge=0)
|
||||
max_device_limit: Optional[int] = 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 = 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)
|
||||
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)
|
||||
@@ -144,47 +154,55 @@ class TariffCreateRequest(BaseModel):
|
||||
is_daily: bool = False
|
||||
daily_price_kopeks: int = Field(0, ge=0)
|
||||
# Режим сброса трафика
|
||||
traffic_reset_mode: Optional[str] = None # DAY, WEEK, MONTH, NO_RESET, None = глобальная настройка
|
||||
traffic_reset_mode: str | None = None # DAY, WEEK, MONTH, NO_RESET, None = глобальная настройка
|
||||
|
||||
|
||||
class TariffUpdateRequest(BaseModel):
|
||||
"""Request to update a tariff."""
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||
description: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
allow_traffic_topup: Optional[bool] = None
|
||||
traffic_topup_enabled: Optional[bool] = None
|
||||
traffic_topup_packages: Optional[Dict[str, int]] = None
|
||||
max_topup_traffic_gb: Optional[int] = Field(None, ge=0)
|
||||
traffic_limit_gb: Optional[int] = Field(None, ge=0)
|
||||
device_limit: Optional[int] = Field(None, ge=1)
|
||||
device_price_kopeks: Optional[int] = Field(None, ge=0)
|
||||
max_device_limit: Optional[int] = Field(None, ge=1)
|
||||
tier_level: Optional[int] = Field(None, ge=1, le=10)
|
||||
display_order: Optional[int] = Field(None, ge=0)
|
||||
period_prices: Optional[List[PeriodPrice]] = None
|
||||
allowed_squads: Optional[List[str]] = None
|
||||
server_traffic_limits: Optional[Dict[str, ServerTrafficLimit]] = None
|
||||
promo_group_ids: Optional[List[int]] = None
|
||||
|
||||
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: Optional[bool] = None
|
||||
price_per_day_kopeks: Optional[int] = Field(None, ge=0)
|
||||
min_days: Optional[int] = Field(None, ge=1)
|
||||
max_days: Optional[int] = Field(None, ge=1)
|
||||
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: Optional[bool] = None
|
||||
traffic_price_per_gb_kopeks: Optional[int] = Field(None, ge=0)
|
||||
min_traffic_gb: Optional[int] = Field(None, ge=1)
|
||||
max_traffic_gb: Optional[int] = 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: Optional[bool] = None
|
||||
daily_price_kopeks: Optional[int] = Field(None, ge=0)
|
||||
is_daily: bool | None = None
|
||||
daily_price_kopeks: int | None = Field(None, ge=0)
|
||||
# Режим сброса трафика
|
||||
traffic_reset_mode: Optional[str] = None # DAY, WEEK, MONTH, NO_RESET, None = глобальная настройка
|
||||
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
|
||||
@@ -192,6 +210,7 @@ class TariffToggleResponse(BaseModel):
|
||||
|
||||
class TariffTrialResponse(BaseModel):
|
||||
"""Response after setting trial tariff."""
|
||||
|
||||
id: int
|
||||
is_trial_available: bool
|
||||
message: str
|
||||
@@ -199,6 +218,7 @@ class TariffTrialResponse(BaseModel):
|
||||
|
||||
class TariffStatsResponse(BaseModel):
|
||||
"""Tariff statistics."""
|
||||
|
||||
id: int
|
||||
name: str
|
||||
subscriptions_count: int
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
"""Support tickets schemas for cabinet."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
|
||||
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: Optional[str] = None
|
||||
media_file_id: Optional[str] = None
|
||||
media_caption: Optional[str] = None
|
||||
media_type: str | None = None
|
||||
media_file_id: str | None = None
|
||||
media_caption: str | None = None
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
@@ -22,15 +23,16 @@ class TicketMessageResponse(BaseModel):
|
||||
|
||||
class TicketResponse(BaseModel):
|
||||
"""Ticket data."""
|
||||
|
||||
id: int
|
||||
title: str
|
||||
status: str
|
||||
priority: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
closed_at: Optional[datetime] = None
|
||||
closed_at: datetime | None = None
|
||||
messages_count: int = 0
|
||||
last_message: Optional[TicketMessageResponse] = None
|
||||
last_message: TicketMessageResponse | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -38,15 +40,16 @@ class TicketResponse(BaseModel):
|
||||
|
||||
class TicketDetailResponse(BaseModel):
|
||||
"""Ticket with all messages."""
|
||||
|
||||
id: int
|
||||
title: str
|
||||
status: str
|
||||
priority: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
closed_at: Optional[datetime] = None
|
||||
closed_at: datetime | None = None
|
||||
is_reply_blocked: bool = False
|
||||
messages: List[TicketMessageResponse] = []
|
||||
messages: list[TicketMessageResponse] = []
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -54,7 +57,8 @@ class TicketDetailResponse(BaseModel):
|
||||
|
||||
class TicketListResponse(BaseModel):
|
||||
"""Paginated ticket list."""
|
||||
items: List[TicketResponse]
|
||||
|
||||
items: list[TicketResponse]
|
||||
total: int
|
||||
page: int
|
||||
per_page: int
|
||||
@@ -63,16 +67,18 @@ class TicketListResponse(BaseModel):
|
||||
|
||||
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: Optional[str] = Field(None, description="Media type: photo, video, document")
|
||||
media_file_id: Optional[str] = Field(None, description="Telegram file_id of uploaded media")
|
||||
media_caption: Optional[str] = Field(None, max_length=1000, description="Media caption")
|
||||
|
||||
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: Optional[str] = Field(None, description="Media type: photo, video, document")
|
||||
media_file_id: Optional[str] = Field(None, description="Telegram file_id of uploaded media")
|
||||
media_caption: Optional[str] = Field(None, max_length=1000, description="Media caption")
|
||||
|
||||
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
|
||||
+356
-115
@@ -1,58 +1,78 @@
|
||||
"""Schemas for Admin Users management in cabinet."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Optional, Dict, Any
|
||||
from pydantic import BaseModel, Field
|
||||
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"
|
||||
|
||||
ACTIVE = 'active'
|
||||
BLOCKED = 'blocked'
|
||||
DELETED = 'deleted'
|
||||
|
||||
|
||||
class SubscriptionStatusEnum(str, Enum):
|
||||
"""Subscription status enum."""
|
||||
TRIAL = "trial"
|
||||
ACTIVE = "active"
|
||||
EXPIRED = "expired"
|
||||
DISABLED = "disabled"
|
||||
PENDING = "pending"
|
||||
|
||||
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"
|
||||
|
||||
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: Optional[datetime] = None
|
||||
end_date: Optional[datetime] = None
|
||||
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: Optional[int] = None
|
||||
tariff_name: Optional[str] = None
|
||||
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
|
||||
@@ -60,29 +80,31 @@ class UserPromoGroupInfo(BaseModel):
|
||||
|
||||
# === User List ===
|
||||
|
||||
|
||||
class UserListItem(BaseModel):
|
||||
"""User item in list."""
|
||||
|
||||
id: int
|
||||
telegram_id: int
|
||||
username: Optional[str] = None
|
||||
first_name: Optional[str] = None
|
||||
last_name: Optional[str] = None
|
||||
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: Optional[datetime] = None
|
||||
last_activity: datetime | None = None
|
||||
|
||||
# Subscription summary
|
||||
has_subscription: bool = False
|
||||
subscription_status: Optional[str] = None
|
||||
subscription_status: str | None = None
|
||||
subscription_is_trial: bool = False
|
||||
subscription_end_date: Optional[datetime] = None
|
||||
subscription_end_date: datetime | None = None
|
||||
|
||||
# Promo group
|
||||
promo_group_id: Optional[int] = None
|
||||
promo_group_name: Optional[str] = None
|
||||
promo_group_id: int | None = None
|
||||
promo_group_name: str | None = None
|
||||
|
||||
# Stats
|
||||
total_spent_kopeks: int = 0
|
||||
@@ -96,7 +118,8 @@ class UserListItem(BaseModel):
|
||||
|
||||
class UsersListResponse(BaseModel):
|
||||
"""Paginated list of users."""
|
||||
users: List[UserListItem]
|
||||
|
||||
users: list[UserListItem]
|
||||
total: int
|
||||
offset: int = 0
|
||||
limit: int = 50
|
||||
@@ -104,35 +127,39 @@ class UsersListResponse(BaseModel):
|
||||
|
||||
# === User Detail ===
|
||||
|
||||
|
||||
class UserTransactionItem(BaseModel):
|
||||
"""User transaction."""
|
||||
|
||||
id: int
|
||||
type: str
|
||||
amount_kopeks: int
|
||||
amount_rubles: float
|
||||
description: Optional[str] = None
|
||||
payment_method: Optional[str] = None
|
||||
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: Optional[int] = None
|
||||
referred_by_id: Optional[int] = None
|
||||
referred_by_username: Optional[str] = None
|
||||
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
|
||||
username: Optional[str] = None
|
||||
first_name: Optional[str] = None
|
||||
last_name: Optional[str] = None
|
||||
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
|
||||
@@ -140,20 +167,20 @@ class UserDetailResponse(BaseModel):
|
||||
balance_rubles: float
|
||||
|
||||
# Email (cabinet)
|
||||
email: Optional[str] = None
|
||||
email: str | None = None
|
||||
email_verified: bool = False
|
||||
|
||||
# Dates
|
||||
created_at: datetime
|
||||
updated_at: Optional[datetime] = None
|
||||
last_activity: Optional[datetime] = None
|
||||
cabinet_last_login: Optional[datetime] = None
|
||||
updated_at: datetime | None = None
|
||||
last_activity: datetime | None = None
|
||||
cabinet_last_login: datetime | None = None
|
||||
|
||||
# Subscription
|
||||
subscription: Optional[UserSubscriptionInfo] = None
|
||||
subscription: UserSubscriptionInfo | None = None
|
||||
|
||||
# Promo group
|
||||
promo_group: Optional[UserPromoGroupInfo] = None
|
||||
promo_group: UserPromoGroupInfo | None = None
|
||||
|
||||
# Referral
|
||||
referral: UserReferralInfo
|
||||
@@ -168,28 +195,80 @@ class UserDetailResponse(BaseModel):
|
||||
# Restrictions
|
||||
restriction_topup: bool = False
|
||||
restriction_subscription: bool = False
|
||||
restriction_reason: Optional[str] = None
|
||||
restriction_reason: str | None = None
|
||||
|
||||
# Promo offer
|
||||
promo_offer_discount_percent: int = 0
|
||||
promo_offer_discount_source: Optional[str] = None
|
||||
promo_offer_discount_expires_at: Optional[datetime] = None
|
||||
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] = []
|
||||
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")
|
||||
|
||||
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
|
||||
@@ -198,44 +277,56 @@ class UpdateBalanceResponse(BaseModel):
|
||||
|
||||
class UpdateSubscriptionRequest(BaseModel):
|
||||
"""Request to update user subscription."""
|
||||
action: str = Field(..., description="Action: extend, set_end_date, change_tariff, set_traffic, toggle_autopay, cancel")
|
||||
|
||||
action: str = Field(
|
||||
..., description='Action: extend, set_end_date, change_tariff, set_traffic, toggle_autopay, cancel'
|
||||
)
|
||||
|
||||
# For extend action
|
||||
days: Optional[int] = Field(None, ge=1, le=3650, description="Days to extend")
|
||||
days: int | None = Field(None, ge=1, le=3650, description='Days to extend')
|
||||
|
||||
# For set_end_date action
|
||||
end_date: Optional[datetime] = Field(None, description="New end date")
|
||||
end_date: datetime | None = Field(None, description='New end date')
|
||||
|
||||
# For change_tariff action
|
||||
tariff_id: Optional[int] = Field(None, description="New tariff ID")
|
||||
tariff_id: int | None = Field(None, description='New tariff ID')
|
||||
|
||||
# For set_traffic action
|
||||
traffic_limit_gb: Optional[int] = Field(None, ge=0, description="New traffic limit in GB")
|
||||
traffic_used_gb: Optional[float] = Field(None, ge=0, description="Set traffic used in GB")
|
||||
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: Optional[bool] = Field(None, description="Enable/disable 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: Optional[bool] = Field(None, description="Is trial subscription")
|
||||
device_limit: Optional[int] = Field(None, ge=1, description="Device limit")
|
||||
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: Optional[UserSubscriptionInfo] = None
|
||||
subscription: UserSubscriptionInfo | None = None
|
||||
|
||||
|
||||
class UpdateUserStatusRequest(BaseModel):
|
||||
"""Request to update user status."""
|
||||
|
||||
status: UserStatusEnum
|
||||
reason: Optional[str] = Field(None, max_length=500, description="Reason for status change")
|
||||
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
|
||||
@@ -244,50 +335,108 @@ class UpdateUserStatusResponse(BaseModel):
|
||||
|
||||
class UpdateRestrictionsRequest(BaseModel):
|
||||
"""Request to update user restrictions."""
|
||||
restriction_topup: Optional[bool] = Field(None, description="Block balance top-up")
|
||||
restriction_subscription: Optional[bool] = Field(None, description="Block subscription purchase/renewal")
|
||||
restriction_reason: Optional[str] = Field(None, max_length=500, description="Reason for 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: Optional[str] = None
|
||||
restriction_reason: str | None = None
|
||||
message: str
|
||||
|
||||
|
||||
class UpdatePromoGroupRequest(BaseModel):
|
||||
"""Request to update user promo group."""
|
||||
promo_group_id: Optional[int] = Field(None, description="New promo group ID (null to remove)")
|
||||
|
||||
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: Optional[int] = None
|
||||
new_promo_group_id: Optional[int] = None
|
||||
promo_group_name: Optional[str] = None
|
||||
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: Optional[str] = Field(None, max_length=500, description="Reason for deletion")
|
||||
|
||||
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
|
||||
@@ -315,20 +464,23 @@ class UsersStatsResponse(BaseModel):
|
||||
|
||||
# === 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"
|
||||
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
|
||||
@@ -336,9 +488,10 @@ class PeriodPriceInfo(BaseModel):
|
||||
|
||||
class UserAvailableTariffItem(BaseModel):
|
||||
"""Tariff available for user."""
|
||||
|
||||
id: int
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
description: str | None = None
|
||||
is_active: bool = True
|
||||
is_trial_available: bool = False
|
||||
traffic_limit_gb: int = 0
|
||||
@@ -347,7 +500,7 @@ class UserAvailableTariffItem(BaseModel):
|
||||
display_order: int = 0
|
||||
|
||||
# Pricing
|
||||
period_prices: List[PeriodPriceInfo] = []
|
||||
period_prices: list[PeriodPriceInfo] = []
|
||||
is_daily: bool = False
|
||||
daily_price_kopeks: int = 0
|
||||
|
||||
@@ -357,6 +510,15 @@ class UserAvailableTariffItem(BaseModel):
|
||||
min_days: int = 1
|
||||
max_days: int = 365
|
||||
|
||||
# Device limits
|
||||
device_price_kopeks: int | None = None
|
||||
max_device_limit: int | None = None
|
||||
|
||||
# Traffic topup
|
||||
traffic_topup_enabled: bool = False
|
||||
traffic_topup_packages: dict[str, int] = {}
|
||||
max_topup_traffic_gb: int = 0
|
||||
|
||||
# Access info
|
||||
is_available: bool = True # Available for this user's promo group
|
||||
requires_promo_group: bool = False # Requires specific promo group
|
||||
@@ -364,92 +526,171 @@ class UserAvailableTariffItem(BaseModel):
|
||||
|
||||
class UserAvailableTariffsResponse(BaseModel):
|
||||
"""List of tariffs available for user."""
|
||||
|
||||
user_id: int
|
||||
promo_group_id: Optional[int] = None
|
||||
promo_group_name: Optional[str] = None
|
||||
tariffs: List[UserAvailableTariffItem] = []
|
||||
promo_group_id: int | None = None
|
||||
promo_group_name: str | None = None
|
||||
tariffs: list[UserAvailableTariffItem] = []
|
||||
total: int = 0
|
||||
|
||||
# Current subscription tariff
|
||||
current_tariff_id: Optional[int] = None
|
||||
current_tariff_name: Optional[str] = None
|
||||
current_tariff_id: int | None = None
|
||||
current_tariff_name: str | None = None
|
||||
|
||||
|
||||
# === Panel Sync ===
|
||||
|
||||
|
||||
class PanelUserInfo(BaseModel):
|
||||
"""User info from panel."""
|
||||
uuid: Optional[str] = None
|
||||
short_uuid: Optional[str] = None
|
||||
username: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
expire_at: Optional[datetime] = None
|
||||
|
||||
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: Optional[str] = None
|
||||
active_squads: List[str] = []
|
||||
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")
|
||||
|
||||
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: Optional[PanelUserInfo] = None
|
||||
changes: Dict[str, Any] = {}
|
||||
errors: List[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")
|
||||
|
||||
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: Optional[str] = None
|
||||
changes: Dict[str, Any] = {}
|
||||
errors: List[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
|
||||
remnawave_uuid: Optional[str] = None
|
||||
last_sync: Optional[datetime] = None
|
||||
telegram_id: int | None = None
|
||||
remnawave_uuid: str | None = None
|
||||
last_sync: datetime | None = None
|
||||
|
||||
# Bot data
|
||||
bot_subscription_status: Optional[str] = None
|
||||
bot_subscription_end_date: Optional[datetime] = None
|
||||
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] = []
|
||||
bot_squads: list[str] = []
|
||||
|
||||
# Panel data (if available)
|
||||
panel_found: bool = False
|
||||
panel_status: Optional[str] = None
|
||||
panel_expire_at: Optional[datetime] = None
|
||||
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] = []
|
||||
panel_squads: list[str] = []
|
||||
|
||||
# Differences
|
||||
has_differences: bool = False
|
||||
differences: List[str] = []
|
||||
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
|
||||
|
||||
@@ -1,27 +1,29 @@
|
||||
"""Схемы для колеса удачи (Fortune Wheel)."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
from pydantic import BaseModel, Field
|
||||
from enum import Enum
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# ==================== ENUMS ====================
|
||||
|
||||
|
||||
class WheelPaymentType(str, Enum):
|
||||
"""Способы оплаты спина."""
|
||||
TELEGRAM_STARS = "telegram_stars"
|
||||
SUBSCRIPTION_DAYS = "subscription_days"
|
||||
|
||||
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"
|
||||
|
||||
SUBSCRIPTION_DAYS = 'subscription_days'
|
||||
BALANCE_BONUS = 'balance_bonus'
|
||||
TRAFFIC_GB = 'traffic_gb'
|
||||
PROMOCODE = 'promocode'
|
||||
NOTHING = 'nothing'
|
||||
|
||||
|
||||
# ==================== USER SCHEMAS ====================
|
||||
@@ -29,6 +31,7 @@ class WheelPrizeType(str, Enum):
|
||||
|
||||
class WheelPrizeDisplay(BaseModel):
|
||||
"""Отображение приза для пользователя."""
|
||||
|
||||
id: int
|
||||
display_name: str
|
||||
emoji: str
|
||||
@@ -41,17 +44,18 @@ class WheelPrizeDisplay(BaseModel):
|
||||
|
||||
class WheelConfigResponse(BaseModel):
|
||||
"""Конфигурация колеса для пользователя."""
|
||||
|
||||
is_enabled: bool
|
||||
name: str
|
||||
spin_cost_stars: Optional[int] = None
|
||||
spin_cost_days: Optional[int] = None
|
||||
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]
|
||||
prizes: list[WheelPrizeDisplay]
|
||||
daily_limit: int
|
||||
user_spins_today: int
|
||||
can_spin: bool
|
||||
can_spin_reason: Optional[str] = None
|
||||
can_spin_reason: str | None = None
|
||||
can_pay_stars: bool = False
|
||||
can_pay_days: bool = False
|
||||
user_balance_kopeks: int = 0
|
||||
@@ -60,8 +64,9 @@ class WheelConfigResponse(BaseModel):
|
||||
|
||||
class SpinAvailabilityResponse(BaseModel):
|
||||
"""Доступность спина."""
|
||||
|
||||
can_spin: bool
|
||||
reason: Optional[str] = None
|
||||
reason: str | None = None
|
||||
spins_remaining_today: int
|
||||
can_pay_stars: bool
|
||||
can_pay_days: bool
|
||||
@@ -73,34 +78,37 @@ class SpinAvailabilityResponse(BaseModel):
|
||||
|
||||
class SpinRequest(BaseModel):
|
||||
"""Запрос на спин."""
|
||||
|
||||
payment_type: WheelPaymentType
|
||||
|
||||
|
||||
class SpinResultResponse(BaseModel):
|
||||
"""Результат спина."""
|
||||
|
||||
success: bool
|
||||
prize_id: Optional[int] = None
|
||||
prize_type: Optional[str] = None
|
||||
prize_id: int | None = None
|
||||
prize_type: str | None = None
|
||||
prize_value: int = 0
|
||||
prize_display_name: str = ""
|
||||
emoji: str = "🎁"
|
||||
color: str = "#3B82F6"
|
||||
prize_display_name: str = ''
|
||||
emoji: str = '🎁'
|
||||
color: str = '#3B82F6'
|
||||
rotation_degrees: float = 0.0
|
||||
message: str = ""
|
||||
promocode: Optional[str] = None
|
||||
error: Optional[str] = None
|
||||
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"
|
||||
emoji: str = '🎁'
|
||||
color: str = '#3B82F6'
|
||||
prize_value_kopeks: int
|
||||
created_at: datetime
|
||||
|
||||
@@ -110,7 +118,8 @@ class SpinHistoryItem(BaseModel):
|
||||
|
||||
class SpinHistoryResponse(BaseModel):
|
||||
"""История спинов с пагинацией."""
|
||||
items: List[SpinHistoryItem]
|
||||
|
||||
items: list[SpinHistoryItem]
|
||||
total: int
|
||||
page: int
|
||||
per_page: int
|
||||
@@ -122,6 +131,7 @@ class SpinHistoryResponse(BaseModel):
|
||||
|
||||
class WheelPrizeAdminResponse(BaseModel):
|
||||
"""Полная информация о призе для админа."""
|
||||
|
||||
id: int
|
||||
config_id: int
|
||||
prize_type: str
|
||||
@@ -131,13 +141,13 @@ class WheelPrizeAdminResponse(BaseModel):
|
||||
color: str
|
||||
prize_value_kopeks: int
|
||||
sort_order: int
|
||||
manual_probability: Optional[float] = None
|
||||
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: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -145,6 +155,7 @@ class WheelPrizeAdminResponse(BaseModel):
|
||||
|
||||
class AdminWheelConfigResponse(BaseModel):
|
||||
"""Полная конфигурация колеса для админа."""
|
||||
|
||||
id: int
|
||||
is_enabled: bool
|
||||
name: str
|
||||
@@ -157,9 +168,9 @@ class AdminWheelConfigResponse(BaseModel):
|
||||
min_subscription_days_for_day_payment: int
|
||||
promo_prefix: str
|
||||
promo_validity_days: int
|
||||
prizes: List[WheelPrizeAdminResponse]
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
prizes: list[WheelPrizeAdminResponse]
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -167,29 +178,31 @@ class AdminWheelConfigResponse(BaseModel):
|
||||
|
||||
class UpdateWheelConfigRequest(BaseModel):
|
||||
"""Запрос на обновление конфига колеса."""
|
||||
is_enabled: Optional[bool] = None
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||
spin_cost_stars: Optional[int] = Field(None, ge=1, le=1000)
|
||||
spin_cost_days: Optional[int] = Field(None, ge=1, le=30)
|
||||
spin_cost_stars_enabled: Optional[bool] = None
|
||||
spin_cost_days_enabled: Optional[bool] = None
|
||||
rtp_percent: Optional[int] = Field(None, ge=0, le=100)
|
||||
daily_spin_limit: Optional[int] = Field(None, ge=0, le=100)
|
||||
min_subscription_days_for_day_payment: Optional[int] = Field(None, ge=1, le=30)
|
||||
promo_prefix: Optional[str] = Field(None, min_length=1, max_length=20)
|
||||
promo_validity_days: Optional[int] = Field(None, ge=1, le=365)
|
||||
|
||||
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}$')
|
||||
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: Optional[float] = Field(None, ge=0, le=1)
|
||||
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)
|
||||
@@ -198,30 +211,33 @@ class CreatePrizeRequest(BaseModel):
|
||||
|
||||
class UpdatePrizeRequest(BaseModel):
|
||||
"""Запрос на обновление приза."""
|
||||
prize_type: Optional[WheelPrizeType] = None
|
||||
prize_value: Optional[int] = Field(None, ge=0)
|
||||
display_name: Optional[str] = Field(None, min_length=1, max_length=100)
|
||||
emoji: Optional[str] = Field(None, max_length=10)
|
||||
color: Optional[str] = Field(None, pattern=r'^#[0-9A-Fa-f]{6}$')
|
||||
prize_value_kopeks: Optional[int] = Field(None, ge=0)
|
||||
sort_order: Optional[int] = Field(None, ge=0)
|
||||
manual_probability: Optional[float] = Field(None, ge=0, le=1)
|
||||
is_active: Optional[bool] = None
|
||||
promo_balance_bonus_kopeks: Optional[int] = Field(None, ge=0)
|
||||
promo_subscription_days: Optional[int] = Field(None, ge=0)
|
||||
promo_traffic_gb: Optional[int] = Field(None, ge=0)
|
||||
|
||||
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]
|
||||
|
||||
prize_ids: list[int]
|
||||
|
||||
|
||||
class AdminSpinItem(BaseModel):
|
||||
"""Спин для админки."""
|
||||
|
||||
id: int
|
||||
user_id: int
|
||||
username: Optional[str] = None
|
||||
username: str | None = None
|
||||
payment_type: str
|
||||
payment_amount: int
|
||||
payment_value_kopeks: int
|
||||
@@ -238,7 +254,8 @@ class AdminSpinItem(BaseModel):
|
||||
|
||||
class AdminSpinsResponse(BaseModel):
|
||||
"""Список спинов для админки с пагинацией."""
|
||||
items: List[AdminSpinItem]
|
||||
|
||||
items: list[AdminSpinItem]
|
||||
total: int
|
||||
page: int
|
||||
per_page: int
|
||||
@@ -247,13 +264,14 @@ class AdminSpinsResponse(BaseModel):
|
||||
|
||||
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: Optional[str] = None
|
||||
period_to: Optional[str] = None
|
||||
prizes_distribution: list[dict]
|
||||
top_wins: list[dict]
|
||||
period_from: str | None = None
|
||||
period_to: str | None = None
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
"""Withdrawal system schemas for cabinet."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# ==================== User-facing ====================
|
||||
|
||||
|
||||
class WithdrawalBalanceResponse(BaseModel):
|
||||
"""Withdrawal balance info for user."""
|
||||
|
||||
total_earned: int
|
||||
referral_spent: int
|
||||
withdrawn: int
|
||||
pending: int
|
||||
available_referral: int
|
||||
available_total: int
|
||||
only_referral_mode: bool
|
||||
min_amount_kopeks: int
|
||||
is_withdrawal_enabled: bool
|
||||
can_request: bool
|
||||
cannot_request_reason: str | None = None
|
||||
requisites_text: str = ''
|
||||
|
||||
|
||||
class WithdrawalCreateRequest(BaseModel):
|
||||
"""Request to create a withdrawal."""
|
||||
|
||||
amount_kopeks: int = Field(..., gt=0, le=10_000_000)
|
||||
payment_details: str = Field(..., min_length=5, max_length=1000)
|
||||
|
||||
|
||||
class WithdrawalItemResponse(BaseModel):
|
||||
"""Withdrawal request item."""
|
||||
|
||||
id: int
|
||||
amount_kopeks: int
|
||||
amount_rubles: float
|
||||
status: str
|
||||
payment_details: str | None = None
|
||||
admin_comment: str | None = None
|
||||
created_at: datetime
|
||||
processed_at: datetime | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class WithdrawalListResponse(BaseModel):
|
||||
"""List of user's withdrawal requests."""
|
||||
|
||||
items: list[WithdrawalItemResponse]
|
||||
total: int
|
||||
|
||||
|
||||
class WithdrawalCreateResponse(BaseModel):
|
||||
"""Response after creating withdrawal."""
|
||||
|
||||
id: int
|
||||
amount_kopeks: int
|
||||
status: str
|
||||
|
||||
|
||||
# ==================== Admin-facing ====================
|
||||
|
||||
|
||||
class AdminWithdrawalItem(BaseModel):
|
||||
"""Withdrawal request in admin list."""
|
||||
|
||||
id: int
|
||||
user_id: int
|
||||
username: str | None = None
|
||||
first_name: str | None = None
|
||||
telegram_id: int | None = None
|
||||
amount_kopeks: int
|
||||
amount_rubles: float
|
||||
status: str
|
||||
risk_score: int = 0
|
||||
risk_level: str = 'low'
|
||||
payment_details: str | None = None
|
||||
admin_comment: str | None = None
|
||||
created_at: datetime
|
||||
processed_at: datetime | None = None
|
||||
|
||||
|
||||
class AdminWithdrawalListResponse(BaseModel):
|
||||
"""List of withdrawal requests for admin."""
|
||||
|
||||
items: list[AdminWithdrawalItem]
|
||||
total: int
|
||||
pending_count: int = 0
|
||||
pending_total_kopeks: int = 0
|
||||
|
||||
|
||||
class AdminWithdrawalDetailResponse(BaseModel):
|
||||
"""Detailed withdrawal request for admin."""
|
||||
|
||||
id: int
|
||||
user_id: int
|
||||
username: str | None = None
|
||||
first_name: str | None = None
|
||||
telegram_id: int | None = None
|
||||
amount_kopeks: int
|
||||
amount_rubles: float
|
||||
status: str
|
||||
risk_score: int = 0
|
||||
risk_level: str = 'low'
|
||||
risk_analysis: dict | None = None
|
||||
payment_details: str | None = None
|
||||
admin_comment: str | None = None
|
||||
balance_kopeks: int = 0
|
||||
total_referrals: int = 0
|
||||
total_earnings_kopeks: int = 0
|
||||
created_at: datetime
|
||||
processed_at: datetime | None = None
|
||||
|
||||
|
||||
class AdminApproveWithdrawalRequest(BaseModel):
|
||||
"""Request to approve a withdrawal."""
|
||||
|
||||
comment: str | None = Field(None, max_length=2000)
|
||||
|
||||
|
||||
class AdminRejectWithdrawalRequest(BaseModel):
|
||||
"""Request to reject a withdrawal."""
|
||||
|
||||
comment: str | None = Field(None, max_length=2000)
|
||||
@@ -2,4 +2,5 @@
|
||||
|
||||
from .email_service import EmailService, email_service
|
||||
|
||||
__all__ = ["EmailService", "email_service"]
|
||||
|
||||
__all__ = ['EmailService', 'email_service']
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
"""Email service for sending verification and password reset emails."""
|
||||
|
||||
import logging
|
||||
import smtplib
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from typing import Optional
|
||||
from email.mime.text import MIMEText
|
||||
|
||||
import structlog
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class EmailService:
|
||||
@@ -29,14 +30,19 @@ class EmailService:
|
||||
|
||||
def _get_smtp_connection(self) -> smtplib.SMTP:
|
||||
"""Create and return SMTP connection."""
|
||||
if self.use_tls:
|
||||
smtp = smtplib.SMTP(self.host, self.port)
|
||||
smtp.starttls()
|
||||
else:
|
||||
smtp = smtplib.SMTP(self.host, self.port)
|
||||
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:
|
||||
smtp.login(self.user, self.password)
|
||||
if smtp.has_extn('auth'):
|
||||
smtp.login(self.user, self.password)
|
||||
else:
|
||||
logger.debug('SMTP server does not support AUTH, skipping authentication', host=self.host)
|
||||
|
||||
return smtp
|
||||
|
||||
@@ -45,7 +51,7 @@ class EmailService:
|
||||
to_email: str,
|
||||
subject: str,
|
||||
body_html: str,
|
||||
body_text: Optional[str] = None,
|
||||
body_text: str | None = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Send an email.
|
||||
@@ -60,27 +66,28 @@ class EmailService:
|
||||
True if email was sent successfully, False otherwise
|
||||
"""
|
||||
if not self.is_configured():
|
||||
logger.warning("SMTP is not configured, cannot send email")
|
||||
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
|
||||
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")
|
||||
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)
|
||||
@@ -88,11 +95,11 @@ class EmailService:
|
||||
with self._get_smtp_connection() as smtp:
|
||||
smtp.sendmail(self.from_email, to_email, msg.as_string())
|
||||
|
||||
logger.info(f"Email sent successfully to {to_email}")
|
||||
logger.info('Email sent successfully to', to_email=to_email)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send email to {to_email}: {e}")
|
||||
logger.error('Failed to send email to', to_email=to_email, error=e)
|
||||
return False
|
||||
|
||||
def send_verification_email(
|
||||
@@ -100,7 +107,10 @@ class EmailService:
|
||||
to_email: str,
|
||||
verification_token: str,
|
||||
verification_url: str,
|
||||
username: Optional[str] = None,
|
||||
username: str | None = None,
|
||||
language: str = 'ru',
|
||||
custom_subject: str | None = None,
|
||||
custom_body_html: str | None = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Send email verification email.
|
||||
@@ -110,14 +120,76 @@ class EmailService:
|
||||
verification_token: Verification token
|
||||
verification_url: Base URL for verification (token will be appended)
|
||||
username: User's name for personalization
|
||||
language: Language code (ru, en, zh, ua, 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
|
||||
"""
|
||||
full_url = f"{verification_url}?token={verification_token}"
|
||||
greeting = f"Hello{', ' + username if username else ''}!"
|
||||
if custom_subject and custom_body_html:
|
||||
return self.send_email(to_email, custom_subject, custom_body_html)
|
||||
|
||||
subject = "Verify your email address"
|
||||
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>
|
||||
@@ -140,15 +212,15 @@ class EmailService:
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h2>{greeting}</h2>
|
||||
<p>Thank you for registering! Please verify your email address by clicking the button below:</p>
|
||||
<a href="{full_url}" class="button">Verify Email</a>
|
||||
<p>Or copy and paste this link in your browser:</p>
|
||||
<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>This link will expire in {settings.get_cabinet_email_verification_expire_hours()} hours.</p>
|
||||
<p>If you didn't create an account, you can safely ignore this email.</p>
|
||||
<p>{t['expires']}</p>
|
||||
<p>{t['ignore']}</p>
|
||||
<div class="footer">
|
||||
<p>Best regards,<br>{self.from_name}</p>
|
||||
<p>{t['regards']}<br>{self.from_name}</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
@@ -162,7 +234,10 @@ class EmailService:
|
||||
to_email: str,
|
||||
reset_token: str,
|
||||
reset_url: str,
|
||||
username: Optional[str] = None,
|
||||
username: str | None = None,
|
||||
language: str = 'ru',
|
||||
custom_subject: str | None = None,
|
||||
custom_body_html: str | None = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Send password reset email.
|
||||
@@ -172,14 +247,76 @@ class EmailService:
|
||||
reset_token: Password reset token
|
||||
reset_url: Base URL for password reset (token will be appended)
|
||||
username: User's name for personalization
|
||||
language: Language code (ru, en, zh, ua, 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
|
||||
"""
|
||||
full_url = f"{reset_url}?token={reset_token}"
|
||||
greeting = f"Hello{', ' + username if username else ''}!"
|
||||
if custom_subject and custom_body_html:
|
||||
return self.send_email(to_email, custom_subject, custom_body_html)
|
||||
|
||||
subject = "Reset your password"
|
||||
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>
|
||||
@@ -203,15 +340,140 @@ class EmailService:
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h2>{greeting}</h2>
|
||||
<p>We received a request to reset your password. Click the button below to set a new password:</p>
|
||||
<a href="{full_url}" class="button">Reset Password</a>
|
||||
<p>Or copy and paste this link in your browser:</p>
|
||||
<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>This link will expire in {settings.get_cabinet_password_reset_expire_hours()} hour(s).</p>
|
||||
<p class="warning">If you didn't request a password reset, please ignore this email or contact support if you're concerned.</p>
|
||||
<p>{t['expires']}</p>
|
||||
<p class="warning">{t['warning']}</p>
|
||||
<div class="footer">
|
||||
<p>Best regards,<br>{self.from_name}</p>
|
||||
<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>
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
"""
|
||||
Service for managing email template overrides stored in the database.
|
||||
|
||||
Custom templates override the hardcoded defaults from email_templates.py.
|
||||
"""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.database import AsyncSessionLocal
|
||||
|
||||
|
||||
logger = structlog.get_logger(__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 шаблона /', notification_type=notification_type, language=language, e=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.now(UTC)
|
||||
|
||||
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
+1054
-918
File diff suppressed because it is too large
Load Diff
@@ -8,16 +8,17 @@ from .database import (
|
||||
get_db,
|
||||
get_db_read_only,
|
||||
get_pool_metrics,
|
||||
init_db,
|
||||
sync_postgres_sequences,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DatabaseManager",
|
||||
"batch_ops",
|
||||
"close_db",
|
||||
"db_manager",
|
||||
"get_db",
|
||||
"get_db_read_only",
|
||||
"get_pool_metrics",
|
||||
"init_db",
|
||||
'DatabaseManager',
|
||||
'batch_ops',
|
||||
'close_db',
|
||||
'db_manager',
|
||||
'get_db',
|
||||
'get_db_read_only',
|
||||
'get_pool_metrics',
|
||||
'sync_postgres_sequences',
|
||||
]
|
||||
|
||||
+81
-169
@@ -1,8 +1,7 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Optional
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import and_, func, select, update, delete
|
||||
import structlog
|
||||
from sqlalchemy import and_, delete, func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
@@ -17,7 +16,8 @@ from app.database.models import (
|
||||
User,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def create_campaign(
|
||||
@@ -26,16 +26,17 @@ 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: Optional[int] = None,
|
||||
tariff_duration_days: Optional[int] = None,
|
||||
tariff_id: int | None = None,
|
||||
tariff_duration_days: int | None = None,
|
||||
is_active: bool = True,
|
||||
partner_user_id: int | None = None,
|
||||
) -> AdvertisingCampaign:
|
||||
campaign = AdvertisingCampaign(
|
||||
name=name,
|
||||
@@ -50,6 +51,7 @@ async def create_campaign(
|
||||
tariff_duration_days=tariff_duration_days,
|
||||
created_by=created_by,
|
||||
is_active=is_active,
|
||||
partner_user_id=partner_user_id,
|
||||
)
|
||||
|
||||
db.add(campaign)
|
||||
@@ -57,22 +59,21 @@ async def create_campaign(
|
||||
await db.refresh(campaign)
|
||||
|
||||
logger.info(
|
||||
"📣 Создана рекламная кампания %s (start=%s, bonus=%s)",
|
||||
campaign.name,
|
||||
campaign.start_parameter,
|
||||
campaign.bonus_type,
|
||||
'📣 Создана рекламная кампания (start bonus=)',
|
||||
campaign_name=campaign.name,
|
||||
start_parameter=campaign.start_parameter,
|
||||
bonus_type=campaign.bonus_type,
|
||||
)
|
||||
return campaign
|
||||
|
||||
|
||||
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),
|
||||
selectinload(AdvertisingCampaign.tariff),
|
||||
selectinload(AdvertisingCampaign.partner),
|
||||
)
|
||||
.where(AdvertisingCampaign.id == campaign_id)
|
||||
)
|
||||
@@ -84,10 +85,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))
|
||||
|
||||
@@ -101,12 +100,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),
|
||||
selectinload(AdvertisingCampaign.tariff),
|
||||
selectinload(AdvertisingCampaign.partner),
|
||||
)
|
||||
.order_by(AdvertisingCampaign.created_at.desc())
|
||||
.offset(offset)
|
||||
@@ -119,9 +119,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))
|
||||
@@ -136,17 +134,18 @@ 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",
|
||||
"tariff_id",
|
||||
"tariff_duration_days",
|
||||
"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',
|
||||
'partner_user_id',
|
||||
}
|
||||
|
||||
update_data = {}
|
||||
@@ -157,33 +156,27 @@ async def update_campaign(
|
||||
if not update_data:
|
||||
return campaign
|
||||
|
||||
update_data["updated_at"] = datetime.utcnow()
|
||||
update_data['updated_at'] = datetime.now(UTC)
|
||||
|
||||
await db.execute(
|
||||
update(AdvertisingCampaign)
|
||||
.where(AdvertisingCampaign.id == campaign.id)
|
||||
.values(**update_data)
|
||||
)
|
||||
await db.execute(update(AdvertisingCampaign).where(AdvertisingCampaign.id == campaign.id).values(**update_data))
|
||||
await db.commit()
|
||||
await db.refresh(campaign)
|
||||
|
||||
logger.info("✏️ Обновлена рекламная кампания %s (%s)", campaign.name, update_data)
|
||||
logger.info('✏️ Обновлена рекламная кампания', campaign_name=campaign.name, update_data=update_data)
|
||||
return campaign
|
||||
|
||||
|
||||
async def delete_campaign(db: AsyncSession, campaign: AdvertisingCampaign) -> bool:
|
||||
await db.execute(
|
||||
delete(AdvertisingCampaign).where(AdvertisingCampaign.id == campaign.id)
|
||||
)
|
||||
await db.execute(delete(AdvertisingCampaign).where(AdvertisingCampaign.id == campaign.id))
|
||||
await db.commit()
|
||||
logger.info("🗑️ Удалена рекламная кампания %s", campaign.name)
|
||||
logger.info('🗑️ Удалена рекламная кампания', campaign_name=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))
|
||||
@@ -200,9 +193,9 @@ async def record_campaign_registration(
|
||||
user_id: int,
|
||||
bonus_type: str,
|
||||
balance_bonus_kopeks: int = 0,
|
||||
subscription_duration_days: Optional[int] = None,
|
||||
tariff_id: Optional[int] = None,
|
||||
tariff_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(
|
||||
@@ -229,14 +222,14 @@ async def record_campaign_registration(
|
||||
await db.commit()
|
||||
await db.refresh(registration)
|
||||
|
||||
logger.info("📈 Регистрируем пользователя %s в кампании %s", user_id, campaign_id)
|
||||
logger.info('📈 Регистрируем пользователя в кампании', user_id=user_id, campaign_id=campaign_id)
|
||||
return registration
|
||||
|
||||
|
||||
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
|
||||
)
|
||||
@@ -245,9 +238,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)
|
||||
)
|
||||
@@ -259,7 +250,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',
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -312,11 +303,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()
|
||||
@@ -339,8 +326,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)
|
||||
@@ -349,7 +336,7 @@ async def get_campaign_statistics(
|
||||
first_payment_time_by_user[user_id] = converted_at
|
||||
|
||||
for user_id, amount_kopeks, created_at in subscription_payments:
|
||||
amount_value = int(amount_kopeks or 0)
|
||||
amount_value = abs(int(amount_kopeks or 0))
|
||||
subscription_payments_total += amount_value
|
||||
paid_users_from_transactions.add(user_id)
|
||||
|
||||
@@ -358,13 +345,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
|
||||
@@ -376,75 +358,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)
|
||||
)
|
||||
|
||||
conversion_rate = 0.0
|
||||
if count:
|
||||
conversion_rate = round((paid_users_count / count) * 100, 1)
|
||||
|
||||
trial_conversion_rate = 0.0
|
||||
if trial_users_count:
|
||||
trial_conversion_rate = round((conversion_count / trial_users_count) * 100, 1)
|
||||
|
||||
avg_revenue_per_user = 0
|
||||
if count:
|
||||
avg_revenue_per_user = int(total_revenue / count)
|
||||
|
||||
deposits_result = await db.execute(
|
||||
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
|
||||
Transaction.user_id.in_(select(registrations_subquery.c.user_id)),
|
||||
Transaction.type == TransactionType.DEPOSIT.value,
|
||||
Transaction.is_completed.is_(True),
|
||||
)
|
||||
)
|
||||
total_revenue = deposits_result.scalar() or 0
|
||||
|
||||
trials_result = await db.execute(
|
||||
select(func.count(func.distinct(Subscription.user_id))).where(
|
||||
Subscription.user_id.in_(select(registrations_subquery.c.user_id)),
|
||||
Subscription.is_trial.is_(True),
|
||||
)
|
||||
)
|
||||
trial_users_count = trials_result.scalar() or 0
|
||||
|
||||
active_trials_result = await db.execute(
|
||||
select(func.count(func.distinct(Subscription.user_id))).where(
|
||||
Subscription.user_id.in_(select(registrations_subquery.c.user_id)),
|
||||
Subscription.is_trial.is_(True),
|
||||
Subscription.status == SubscriptionStatus.ACTIVE.value,
|
||||
)
|
||||
)
|
||||
active_trials_count = active_trials_result.scalar() or 0
|
||||
|
||||
conversions_result = await db.execute(
|
||||
select(func.count(func.distinct(SubscriptionConversion.user_id))).where(
|
||||
SubscriptionConversion.user_id.in_(select(registrations_subquery.c.user_id))
|
||||
)
|
||||
)
|
||||
conversion_count = conversions_result.scalar() or 0
|
||||
|
||||
paid_users_result = await db.execute(
|
||||
select(func.count(User.id)).where(
|
||||
User.id.in_(select(registrations_subquery.c.user_id)),
|
||||
User.has_had_paid_subscription.is_(True),
|
||||
)
|
||||
)
|
||||
paid_users_count = paid_users_result.scalar() or 0
|
||||
|
||||
avg_first_payment_result = await db.execute(
|
||||
select(func.coalesce(func.avg(SubscriptionConversion.first_payment_amount_kopeks), 0)).where(
|
||||
SubscriptionConversion.user_id.in_(select(registrations_subquery.c.user_id))
|
||||
)
|
||||
)
|
||||
avg_first_payment = int(avg_first_payment_result.scalar() or 0)
|
||||
avg_first_payment = int(sum(first_payment_amount_by_user.values()) / len(first_payment_amount_by_user))
|
||||
|
||||
conversion_rate = 0.0
|
||||
if count:
|
||||
@@ -459,50 +377,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,
|
||||
}
|
||||
|
||||
@@ -2,16 +2,17 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Optional
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select, update
|
||||
import structlog
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import CloudPaymentsPayment
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def create_cloudpayments_payment(
|
||||
@@ -20,11 +21,11 @@ async def create_cloudpayments_payment(
|
||||
user_id: int,
|
||||
invoice_id: str,
|
||||
amount_kopeks: int,
|
||||
description: Optional[str] = None,
|
||||
currency: str = "RUB",
|
||||
payment_url: Optional[str] = None,
|
||||
email: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
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:
|
||||
"""
|
||||
@@ -51,7 +52,7 @@ async def create_cloudpayments_payment(
|
||||
amount_kopeks=amount_kopeks,
|
||||
currency=currency,
|
||||
description=description,
|
||||
status="pending",
|
||||
status='pending',
|
||||
is_paid=False,
|
||||
payment_url=payment_url,
|
||||
email=email,
|
||||
@@ -64,10 +65,10 @@ async def create_cloudpayments_payment(
|
||||
await db.refresh(payment)
|
||||
|
||||
logger.debug(
|
||||
"Created CloudPayments payment: id=%s, invoice=%s, amount=%s",
|
||||
payment.id,
|
||||
invoice_id,
|
||||
amount_kopeks,
|
||||
'Created CloudPayments payment: id invoice amount',
|
||||
payment_id=payment.id,
|
||||
invoice_id=invoice_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
)
|
||||
|
||||
return payment
|
||||
@@ -76,36 +77,28 @@ async def create_cloudpayments_payment(
|
||||
async def get_cloudpayments_payment_by_invoice_id(
|
||||
db: AsyncSession,
|
||||
invoice_id: str,
|
||||
) -> Optional[CloudPaymentsPayment]:
|
||||
) -> CloudPaymentsPayment | None:
|
||||
"""Get CloudPayments payment by invoice ID."""
|
||||
result = await db.execute(
|
||||
select(CloudPaymentsPayment).where(
|
||||
CloudPaymentsPayment.invoice_id == 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,
|
||||
) -> Optional[CloudPaymentsPayment]:
|
||||
) -> CloudPaymentsPayment | None:
|
||||
"""Get CloudPayments payment by internal ID."""
|
||||
result = await db.execute(
|
||||
select(CloudPaymentsPayment).where(CloudPaymentsPayment.id == payment_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,
|
||||
) -> Optional[CloudPaymentsPayment]:
|
||||
) -> CloudPaymentsPayment | None:
|
||||
"""Get CloudPayments payment by CloudPayments transaction ID."""
|
||||
result = await db.execute(
|
||||
select(CloudPaymentsPayment).where(
|
||||
CloudPaymentsPayment.transaction_id_cp == transaction_id_cp
|
||||
)
|
||||
select(CloudPaymentsPayment).where(CloudPaymentsPayment.transaction_id_cp == transaction_id_cp)
|
||||
)
|
||||
return result.scalars().first()
|
||||
|
||||
@@ -114,7 +107,7 @@ async def update_cloudpayments_payment(
|
||||
db: AsyncSession,
|
||||
payment_id: int,
|
||||
**kwargs: Any,
|
||||
) -> Optional[CloudPaymentsPayment]:
|
||||
) -> CloudPaymentsPayment | None:
|
||||
"""
|
||||
Update CloudPayments payment record.
|
||||
|
||||
@@ -134,7 +127,7 @@ async def update_cloudpayments_payment(
|
||||
if hasattr(payment, key):
|
||||
setattr(payment, key, value)
|
||||
|
||||
payment.updated_at = datetime.utcnow()
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
await db.refresh(payment)
|
||||
|
||||
@@ -145,15 +138,15 @@ async def mark_cloudpayments_payment_as_paid(
|
||||
db: AsyncSession,
|
||||
payment_id: int,
|
||||
*,
|
||||
transaction_id_cp: Optional[int] = None,
|
||||
token: Optional[str] = None,
|
||||
card_first_six: Optional[str] = None,
|
||||
card_last_four: Optional[str] = None,
|
||||
card_type: Optional[str] = None,
|
||||
card_exp_date: Optional[str] = None,
|
||||
email: Optional[str] = None,
|
||||
callback_payload: Optional[Dict[str, Any]] = None,
|
||||
) -> Optional[CloudPaymentsPayment]:
|
||||
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.
|
||||
|
||||
@@ -176,9 +169,9 @@ async def mark_cloudpayments_payment_as_paid(
|
||||
if not payment:
|
||||
return None
|
||||
|
||||
payment.status = "completed"
|
||||
payment.status = 'completed'
|
||||
payment.is_paid = True
|
||||
payment.paid_at = datetime.utcnow()
|
||||
payment.paid_at = datetime.now(UTC)
|
||||
|
||||
if transaction_id_cp is not None:
|
||||
payment.transaction_id_cp = transaction_id_cp
|
||||
@@ -197,14 +190,12 @@ async def mark_cloudpayments_payment_as_paid(
|
||||
if callback_payload:
|
||||
payment.callback_payload = callback_payload
|
||||
|
||||
payment.updated_at = datetime.utcnow()
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
await db.refresh(payment)
|
||||
|
||||
logger.info(
|
||||
"Marked CloudPayments payment as paid: id=%s, invoice=%s",
|
||||
payment.id,
|
||||
payment.invoice_id,
|
||||
'Marked CloudPayments payment as paid: id invoice', payment_id=payment.id, invoice_id=payment.invoice_id
|
||||
)
|
||||
|
||||
return payment
|
||||
@@ -214,7 +205,7 @@ async def link_cloudpayments_payment_to_transaction(
|
||||
db: AsyncSession,
|
||||
payment_id: int,
|
||||
transaction_id: int,
|
||||
) -> Optional[CloudPaymentsPayment]:
|
||||
) -> CloudPaymentsPayment | None:
|
||||
"""Link CloudPayments payment to internal transaction."""
|
||||
payment = await get_cloudpayments_payment_by_id(db, payment_id)
|
||||
if not payment:
|
||||
|
||||
@@ -1,32 +1,29 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import List, Optional, Sequence, Tuple
|
||||
from collections.abc import Sequence
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import and_, delete, desc, func, select
|
||||
import structlog
|
||||
from sqlalchemy import and_, delete, desc, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import ContestTemplate, ContestRound, ContestAttempt, User
|
||||
from app.database.models import ContestAttempt, ContestRound, ContestTemplate, User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
# Templates
|
||||
async def get_template_by_id(db: AsyncSession, template_id: int) -> Optional[ContestTemplate]:
|
||||
result = await db.execute(
|
||||
select(ContestTemplate).where(ContestTemplate.id == template_id)
|
||||
)
|
||||
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) -> Optional[ContestTemplate]:
|
||||
result = await db.execute(
|
||||
select(ContestTemplate).where(ContestTemplate.slug == slug)
|
||||
)
|
||||
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]:
|
||||
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))
|
||||
@@ -39,16 +36,16 @@ async def upsert_template(
|
||||
*,
|
||||
slug: str,
|
||||
name: str,
|
||||
description: str = "",
|
||||
prize_type: str = "days",
|
||||
prize_value: str = "1",
|
||||
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: Optional[str] = None,
|
||||
schedule_times: str | None = None,
|
||||
cooldown_hours: int = 24,
|
||||
payload: Optional[dict] = None,
|
||||
is_enabled: Optional[bool] = None,
|
||||
payload: dict | None = None,
|
||||
is_enabled: bool | None = None,
|
||||
) -> ContestTemplate:
|
||||
template = await get_template_by_slug(db, slug)
|
||||
if not template:
|
||||
@@ -98,7 +95,7 @@ async def create_round(
|
||||
template_id=template.id,
|
||||
starts_at=starts_at,
|
||||
ends_at=ends_at,
|
||||
status="active",
|
||||
status='active',
|
||||
payload=payload,
|
||||
max_winners=template.max_winners,
|
||||
attempts_per_user=template.attempts_per_user,
|
||||
@@ -109,14 +106,14 @@ async def create_round(
|
||||
return round_obj
|
||||
|
||||
|
||||
async def get_active_rounds(db: AsyncSession) -> List[ContestRound]:
|
||||
now = datetime.utcnow()
|
||||
async def get_active_rounds(db: AsyncSession) -> list[ContestRound]:
|
||||
now = datetime.now(UTC)
|
||||
result = await db.execute(
|
||||
select(ContestRound)
|
||||
.options(selectinload(ContestRound.template))
|
||||
.where(
|
||||
and_(
|
||||
ContestRound.status == "active",
|
||||
ContestRound.status == 'active',
|
||||
ContestRound.starts_at <= now,
|
||||
ContestRound.ends_at >= now,
|
||||
)
|
||||
@@ -126,15 +123,15 @@ async def get_active_rounds(db: AsyncSession) -> List[ContestRound]:
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_active_round_by_template(db: AsyncSession, template_id: int) -> Optional[ContestRound]:
|
||||
now = datetime.utcnow()
|
||||
async def get_active_round_by_template(db: AsyncSession, template_id: int) -> ContestRound | None:
|
||||
now = datetime.now(UTC)
|
||||
result = await db.execute(
|
||||
select(ContestRound)
|
||||
.options(selectinload(ContestRound.template))
|
||||
.where(
|
||||
and_(
|
||||
ContestRound.template_id == template_id,
|
||||
ContestRound.status == "active",
|
||||
ContestRound.status == 'active',
|
||||
ContestRound.starts_at <= now,
|
||||
ContestRound.ends_at >= now,
|
||||
)
|
||||
@@ -145,7 +142,7 @@ async def get_active_round_by_template(db: AsyncSession, template_id: int) -> Op
|
||||
|
||||
|
||||
async def finish_round(db: AsyncSession, round_obj: ContestRound) -> ContestRound:
|
||||
round_obj.status = "finished"
|
||||
round_obj.status = 'finished'
|
||||
await db.commit()
|
||||
await db.refresh(round_obj)
|
||||
return round_obj
|
||||
@@ -159,7 +156,7 @@ async def increment_winner_count(db: AsyncSession, round_obj: ContestRound) -> C
|
||||
|
||||
|
||||
# Attempts
|
||||
async def get_attempt(db: AsyncSession, round_id: int, user_id: int) -> Optional[ContestAttempt]:
|
||||
async def get_attempt(db: AsyncSession, round_id: int, user_id: int) -> ContestAttempt | None:
|
||||
result = await db.execute(
|
||||
select(ContestAttempt).where(
|
||||
and_(
|
||||
@@ -176,7 +173,7 @@ async def create_attempt(
|
||||
*,
|
||||
round_id: int,
|
||||
user_id: int,
|
||||
answer: Optional[str],
|
||||
answer: str | None,
|
||||
is_winner: bool,
|
||||
) -> ContestAttempt:
|
||||
attempt = ContestAttempt(
|
||||
@@ -195,7 +192,7 @@ async def update_attempt(
|
||||
db: AsyncSession,
|
||||
attempt: ContestAttempt,
|
||||
*,
|
||||
answer: Optional[str] = None,
|
||||
answer: str | None = None,
|
||||
is_winner: bool = False,
|
||||
) -> ContestAttempt:
|
||||
"""Update existing attempt with answer and winner status."""
|
||||
@@ -214,7 +211,7 @@ async def clear_attempts(db: AsyncSession, round_id: int) -> int:
|
||||
return deleted_count
|
||||
|
||||
|
||||
async def list_winners(db: AsyncSession, round_id: int) -> Sequence[Tuple[User, ContestAttempt]]:
|
||||
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)
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
from sqlalchemy import select, and_
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import structlog
|
||||
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__)
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def create_cryptobot_payment(
|
||||
@@ -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,24 @@ 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(
|
||||
'Создан CryptoBot платеж: на для пользователя',
|
||||
invoice_id=invoice_id,
|
||||
amount=amount,
|
||||
asset=asset,
|
||||
user_id=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 +60,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:
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
|
||||
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('Обновлен статус CryptoBot платежа', invoice_id=invoice_id, status=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()
|
||||
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
|
||||
logger.info(f"Связан CryptoBot платеж {invoice_id} с транзакцией {transaction_id}")
|
||||
|
||||
logger.info('Связан CryptoBot платеж с транзакцией', invoice_id=invoice_id, transaction_id=transaction_id)
|
||||
return payment
|
||||
|
||||
|
||||
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 +119,13 @@ 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]:
|
||||
|
||||
from datetime import timedelta
|
||||
cutoff_time = datetime.utcnow() - timedelta(hours=older_than_hours)
|
||||
|
||||
async def get_pending_cryptobot_payments(db: AsyncSession, older_than_hours: int = 24) -> list[CryptoBotPayment]:
|
||||
cutoff_time = datetime.now(UTC) - 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()
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Optional
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
@@ -11,31 +10,32 @@ 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__)
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
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."""
|
||||
|
||||
expires_at = datetime.utcnow() + timedelta(hours=valid_hours)
|
||||
expires_at = datetime.now(UTC) + timedelta(hours=valid_hours)
|
||||
|
||||
result = await db.execute(
|
||||
select(DiscountOffer)
|
||||
.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,10 +113,10 @@ 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()
|
||||
now = datetime.now(UTC)
|
||||
stmt = (
|
||||
select(DiscountOffer)
|
||||
.options(
|
||||
@@ -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,9 +159,9 @@ async def mark_offer_claimed(
|
||||
db: AsyncSession,
|
||||
offer: DiscountOffer,
|
||||
*,
|
||||
details: Optional[dict] = None,
|
||||
details: dict | None = None,
|
||||
) -> DiscountOffer:
|
||||
offer.claimed_at = datetime.utcnow()
|
||||
offer.claimed_at = datetime.now(UTC)
|
||||
offer.is_active = False
|
||||
await db.commit()
|
||||
await db.refresh(offer)
|
||||
@@ -171,34 +171,29 @@ 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,
|
||||
details=details,
|
||||
)
|
||||
except Exception as exc: # pragma: no cover - defensive logging
|
||||
logger.warning(
|
||||
"Failed to record promo offer claim log for offer %s: %s",
|
||||
offer.id,
|
||||
exc,
|
||||
)
|
||||
logger.warning('Failed to record promo offer claim log for offer', offer_id=offer.id, exc=exc)
|
||||
try:
|
||||
await db.rollback()
|
||||
except Exception as rollback_error: # pragma: no cover - defensive logging
|
||||
logger.warning(
|
||||
"Failed to rollback session after promo offer claim log failure: %s",
|
||||
rollback_error,
|
||||
'Failed to rollback session after promo offer claim log failure', rollback_error=rollback_error
|
||||
)
|
||||
|
||||
return offer
|
||||
|
||||
|
||||
async def deactivate_expired_offers(db: AsyncSession) -> int:
|
||||
now = datetime.utcnow()
|
||||
now = datetime.now(UTC)
|
||||
result = await db.execute(
|
||||
select(DiscountOffer).where(
|
||||
DiscountOffer.is_active == True, # noqa: E712
|
||||
DiscountOffer.is_active == True,
|
||||
DiscountOffer.expires_at < now,
|
||||
)
|
||||
)
|
||||
@@ -213,42 +208,39 @@ 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"),
|
||||
exc,
|
||||
'Failed to record promo offer disable log for offer', payload=payload.get('offer_id'), exc=exc
|
||||
)
|
||||
try:
|
||||
await db.rollback()
|
||||
except Exception as rollback_error: # pragma: no cover - defensive logging
|
||||
logger.warning(
|
||||
"Failed to rollback session after promo offer disable log failure: %s",
|
||||
rollback_error,
|
||||
'Failed to rollback session after promo offer disable log failure', rollback_error=rollback_error
|
||||
)
|
||||
|
||||
return count
|
||||
@@ -257,8 +249,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(
|
||||
|
||||
+22
-28
@@ -1,19 +1,18 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Iterable, Optional
|
||||
from collections.abc import Iterable
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import delete, func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import FaqPage, FaqSetting
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def get_faq_setting(db: AsyncSession, language: str) -> 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()
|
||||
|
||||
|
||||
@@ -22,7 +21,7 @@ async def set_faq_enabled(db: AsyncSession, language: str, enabled: bool) -> Faq
|
||||
|
||||
if setting:
|
||||
setting.is_enabled = bool(enabled)
|
||||
setting.updated_at = datetime.utcnow()
|
||||
setting.updated_at = datetime.now(UTC)
|
||||
else:
|
||||
setting = FaqSetting(
|
||||
language=language,
|
||||
@@ -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 для языка', page_id=page.id, language=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
|
||||
@@ -120,12 +117,12 @@ async def update_faq_page(
|
||||
if is_active is not None:
|
||||
page.is_active = bool(is_active)
|
||||
|
||||
page.updated_at = datetime.utcnow()
|
||||
page.updated_at = datetime.now(UTC)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(page)
|
||||
|
||||
logger.info("✅ Страница FAQ %s обновлена", page.id)
|
||||
logger.info('✅ Страница FAQ обновлена', page_id=page.id)
|
||||
|
||||
return page
|
||||
|
||||
@@ -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 удалена', page_id=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.now(UTC))
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
"""CRUD операции для платежей Freekassa."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import FreekassaPayment
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def create_freekassa_payment(
|
||||
@@ -19,11 +19,11 @@ async def create_freekassa_payment(
|
||||
user_id: int,
|
||||
order_id: str,
|
||||
amount_kopeks: int,
|
||||
currency: str = "RUB",
|
||||
description: Optional[str] = None,
|
||||
payment_url: Optional[str] = None,
|
||||
expires_at: Optional[datetime] = None,
|
||||
metadata_json: Optional[str] = None,
|
||||
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(
|
||||
@@ -35,45 +35,31 @@ async def create_freekassa_payment(
|
||||
payment_url=payment_url,
|
||||
expires_at=expires_at,
|
||||
metadata_json=json.loads(metadata_json) if metadata_json else None,
|
||||
status="pending",
|
||||
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}")
|
||||
logger.info('Создан платеж Freekassa: order_id=, user_id', order_id=order_id, user_id=user_id)
|
||||
return payment
|
||||
|
||||
|
||||
async def get_freekassa_payment_by_order_id(
|
||||
db: AsyncSession, order_id: str
|
||||
) -> Optional[FreekassaPayment]:
|
||||
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)
|
||||
)
|
||||
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
|
||||
) -> Optional[FreekassaPayment]:
|
||||
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
|
||||
)
|
||||
)
|
||||
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
|
||||
) -> Optional[FreekassaPayment]:
|
||||
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)
|
||||
)
|
||||
result = await db.execute(select(FreekassaPayment).where(FreekassaPayment.id == payment_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
@@ -83,18 +69,18 @@ async def update_freekassa_payment_status(
|
||||
*,
|
||||
status: str,
|
||||
is_paid: bool = False,
|
||||
freekassa_order_id: Optional[str] = None,
|
||||
payment_system_id: Optional[int] = None,
|
||||
callback_payload: Optional[dict] = None,
|
||||
transaction_id: Optional[int] = None,
|
||||
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()
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
|
||||
if is_paid:
|
||||
payment.paid_at = datetime.utcnow()
|
||||
payment.paid_at = datetime.now(UTC)
|
||||
if freekassa_order_id:
|
||||
payment.freekassa_order_id = freekassa_order_id
|
||||
if payment_system_id is not None:
|
||||
@@ -107,20 +93,20 @@ async def update_freekassa_payment_status(
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
logger.info(
|
||||
f"Обновлен статус платежа Freekassa: order_id={payment.order_id}, "
|
||||
f"status={status}, is_paid={is_paid}"
|
||||
'Обновлен статус платежа Freekassa: order_id=, status=, is_paid',
|
||||
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]:
|
||||
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.status == 'pending',
|
||||
FreekassaPayment.is_paid == False,
|
||||
)
|
||||
)
|
||||
@@ -132,7 +118,7 @@ async def get_user_freekassa_payments(
|
||||
user_id: int,
|
||||
limit: int = 10,
|
||||
offset: int = 0,
|
||||
) -> List[FreekassaPayment]:
|
||||
) -> list[FreekassaPayment]:
|
||||
"""Получает платежи пользователя с пагинацией."""
|
||||
result = await db.execute(
|
||||
select(FreekassaPayment)
|
||||
@@ -146,12 +132,12 @@ async def get_user_freekassa_payments(
|
||||
|
||||
async def get_expired_pending_payments(
|
||||
db: AsyncSession,
|
||||
) -> List[FreekassaPayment]:
|
||||
) -> list[FreekassaPayment]:
|
||||
"""Получает просроченные платежи в статусе pending."""
|
||||
now = datetime.utcnow()
|
||||
now = datetime.now(UTC)
|
||||
result = await db.execute(
|
||||
select(FreekassaPayment).where(
|
||||
FreekassaPayment.status == "pending",
|
||||
FreekassaPayment.status == 'pending',
|
||||
FreekassaPayment.is_paid == False,
|
||||
FreekassaPayment.expires_at < now,
|
||||
)
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Optional
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import HeleketPayment
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def create_heleket_payment(
|
||||
@@ -20,13 +21,13 @@ async def create_heleket_payment(
|
||||
amount: str,
|
||||
currency: str,
|
||||
status: str,
|
||||
payer_amount: Optional[str] = None,
|
||||
payer_currency: Optional[str] = None,
|
||||
exchange_rate: Optional[float] = None,
|
||||
discount_percent: Optional[int] = None,
|
||||
payment_url: Optional[str] = None,
|
||||
expires_at: Optional[datetime] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
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,
|
||||
@@ -49,12 +50,12 @@ async def create_heleket_payment(
|
||||
await db.refresh(payment)
|
||||
|
||||
logger.info(
|
||||
"Создан Heleket платеж: uuid=%s order_id=%s amount=%s %s для пользователя %s",
|
||||
uuid,
|
||||
order_id,
|
||||
amount,
|
||||
currency,
|
||||
user_id,
|
||||
'Создан Heleket платеж: uuid= order_id= amount= для пользователя',
|
||||
uuid=uuid,
|
||||
order_id=order_id,
|
||||
amount=amount,
|
||||
currency=currency,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
return payment
|
||||
@@ -63,11 +64,9 @@ async def create_heleket_payment(
|
||||
async def get_heleket_payment_by_uuid(
|
||||
db: AsyncSession,
|
||||
uuid: str,
|
||||
) -> Optional[HeleketPayment]:
|
||||
) -> HeleketPayment | None:
|
||||
result = await db.execute(
|
||||
select(HeleketPayment)
|
||||
.options(selectinload(HeleketPayment.user))
|
||||
.where(HeleketPayment.uuid == uuid)
|
||||
select(HeleketPayment).options(selectinload(HeleketPayment.user)).where(HeleketPayment.uuid == uuid)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
@@ -75,11 +74,9 @@ async def get_heleket_payment_by_uuid(
|
||||
async def get_heleket_payment_by_order_id(
|
||||
db: AsyncSession,
|
||||
order_id: str,
|
||||
) -> Optional[HeleketPayment]:
|
||||
) -> HeleketPayment | None:
|
||||
result = await db.execute(
|
||||
select(HeleketPayment)
|
||||
.options(selectinload(HeleketPayment.user))
|
||||
.where(HeleketPayment.order_id == order_id)
|
||||
select(HeleketPayment).options(selectinload(HeleketPayment.user)).where(HeleketPayment.order_id == order_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
@@ -87,11 +84,9 @@ async def get_heleket_payment_by_order_id(
|
||||
async def get_heleket_payment_by_id(
|
||||
db: AsyncSession,
|
||||
payment_id: int,
|
||||
) -> Optional[HeleketPayment]:
|
||||
) -> HeleketPayment | None:
|
||||
result = await db.execute(
|
||||
select(HeleketPayment)
|
||||
.options(selectinload(HeleketPayment.user))
|
||||
.where(HeleketPayment.id == payment_id)
|
||||
select(HeleketPayment).options(selectinload(HeleketPayment.user)).where(HeleketPayment.id == payment_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
@@ -100,19 +95,19 @@ async def update_heleket_payment(
|
||||
db: AsyncSession,
|
||||
uuid: str,
|
||||
*,
|
||||
status: Optional[str] = None,
|
||||
payer_amount: Optional[str] = None,
|
||||
payer_currency: Optional[str] = None,
|
||||
exchange_rate: Optional[float] = None,
|
||||
discount_percent: Optional[int] = None,
|
||||
paid_at: Optional[datetime] = None,
|
||||
payment_url: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> Optional[HeleketPayment]:
|
||||
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)
|
||||
logger.error('Heleket платеж с uuid= не найден', uuid=uuid)
|
||||
return None
|
||||
|
||||
if status is not None:
|
||||
@@ -134,17 +129,17 @@ async def update_heleket_payment(
|
||||
if paid_at is not None:
|
||||
payment.paid_at = paid_at
|
||||
|
||||
payment.updated_at = datetime.utcnow()
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
|
||||
logger.info(
|
||||
"Обновлен Heleket платеж %s: статус=%s payer_amount=%s %s",
|
||||
uuid,
|
||||
payment.status,
|
||||
payment.payer_amount,
|
||||
payment.payer_currency,
|
||||
'Обновлен Heleket платеж : статус= payer_amount',
|
||||
uuid=uuid,
|
||||
payment_status=payment.status,
|
||||
payer_amount=payment.payer_amount,
|
||||
payer_currency=payment.payer_currency,
|
||||
)
|
||||
|
||||
return payment
|
||||
@@ -154,23 +149,19 @@ async def link_heleket_payment_to_transaction(
|
||||
db: AsyncSession,
|
||||
uuid: str,
|
||||
transaction_id: int,
|
||||
) -> Optional[HeleketPayment]:
|
||||
) -> HeleketPayment | None:
|
||||
payment = await get_heleket_payment_by_uuid(db, uuid)
|
||||
|
||||
if not payment:
|
||||
logger.error("Не найден Heleket платеж для связи с транзакцией: %s", uuid)
|
||||
logger.error('Не найден Heleket платеж для связи с транзакцией', uuid=uuid)
|
||||
return None
|
||||
|
||||
payment.transaction_id = transaction_id
|
||||
payment.updated_at = datetime.utcnow()
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
|
||||
logger.info(
|
||||
"Heleket платеж %s связан с транзакцией %s",
|
||||
uuid,
|
||||
transaction_id,
|
||||
)
|
||||
logger.info('Heleket платеж связан с транзакцией', uuid=uuid, transaction_id=transaction_id)
|
||||
|
||||
return payment
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
"""CRUD операции для платежей KassaAI."""
|
||||
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import KassaAiPayment
|
||||
|
||||
|
||||
logger = structlog.get_logger(__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('Создан платеж KassaAI: order_id=, user_id', 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.now(UTC)
|
||||
|
||||
if is_paid:
|
||||
payment.paid_at = datetime.now(UTC)
|
||||
if kassa_ai_order_id:
|
||||
payment.kassa_ai_order_id = kassa_ai_order_id
|
||||
if payment_system_id is not None:
|
||||
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(
|
||||
'Обновлен статус платежа KassaAI: order_id=, status=, is_paid',
|
||||
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.now(UTC)
|
||||
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,15 +1,14 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import select
|
||||
from datetime import datetime
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import MulenPayPayment
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def create_mulenpay_payment(
|
||||
@@ -19,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,
|
||||
@@ -42,43 +41,29 @@ async def create_mulenpay_payment(
|
||||
await db.refresh(payment)
|
||||
|
||||
logger.info(
|
||||
"Создан %s платеж #%s (uuid=%s) на сумму %s копеек для пользователя %s",
|
||||
settings.get_mulenpay_display_name(),
|
||||
payment.mulen_payment_id,
|
||||
uuid,
|
||||
amount_kopeks,
|
||||
user_id,
|
||||
'Создан платеж # (uuid=) на сумму копеек для пользователя',
|
||||
get_mulenpay_display_name=settings.get_mulenpay_display_name(),
|
||||
mulen_payment_id=payment.mulen_payment_id,
|
||||
uuid=uuid,
|
||||
amount_kopeks=amount_kopeks,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
return payment
|
||||
|
||||
|
||||
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()
|
||||
|
||||
|
||||
@@ -87,11 +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,
|
||||
metadata: Optional[dict] = 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:
|
||||
@@ -105,7 +90,7 @@ async def update_mulenpay_payment_status(
|
||||
if metadata is not None:
|
||||
payment.metadata_json = metadata
|
||||
|
||||
payment.updated_at = datetime.utcnow()
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
return payment
|
||||
@@ -118,7 +103,7 @@ async def update_mulenpay_payment_metadata(
|
||||
metadata: dict,
|
||||
) -> MulenPayPayment:
|
||||
payment.metadata_json = metadata
|
||||
payment.updated_at = datetime.utcnow()
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
return payment
|
||||
@@ -131,7 +116,7 @@ async def link_mulenpay_payment_to_transaction(
|
||||
transaction_id: int,
|
||||
) -> MulenPayPayment:
|
||||
payment.transaction_id = transaction_id
|
||||
payment.updated_at = datetime.utcnow()
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
return payment
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user