Compare commits
3642 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 813193102c | |||
| 42112d6c8a | |||
| dfe5e36fe0 | |||
| b5d4a54e97 | |||
| 1eaf7204ce | |||
| 8cc37dc439 | |||
| ccafb18122 | |||
| 654b132ae6 | |||
| 815fa0560c | |||
| c4d19810a4 | |||
| 9fbb32eb5b | |||
| 3ce3cde859 | |||
| 4e71dcd2b1 | |||
| acf21fa6d2 | |||
| f2b724a78d | |||
| 03473360c1 | |||
| 27dc68be41 | |||
| b15d78f8af | |||
| d232d14937 | |||
| 927d469db2 | |||
| e20e9e7cec | |||
| 0f097c3108 | |||
| eaa3c80d59 | |||
| 0c49eddfde | |||
| 33882f1e93 | |||
| f94666b68d | |||
| d32a49f801 | |||
| 9596f82ea6 | |||
| 0e3fdf7d61 | |||
| 54cfec6029 | |||
| 9e88eae416 | |||
| 8d32efac73 | |||
| 9f07b6007e | |||
| 2fb3b6c5dc | |||
| 0637576425 | |||
| ee7a445610 | |||
| ba4c7ee7ac | |||
| 4da169eb0f | |||
| 28937abab4 | |||
| b1dcf2779e | |||
| 3219062ce2 | |||
| 2ac72951f8 | |||
| cefb8cba78 | |||
| e869655028 | |||
| ae03ede209 | |||
| a72bb25ea2 | |||
| 0b22bc7056 | |||
| cde84ff20b | |||
| f76114d7dc | |||
| 9708a56fd7 | |||
| a360337660 | |||
| 9f78335651 | |||
| ef8142a99b | |||
| 06d51f9fcf | |||
| 4d94347179 | |||
| 2f6aa0b1e6 | |||
| 536a58d4d3 | |||
| 62f7c07059 | |||
| 0648edb3c4 | |||
| 9b6c235424 | |||
| 2fc35950cf | |||
| d2a5d74239 | |||
| 6b04c6748e | |||
| 10b44cbdb7 | |||
| dde04c716c | |||
| 819f19a7ea | |||
| e7201597ab | |||
| ea61aa7053 | |||
| 14546a8485 | |||
| f55455761f | |||
| b1a7767827 | |||
| 43c04b7e64 | |||
| add679cf6f | |||
| ee173190a0 | |||
| a6e21b47f6 | |||
| cd627e5840 | |||
| fc096e6867 | |||
| b591844c4e | |||
| 9557acb546 | |||
| 1fa6e75bd3 | |||
| 8965e43a55 | |||
| 8e7a6beece | |||
| e9f4fd8007 | |||
| a991c59d58 | |||
| dd0624adf8 | |||
| ab51cbe96a | |||
| 2f7a08deb0 | |||
| a0fa7f986b | |||
| b2d3eebe39 | |||
| a043fc0e46 | |||
| d6eec8787e | |||
| 95eee42047 | |||
| c673d6db4f | |||
| bb32a63d48 | |||
| af5acb272c | |||
| 5cd5ac8710 | |||
| 8bb58b44b3 | |||
| 511ecd506f | |||
| 62fc014bc3 | |||
| 961f564274 | |||
| 984870c78c | |||
| 6196c135d2 | |||
| bf1b8315a8 | |||
| b7eabcf7e7 | |||
| e83ebc4f02 | |||
| 1a4758f4cc | |||
| b80e8b3804 | |||
| e036173670 | |||
| bd728c6466 | |||
| e3aec310a3 | |||
| 1c59ce601e | |||
| 0563f7524f | |||
| cedbe07759 | |||
| 0746b2a5a7 | |||
| e8a72860dc | |||
| abbfe4a7d3 | |||
| 1ad5d9123b | |||
| 57031e4afe | |||
| b54d2434c0 | |||
| 7c2d7b6b4f | |||
| 9e731a5f18 | |||
| cd3832fef0 | |||
| cb6d51ed3d | |||
| 8b75768296 | |||
| 3f0a26b1c7 | |||
| de4f608d09 | |||
| 618327b9f1 | |||
| 54fdad8f0c | |||
| e9bbd68aa1 | |||
| 5df4837087 | |||
| 025e74c462 | |||
| 3208303c23 | |||
| 4cf0c5a971 | |||
| 7519352e79 | |||
| e7f94f9661 | |||
| cb68a612e9 | |||
| 44d4deb5a6 | |||
| 23c73d1f21 | |||
| 607beca837 | |||
| 31a5f8766f | |||
| cfdc829f48 | |||
| c44cefb983 | |||
| 79c2307c92 | |||
| 25f126a5cb | |||
| 45bd54e7b0 | |||
| 4ce43f80b8 | |||
| f7a4cedbbf | |||
| 6f83122e42 | |||
| dbcf3541a8 | |||
| b7adf3e971 | |||
| 037b1f876b | |||
| 4495658fad | |||
| 017b758e6c | |||
| edff6d5102 | |||
| 979fbe305a | |||
| d1edb8f892 | |||
| 0eeda6b9ac | |||
| 7c31c3616e | |||
| fba80b1a0d | |||
| 86dbb317f3 | |||
| 7432d43b55 | |||
| a61fb10784 | |||
| 8d83881cf0 | |||
| d624df5f65 | |||
| a96a7de784 | |||
| fa188e04c5 | |||
| e8c95ece8d | |||
| bc0f4adb7a | |||
| 3eb5a71e8d | |||
| 37c97de5d6 | |||
| 3624832ed8 | |||
| 9ee39d5f71 | |||
| 3a0e558b6f | |||
| ca083c216e | |||
| 8b9c1d1cc6 | |||
| 78830a7884 | |||
| 9fe7d0592f | |||
| 6a3e3c6fc4 | |||
| 54bfff0ed6 | |||
| 6ee305cbba | |||
| 340041ddae | |||
| 8a3628a6c5 | |||
| 8ac3350ac6 | |||
| a507ef9966 | |||
| 113405ed71 | |||
| 1bdf1a0f4c | |||
| ff7883e33c | |||
| 9bd909c1b6 | |||
| 408b4e8a4d | |||
| 0ee9708b4d | |||
| 44fadf6709 | |||
| 7384763466 | |||
| 9b6f3b10c6 | |||
| 0e4007bde6 | |||
| 9fcffc7df1 | |||
| 8e1a88c5cd | |||
| 7bb21c9f77 | |||
| 7333379fcf | |||
| fc040007b4 | |||
| b30a3c7961 | |||
| 62e18156f5 | |||
| 853666af6f | |||
| 3416f7b56c | |||
| d78d82c6ce | |||
| 47bd1be714 | |||
| 1eec919b88 | |||
| 777ad52bad | |||
| 5fcadb5544 | |||
| 0a8af20b58 | |||
| 95163a0e35 | |||
| ac58466c1a | |||
| 5ce14dcd9b | |||
| 21d700e16d | |||
| 5f2d4a5e04 | |||
| 7b90d73007 | |||
| 68c90aec44 | |||
| 9280c9a39a | |||
| 245e31e262 | |||
| c00d7ef4d3 | |||
| 2a652c562b | |||
| 1cc31729eb | |||
| 34e5c20c1a | |||
| c8545fe725 | |||
| a8339d376d | |||
| 47150ccbd0 | |||
| 0a0890d8bb | |||
| 6bcf24e3d8 | |||
| 898008a1bd | |||
| 09af2e1d41 | |||
| 045d44f451 | |||
| 04b2325cb9 | |||
| 504581b0f8 | |||
| 0572f414d4 | |||
| 50736dc005 | |||
| fe4bdb6a04 | |||
| 7d7c5f3972 | |||
| 131a2d18cc | |||
| 47296c5c08 | |||
| c15b7a63fb | |||
| e476afe208 | |||
| b59d422fb0 | |||
| 8b105c2396 | |||
| 9b6e0b26c3 | |||
| 4ca038e186 | |||
| 3e185e7ffe | |||
| 5e27dcdcfb | |||
| 8730a68b40 | |||
| d88f13784a | |||
| 6e471c9094 | |||
| 3201b60ff2 | |||
| d30d1e2a29 | |||
| bd73ad069f | |||
| 2186780c32 | |||
| fbb2e561c5 | |||
| c8aba42d3d | |||
| 05d495e874 | |||
| 427011fe41 | |||
| 2feacccb13 | |||
| dc196fb478 | |||
| 71c219e6ea | |||
| e250812463 | |||
| 477b0b26c4 | |||
| 06323f7168 | |||
| 6b471caa22 | |||
| 6fb28dd363 | |||
| 22dcff47d1 | |||
| 1d9b5ea3c6 | |||
| 08e0b3a657 | |||
| 10b80981b2 | |||
| 348f02f34c | |||
| 9e9e984f36 | |||
| d47ca550ae | |||
| 16f4285948 | |||
| 23cb6dd8d0 | |||
| dfbb6b0058 | |||
| 62f8fdd9bb | |||
| fc114d15a0 | |||
| a9fb8a069d | |||
| 9c6f738382 | |||
| 1a7b2eb908 | |||
| b108cc6b60 | |||
| 92cb1e3971 | |||
| 005fc95433 | |||
| f09771bc1c | |||
| d5b72198d7 | |||
| 991bf1161c | |||
| 1a955d920a | |||
| d575c914a6 | |||
| 90a054e942 | |||
| aac5fabbca | |||
| 3ce5d1650b | |||
| 24879b9cf2 | |||
| 37f27df062 | |||
| b78ed6797a | |||
| f73dcd6f66 | |||
| 0634d01336 | |||
| b43d6a83ee | |||
| 93144eea0e | |||
| 7d078a58e1 | |||
| 224c346bc0 | |||
| 51e937ae0c | |||
| 04073815ca | |||
| ff556ba347 | |||
| 2d751c238c | |||
| 5d27fa2963 | |||
| fe71935d58 | |||
| d0d1b7617a | |||
| 310990ae32 | |||
| b565c32e77 | |||
| f3edb4344b | |||
| ccdcff6a7b | |||
| 1d4090ea7a | |||
| 68c7d35165 | |||
| 221cc05af3 | |||
| 1524970401 | |||
| 32a03c5e4b | |||
| a03036c099 | |||
| 811e8a97b3 | |||
| c7625643c6 | |||
| 5ce87238c8 | |||
| 7d12a327ca | |||
| 8c93637d79 | |||
| 2d0ea02b4b | |||
| 98692ac3bd | |||
| 940b6b93b2 | |||
| 17768c303f | |||
| 441fcd2a36 | |||
| c2b6b4e370 | |||
| 2f8d27b1ec | |||
| dc32f53585 | |||
| 165691412b | |||
| 824d4b3a23 | |||
| 060f0ca199 | |||
| ac43f17a43 | |||
| 54fc1c029c | |||
| d259bcf169 | |||
| da46c18210 | |||
| ace7121a4e | |||
| 61a93f86b3 | |||
| 49bcd18a9a | |||
| 7df64d8edf | |||
| ca63b4d758 | |||
| 3cbb304e9b | |||
| ef5192234a | |||
| f7bb9ac4d9 | |||
| b8bb4f3c3f | |||
| e43ff27639 | |||
| e02123c1ed | |||
| f26df389b4 | |||
| 10d36a829d | |||
| 61e936dd49 | |||
| d0f9a04d5f | |||
| cf6bbffd9b | |||
| 5ad45addfa | |||
| 2af013b75b | |||
| 74bda5c986 | |||
| b290ccfff2 | |||
| 904d2883e8 | |||
| 1a54beed17 | |||
| 701a8d33af | |||
| aa03384012 | |||
| d346c63184 | |||
| b8005aaad7 | |||
| bfa9490425 | |||
| 1f4af21a07 | |||
| f952c0e38d | |||
| d7dab5d30f | |||
| 8407f7cdb1 | |||
| 6c72987201 | |||
| 81b6d3f18f | |||
| 95754ab224 | |||
| 949d964a7f | |||
| 8eab79434a | |||
| 8ab2351995 | |||
| e8802dde55 | |||
| acc5ace234 | |||
| 7b33721305 | |||
| e94f4598b5 | |||
| c66c8b7933 | |||
| 0b934efa09 | |||
| 79e644f6dd | |||
| 5c68a1aca7 | |||
| bde613c4bf | |||
| 184d8c3ba7 | |||
| d48221d57f | |||
| 657fd6d07d | |||
| 1211cd22ad | |||
| 19ed88009d | |||
| 0f30ee87ab | |||
| 4cc6778e45 | |||
| ab00a0d136 | |||
| 3d4f1af9a9 | |||
| 0302176fd2 | |||
| 8910780332 | |||
| 8bea64179a | |||
| 777f90eab1 | |||
| ad0b0f475a | |||
| 499292eab6 | |||
| 21b2f1bd2a | |||
| 3c025b536e | |||
| bded6fe59f | |||
| 344ad8f3c0 | |||
| b43dd3167f | |||
| 79ed9dbab2 | |||
| 38ff59f71f | |||
| 65c85e146d | |||
| 467955e7b3 | |||
| 1a4250a7ed | |||
| e181e4325b | |||
| 1a35237852 | |||
| 90baf3da52 | |||
| 348e4dd4dc | |||
| d2cd96a45c | |||
| 64238e3280 | |||
| fcfac2df1f | |||
| a0734a6eb4 | |||
| 0d7a235715 | |||
| 180788bc7b | |||
| 922681891a | |||
| 257c628cc8 | |||
| dcee842c92 | |||
| 5f02471fd5 | |||
| 82f0f7ce17 | |||
| b805b7eef9 | |||
| ad03fa070a | |||
| 9c354d971f | |||
| 5cc70c4bb8 | |||
| 9db806d387 | |||
| b7f7621d28 | |||
| 822ac2417d | |||
| b3c80a2a82 | |||
| 3e9d606647 | |||
| 0ab89f4956 | |||
| 0090f1c409 | |||
| 1cc37a57c1 | |||
| 7809efa556 | |||
| b4a69e3c85 | |||
| 67c975d9c2 | |||
| c0812a7ee9 | |||
| 8ed755a339 | |||
| 1aae8783ca | |||
| 5997a741fc | |||
| c5d522e6b8 | |||
| fa1d128d76 | |||
| f30516a6c2 | |||
| 1feb0538cf | |||
| 3a791d624f | |||
| fe0f1f7641 | |||
| 212783ae3d | |||
| 7d72fc9555 | |||
| fb4df5cee1 | |||
| 45cd847b44 | |||
| 98fd97dea2 | |||
| e334086ad1 | |||
| 2e792911f1 | |||
| 878b666a0b | |||
| 69c3239469 | |||
| b8d48745c5 | |||
| 6b64e73d6e | |||
| b433a96cd6 | |||
| 0e4c83299d | |||
| c4d1f3eccc | |||
| 22dcf425cb | |||
| edf4132c2d | |||
| a56e14c736 | |||
| 2e3c707745 | |||
| ebd8a10fd9 | |||
| a681a9ff1b | |||
| d11458c4a8 | |||
| 9d6e17b609 | |||
| cb27487021 | |||
| e1e6b40a19 | |||
| 2616292f97 | |||
| 2e1dfa6638 | |||
| 3a730492cb | |||
| c35da4b98d | |||
| 5e98c25020 | |||
| 11a4d5b436 | |||
| 93c733aac0 | |||
| 5b03e7ab8c | |||
| 33912331af | |||
| aacc07835e | |||
| 86df2a1408 | |||
| 018de17c5b | |||
| 66afa2b924 | |||
| 4ee32fe0de | |||
| 5b59ca3c4f | |||
| 0108b0fc22 | |||
| 2e575f82af | |||
| e8af09041d | |||
| 1cf041fb86 | |||
| 63074cb009 | |||
| 89b9a6e04f | |||
| 4efa344e43 | |||
| 2d3d1ba5d3 | |||
| 849d781e92 | |||
| 48a988aa43 | |||
| ec71f733ad | |||
| 051121a366 | |||
| e2c2b9127c | |||
| fdf7263bb0 | |||
| c8dcde0919 | |||
| 9db0cd6328 | |||
| 2375bf51b3 | |||
| 3631b49d6d | |||
| 3938b56802 | |||
| 3c3b793a9f | |||
| 8985a89a9f | |||
| 5750e6a4d2 | |||
| 8d7f73edec | |||
| 4853d5b19b | |||
| 901f5fd90d | |||
| 89e3865019 | |||
| 538b2b52d3 | |||
| f1c9df8471 | |||
| 9e09e97ed8 | |||
| e50355be23 | |||
| bd036fb4a9 | |||
| 0181f8e5fb | |||
| f5eea6798a | |||
| 7c8d0254b2 | |||
| 49c3c1c0c9 | |||
| 38522442d1 | |||
| 0dacd79598 | |||
| 2022654055 | |||
| f3a1de6335 | |||
| 0b5b89ea9a | |||
| 8e21c34c8c | |||
| 400f3b01d6 | |||
| 6a0b2aabaf | |||
| 13a1ddad11 | |||
| ed404a0e54 | |||
| 05acfc0733 | |||
| 969cd6ed90 | |||
| 939c9b9b09 | |||
| 051c0db106 | |||
| e5fbe005f8 | |||
| 63efb31785 | |||
| 32586e177b | |||
| 651dba2648 | |||
| 1bbcf2a95c | |||
| 82af176018 | |||
| c10a4e9f8a | |||
| 9727e07464 | |||
| b780fe2278 | |||
| 947d61f2a8 | |||
| 27725ec1a7 | |||
| 93a29463f4 | |||
| 84cfcf45fa | |||
| 6651e59f63 | |||
| ed46b7c919 | |||
| 88cd9fbc5b | |||
| 46fc5827ca | |||
| 65d386f3b2 | |||
| 9db640931e | |||
| 53e4dec12d | |||
| 174d9f9b84 | |||
| 30ec07f7fe | |||
| dbf1fd8085 | |||
| 32e1f231d3 | |||
| 96b788693d | |||
| a0e00c3735 | |||
| 51e68cf215 | |||
| fa3ed3af81 | |||
| 6e72cbb5b0 | |||
| 5f316f85b3 | |||
| 2abd1fd9df | |||
| fcb5fedf9f | |||
| d35c9ad87e | |||
| 0cc0368b0b | |||
| 00fb154cb9 | |||
| 4441cf79ff | |||
| 7428bd5cf7 | |||
| 76c524e980 | |||
| 5a9fa167c2 | |||
| c61e9c78ba | |||
| e5e3a9e4b5 | |||
| a1f47e13e3 | |||
| 7ff0ed1248 | |||
| 5eef24fb91 | |||
| 05ba26d3ac | |||
| ef68ce19ce | |||
| 2fa84f6dc9 | |||
| 32409d7471 | |||
| 270282a340 | |||
| 87eaa06652 | |||
| 845f03e873 | |||
| 97eabb0604 | |||
| f0d714d787 | |||
| 27a34d6e9f | |||
| a4e97e0317 | |||
| 97dcd0b7cc | |||
| 9b57904cf4 | |||
| 6ebdae0cae | |||
| b9d4a8f8e9 | |||
| 707c3c5daf | |||
| d075abfeea | |||
| d3399c569c | |||
| e36e3f7f8f | |||
| 583e01f5e6 | |||
| 784b5e0af6 | |||
| cc42d33fbe | |||
| 9c3d8fd701 | |||
| d281eeb69d | |||
| b510ffb7d8 | |||
| 6855b7765c | |||
| 9ed1129268 | |||
| 0c4ba3c0d0 | |||
| 70337ac310 | |||
| e981082713 | |||
| 104d98542e | |||
| fe63ef5fc5 | |||
| b1ff964dd6 | |||
| aba1d5dbe5 | |||
| 0af7e4ac68 | |||
| 7d9a572e6e | |||
| de6557d7e7 | |||
| a052c7bb80 | |||
| 8c47f3c49b | |||
| 323ad903e7 | |||
| 8105fe285d | |||
| 82a19025f1 | |||
| e592b3e5c4 | |||
| 2da12354a1 | |||
| fc65b62d65 | |||
| 28603a87e1 | |||
| 8956aafc9a | |||
| 2ee9b263b8 | |||
| 3a5d838be3 | |||
| bf25fcfda6 | |||
| ecdf2fcae4 | |||
| 6fec14de4b | |||
| 7b4cbb27b7 | |||
| d7d7795257 | |||
| 93554c7034 | |||
| fbe7febdde | |||
| f091b3ea5e | |||
| aae469853e | |||
| dac68ccd8c | |||
| fb140e82f6 | |||
| 8cac442de1 | |||
| 8d8a465c43 | |||
| 51f2c660e8 | |||
| 39ecca306d | |||
| 7896a24e13 | |||
| c21325f260 | |||
| 18df7395a2 | |||
| f139251333 | |||
| 369e384b7d | |||
| 008f62e02b | |||
| 6e8981a245 | |||
| bb17cf7361 | |||
| d13c6767e6 | |||
| bf2ee37f33 | |||
| b82929ac3d | |||
| 9b0ea9bf2a | |||
| 5bde7196dc | |||
| 71c3e7fe0d | |||
| 201543c263 | |||
| 5ce7417c80 | |||
| e98564b5ab | |||
| 37dd48cc20 | |||
| 9dff6e8045 | |||
| 572094050e | |||
| 2b41337bbf | |||
| ca94c402bd | |||
| 6fdc46c3c6 | |||
| 34fc568f97 | |||
| 38664ff853 | |||
| e44284d4ba | |||
| d54dc822cc | |||
| dbe9c2d5e9 | |||
| b036d090c6 | |||
| d44b303dd3 | |||
| e69bfdac20 | |||
| 53f6c4e71b | |||
| c67223a332 | |||
| 2c253382d5 | |||
| 02b65cb1c9 | |||
| 49ace78a68 | |||
| 0669086742 | |||
| 2f2d9bc1af | |||
| 9b2b11ee44 | |||
| d318738596 | |||
| 6ae41369e9 | |||
| ce9f2e0d41 | |||
| aa7bd42375 | |||
| a6b19e4b95 | |||
| 7a21518ce2 | |||
| ba92f7da6c | |||
| f62f37581f | |||
| 0d08c2c7f0 | |||
| 078e59f09c | |||
| ab708a78a8 | |||
| 630688ecf5 | |||
| 2b6901e87a | |||
| 0e43daf48b | |||
| e9badff1d8 | |||
| f588191931 | |||
| e17dfbff35 | |||
| b7e9770588 | |||
| 9371524257 | |||
| c1f8fe69cf | |||
| 87e3449444 | |||
| e8ef1bc255 | |||
| fe3a593da0 | |||
| bf96d85edb | |||
| 700d718da6 | |||
| 0c9ad3cec8 | |||
| 73d789d3f7 | |||
| 0a09ae4290 | |||
| 0758988b5f | |||
| 089f668e14 | |||
| 1e5bf143c4 | |||
| d1132c82f8 | |||
| 62545276e4 | |||
| 2878bc3c3d | |||
| eed0fe39e7 | |||
| 6bbee5586f | |||
| 7ac2354197 | |||
| 3463f08162 | |||
| 4b0aaa02c7 | |||
| a946f7b501 | |||
| 7f2e4615ee | |||
| 28ab7719f5 | |||
| 31b5101f64 | |||
| 62777f8995 | |||
| f9cff3e169 | |||
| 04c255d748 | |||
| aae771764e | |||
| d4e1544fc8 | |||
| 6db5cae013 | |||
| 61089a9f30 | |||
| 3ad1f0fc97 | |||
| 67ac3c933f | |||
| d6f4751274 | |||
| 6fdb02af10 | |||
| ce456c4f7a | |||
| 314b17ce79 | |||
| 598d9ad0a1 | |||
| 734711dab7 | |||
| c4c5d6a018 | |||
| 883cd5a952 | |||
| 8cf7445b99 | |||
| 4c11af4644 | |||
| 47036d5616 | |||
| 025330e052 | |||
| 8d18c78fb2 | |||
| d93a2a0682 | |||
| 0c93516056 | |||
| bf126bdadb | |||
| d421d43522 | |||
| 29cda4dcd7 | |||
| dde90f9ca4 | |||
| 8415c68063 | |||
| 9edde19b8a | |||
| 83a473606f | |||
| 849f06ad2b | |||
| e27e1299f7 | |||
| 03a45e39d4 | |||
| 4663464ea4 | |||
| 338b98bae3 | |||
| 7326d64fbe | |||
| bee94bcad0 | |||
| 414b796700 | |||
| f76abce939 | |||
| 99460665f1 | |||
| 730a6ae185 | |||
| ab8a5ca336 | |||
| d1f0cf5854 | |||
| 1f3c85e182 | |||
| bfcfe0de3d | |||
| f55f745080 | |||
| a26524e6ea | |||
| b5f57d4d0e | |||
| 5aa9c1316e | |||
| 524b4f5e64 | |||
| a2bb0b8341 | |||
| 9cabbf72d2 | |||
| 5f368f84ce | |||
| ed01d2bc6f | |||
| 7107070624 | |||
| c361f0c1d4 | |||
| ab95bc2192 | |||
| bf6a68ae27 | |||
| 1083651591 | |||
| b35fb824ec | |||
| ad839fb63d | |||
| 377a731ca2 | |||
| 3395021fcf | |||
| a4ce1a83be | |||
| 8b2f9e1803 | |||
| a40c07a6af | |||
| 2dbc23fa80 | |||
| 233401c057 | |||
| 66f17f90fd | |||
| 9748cc6b3e | |||
| bcab53cfe3 | |||
| 1af387de2b | |||
| 25c54c588b | |||
| 6ba7c6ed5e | |||
| 222c1a488f | |||
| c1a92adf41 | |||
| 972bb18344 | |||
| de11de4cd0 | |||
| f8c3daac23 | |||
| 91a3b14a1f | |||
| d310896168 | |||
| d4df56b173 | |||
| 3eab0b3848 | |||
| 609a557502 | |||
| 6a4ebad89f | |||
| e229b625f6 | |||
| 3ff5dec4d3 | |||
| 6dfd7db115 | |||
| 782dff4f82 | |||
| 1540d2665f | |||
| 567d0c1a8d | |||
| 6b031d4e9c | |||
| 72046d1ccf | |||
| d41af2cd85 | |||
| 174647c963 | |||
| e5e52b839c | |||
| ab19becdc8 | |||
| f5e9a6f2b9 | |||
| b492437b54 | |||
| 07546da0fd | |||
| fc4423d9de | |||
| c9a0f59e28 | |||
| 2d31bc6f23 | |||
| dac2e1c85f | |||
| f4d9b0d219 | |||
| 9b485bf822 | |||
| af3e8eab4e | |||
| 63d7500398 | |||
| 545f52f207 | |||
| 7fbb4cf2da | |||
| d7aab2c3da | |||
| f59e04b231 | |||
| a2ac04a57a | |||
| 4526df2342 | |||
| d0c65f201f | |||
| 4fd736ecbf | |||
| 351700f886 | |||
| 453d1dbf92 | |||
| 0f358a9691 | |||
| ab890ea958 | |||
| e0b0498f81 | |||
| af8dc119a5 | |||
| 7ad98ceebb | |||
| 004aad4f06 | |||
| ec5524d74f | |||
| c1211ee025 | |||
| bf6528128f | |||
| 34c22b8955 | |||
| d48488338e | |||
| a37fd89d8b | |||
| 4553b1e038 | |||
| 8c39f5aecf | |||
| ce5ceb2f89 | |||
| 8ca7750d25 | |||
| 1045cf42f9 | |||
| 427a8594d5 | |||
| e007e8142a | |||
| 55e47e8e00 | |||
| f84c54da03 | |||
| 9e5279454c | |||
| e935d13ccd | |||
| 2c17ce5c3a | |||
| 947ad48acd | |||
| aefae5818e | |||
| 47191342f6 | |||
| 0c841cbf86 | |||
| 8ef4c74b4a | |||
| feb5e92fc2 | |||
| 27338809c1 | |||
| 53231ca074 | |||
| 1c177660c0 | |||
| 041f1d09d7 | |||
| 8e13f644b0 | |||
| fc0b7591f9 | |||
| 308c645096 | |||
| fd41d22322 | |||
| 694d85ec10 | |||
| 790ffecade | |||
| 5c5cc42af7 | |||
| 0784f56f7c | |||
| 775da62e00 | |||
| 436efcdd3e | |||
| 086660ebef | |||
| c546d10d93 | |||
| 169bdae0d1 | |||
| 56e0ec4cd2 | |||
| f62e2794b2 | |||
| f19db8f184 | |||
| 41d4c9b507 | |||
| 73e4abb29f | |||
| f7cd8c9fc3 | |||
| 6497f52a11 | |||
| a4de6a8330 | |||
| d27e625ee6 | |||
| e7a34152fc | |||
| e332fc41fc | |||
| 6bf0ac7fbf | |||
| 0effaa5240 | |||
| 89b0544f86 | |||
| b1626d32cd | |||
| 8cdb4289c9 | |||
| d4e3adddfc | |||
| 1baeb50322 | |||
| 7ed635600e | |||
| cb81fae3d8 | |||
| 93cb99d50c | |||
| 5a4f59dfb2 | |||
| 59f39e18a0 | |||
| d3dd423042 | |||
| b90b270a9e | |||
| 7846b54a6f | |||
| 77083e8349 | |||
| f121a5733b | |||
| ad6bb95df7 | |||
| 58588e37d0 | |||
| 9717876fe7 | |||
| badf44a9e7 | |||
| a320de1cbb | |||
| aebfd8933a | |||
| 482fb26d48 | |||
| 8542473c6a | |||
| 66e56c68cb | |||
| 135f3ccb92 | |||
| d082e8a61c | |||
| 23ad622fcd | |||
| 9b639c3e1d | |||
| 75e9b8e27d | |||
| 893736b240 | |||
| f5403802cc | |||
| 1c2325ca70 | |||
| e032369110 | |||
| 9435a82add | |||
| 7478167065 | |||
| de5206ccd0 | |||
| 9d24522fab | |||
| 7be8757964 | |||
| dec3aefdec | |||
| 30645d633e | |||
| 2dcf3ef087 | |||
| 6c4aa8246c | |||
| b08be14c6a | |||
| 14d4166ac9 | |||
| c2e91265b8 | |||
| edd2809f73 | |||
| b657b9e95c | |||
| d112d769b7 | |||
| 28d26cee17 | |||
| e7115461db | |||
| c8205bd882 | |||
| 1314d1c5cb | |||
| 138a8920d9 | |||
| ac3890598f | |||
| 2784920fbb | |||
| 81e5731f34 | |||
| d637f461f9 | |||
| 7fbe5a2bd8 | |||
| 9819897dbb | |||
| d20f264b59 | |||
| 3d4e8b4d1b | |||
| 253df6a60a | |||
| fae50b2155 | |||
| 0df5fe9844 | |||
| 522146c054 | |||
| 9a3301bc38 | |||
| c169b8e69a | |||
| dbc9cbbb39 | |||
| ec768d68ef | |||
| 254255fe94 | |||
| c1aaa3acb0 | |||
| 4c2262e0b0 | |||
| 9c4445505b | |||
| b47944ef7b | |||
| 2b268a0e06 | |||
| 3ba9bddfc5 | |||
| e19f18931f | |||
| b8cbfc6065 | |||
| e7714dc33d | |||
| 847d04fa42 | |||
| 164b4f4e33 | |||
| e1b7af2990 | |||
| da74c7841d | |||
| 68d83be87a | |||
| 57f820dc35 | |||
| cd0aba5a6c | |||
| a43e68846a | |||
| adadeb4b2a | |||
| ad81cff59a | |||
| bf0c1ce711 | |||
| c71401cad4 | |||
| 81744d3de8 | |||
| f49dbeaf3a | |||
| 649bd32af3 | |||
| 55c37d8860 | |||
| c953cc150d | |||
| 59a78f0823 | |||
| 8ac3d5e826 | |||
| 744b7d532c | |||
| 72ec8d565c | |||
| 9350868830 | |||
| effbede310 | |||
| 4b3be5725c | |||
| 22ed539b3a | |||
| fa46a5b96c | |||
| 63402474d2 | |||
| 93db300dc7 | |||
| 76e29314b2 | |||
| 7e5c1d388e | |||
| 6b14ab70c6 | |||
| 5e3cff20b1 | |||
| 34c1c0b806 | |||
| e69dca013c | |||
| d201e07385 | |||
| 613e239691 | |||
| b72e00de9d | |||
| 0830461398 | |||
| 9abf0c1da9 | |||
| 74148d4e7d | |||
| 8901cad885 | |||
| a800bd4e3c | |||
| 5c3c438fb7 | |||
| 65df7ff58a | |||
| 46855439e2 | |||
| 7b3b4a8fb3 | |||
| 8a90893b40 | |||
| edf1d3dc29 | |||
| d5bf7f2749 | |||
| 5939d2d69a | |||
| 90a2fd9fdc | |||
| 841b183a59 | |||
| 9a73621ff4 | |||
| 71e8ceace9 | |||
| 4e8e11fc7b | |||
| b3a1a8ddb8 | |||
| 029d162e44 | |||
| 86ab3e544d | |||
| 587ca35049 | |||
| 02c68e5983 | |||
| 9ddc6b8f0c | |||
| eb69b166fe | |||
| 0ea6476834 | |||
| 6994735b0c | |||
| f504322afd | |||
| a60ff3bdb1 | |||
| 1eec3b71f6 | |||
| 31945067c9 | |||
| fe0da51909 | |||
| 9ddf7891aa | |||
| ce4eb3341d | |||
| f8e68de2bb | |||
| 6613de548c | |||
| 1d3f02d26a | |||
| 77d571980f | |||
| db79c538f0 | |||
| c066b9929d | |||
| ac39a22f61 | |||
| 814617bd64 | |||
| ba57848b60 | |||
| 269cbe6b2c | |||
| 07d3eff9a0 | |||
| 4e63a99197 | |||
| fe36737406 | |||
| 48917e47db | |||
| 6f348b00d3 | |||
| ea9da58e35 | |||
| 036e4d275d | |||
| cff14989ae | |||
| 10da004e5d | |||
| ed0f62542f | |||
| cc2b7c2036 | |||
| d3a87d62b4 | |||
| ef70f176dd | |||
| 7a111bae6c | |||
| 3132b32545 | |||
| 10da3595b9 | |||
| f0f3f3508f | |||
| 60b85911e1 | |||
| e78d75a440 | |||
| de57db14ea | |||
| b85d5083a9 | |||
| 862fcc53c4 | |||
| 42f1757508 | |||
| 50279979ce | |||
| ec00fe12a0 | |||
| 0806ed9f1e | |||
| 11b886d6ca | |||
| 291695395f | |||
| f787dfef04 | |||
| edc6b766cb | |||
| da2d2c826a | |||
| 0ad1f58fd4 | |||
| 9f398dbbe5 | |||
| 3a33928c9e | |||
| cf77b38fe7 | |||
| 3ab2dc0484 | |||
| 27b5f70712 | |||
| 05e47e3a1c | |||
| e85a6dd9e0 | |||
| 595c7ce5bb | |||
| 46e3433b55 | |||
| f7577c1acc | |||
| 2e57522a58 | |||
| a3ca5725a6 | |||
| 3a09240019 | |||
| b3ad09f0a1 | |||
| de515862f4 | |||
| c49e8d6c18 | |||
| decafb1d0e | |||
| 8f19a5e205 | |||
| fc75009e9b | |||
| c753f2f67e | |||
| 5a260c00d0 | |||
| 6f690a672e | |||
| 3b2c560947 | |||
| fb1641c5ba | |||
| 91615ec4b9 | |||
| fe98d4391f | |||
| 76f8ccdd85 | |||
| 45bc3bc15c | |||
| 0975fdea4d | |||
| 61bf48b7fa | |||
| 0ff4dd4321 | |||
| e0edc6bcdc | |||
| 14e58421e4 | |||
| b24ba2e75f | |||
| d9be872581 | |||
| 30ab648784 | |||
| b730de4175 | |||
| 6bfb484a9b | |||
| 0e6f6d0908 | |||
| 90a495e942 | |||
| 0182e190a8 | |||
| 30274dce20 | |||
| 0098cc724f | |||
| d9cf4008fe | |||
| ca83cf5349 | |||
| bf5e267822 | |||
| b51a0c04bc | |||
| 42e19de19c | |||
| 56c5e04c76 | |||
| 556f9dbd45 | |||
| 562fe9dccb | |||
| 960063fdca | |||
| 4a4e7406c3 | |||
| fca88c5e08 | |||
| 55dcb61798 | |||
| 3d9cc65336 | |||
| 61c69e6827 | |||
| 246c9d3935 | |||
| 7f6b0f4190 | |||
| 9e495e9f64 | |||
| 58bc8e97ee | |||
| f491a6bf00 | |||
| 8abcd0707d | |||
| b4ac9cd2bc | |||
| b18759b69f | |||
| d327ad9379 | |||
| cfe86bd8d3 | |||
| 5cb4479a70 | |||
| 64664fa30f | |||
| 15ed087142 | |||
| be6d358ed0 | |||
| dd77ecae9d | |||
| edfb325ccd | |||
| a4a6685dbb | |||
| ae9d32ceb4 | |||
| fa12448068 | |||
| a72b7ce3c8 | |||
| 1bf150f45a | |||
| e0633c409d | |||
| 2621b2f8be | |||
| ba1394c35f | |||
| 1072b64af8 | |||
| d3584d52e8 | |||
| f32b9242ba | |||
| 516a1f3e52 | |||
| 57906b1df9 | |||
| 83865dd0b9 | |||
| 69553d2551 | |||
| 02a8b932a3 | |||
| b27624021f | |||
| 4fde44eefd | |||
| 37f5977efd | |||
| a9cfbb4a4d | |||
| c7beec1df0 | |||
| 172c3426b9 | |||
| a616987eab | |||
| 0742d7cdc9 | |||
| f10a84399b | |||
| 6d2eb30704 | |||
| 618a5153f6 | |||
| 292378ebbc | |||
| 645cca088a | |||
| 4159689a4e | |||
| dd2ec37bec | |||
| 125131c8e6 | |||
| 3b895b73f9 | |||
| 39c915acc4 | |||
| 8f08489939 | |||
| baf69495d6 | |||
| 7e9c1bd784 | |||
| 509e7ffe14 | |||
| e4547b3ca0 | |||
| 5d638dbba9 | |||
| d0390bb432 | |||
| 66eaadd628 | |||
| 8a5b5599cc | |||
| a7935f3e88 | |||
| bbb89633c3 | |||
| 40a690b4bd | |||
| 4f06a183be | |||
| 6701a63a0a | |||
| 905e1f1861 | |||
| fe91fc1c29 | |||
| df8b85a179 | |||
| 8d08b23d0f | |||
| ce087beea6 | |||
| e63d2158c3 | |||
| eb64d8ed3a | |||
| fc4e17d995 | |||
| f22fd67b44 | |||
| e9ba19011a | |||
| 6569e45d9c | |||
| bc12bf47d6 | |||
| e731ccc055 | |||
| c648e78e34 | |||
| ef073bd307 | |||
| dde44a8afd | |||
| 0981aa6efa | |||
| 2b71e1701c | |||
| 2bd5c3e061 | |||
| 8f39c69f22 | |||
| b4301265ec | |||
| 7d793eff6a | |||
| 48e1485cac | |||
| f548f186de | |||
| 97ae7b6b1b | |||
| ac06b039a9 | |||
| 8514f8604a | |||
| a22b04a91e | |||
| 11f5289f2d | |||
| c72886d45a | |||
| fbfd59ca83 | |||
| 1737791659 | |||
| 3a8112b4ec | |||
| cfdb7b6bdd | |||
| 223a112d88 | |||
| b72ad3e090 | |||
| e81b555e43 | |||
| 8214775ded | |||
| 3bb5727089 | |||
| 39677eb82e | |||
| c56cdf2383 | |||
| a40c4dd7e9 | |||
| a09fd825bf | |||
| 03cf74656e | |||
| 98a301c1b5 | |||
| 1c865e3ae8 | |||
| 01388442b7 | |||
| 251a940be2 | |||
| fc725ba9e1 | |||
| 9f7e73ae2e | |||
| d576801676 | |||
| 8b82d56635 | |||
| b165fafc4d | |||
| bb9a0b305c | |||
| 99b219a307 | |||
| ad2a9b9d14 | |||
| e03bf2a393 | |||
| b6ead34e6c | |||
| cb180d9c0e | |||
| 791e26f56a | |||
| b0403dab83 | |||
| e6c3d45991 | |||
| d43597ab2a | |||
| b2bf5024a0 | |||
| 001f35ed89 | |||
| 9def85e8e2 | |||
| ed92e0cd8e | |||
| 336cfe440a | |||
| 3c312e089b | |||
| d54e42612e | |||
| 233520a656 | |||
| aeff150feb | |||
| f0d67e2428 | |||
| 72df1d91d5 | |||
| 26811cdcff | |||
| bc26d8352b | |||
| 4fe2e029bc | |||
| f54bf16ef4 | |||
| 7dfa5d5a6d | |||
| ac1f6120ab | |||
| 26c9896b51 | |||
| 3b7323c6f4 | |||
| 956936185a | |||
| 435c2d98f1 | |||
| 7f24bd7c8c | |||
| b15781ad3b | |||
| c598d66ecc | |||
| ffc969daaa | |||
| 116201cca3 | |||
| a7592836da | |||
| 734c3679ce | |||
| d350b627fc | |||
| 4fe483da56 | |||
| 8b4e1c830b | |||
| ebfb653d8b | |||
| 8b4f1672dd | |||
| e48a3f178e | |||
| a72ed1c2a1 | |||
| 15db2d975f | |||
| 99ff5f8a9d | |||
| 742093e68c | |||
| a72f6caf80 | |||
| 605d33053d | |||
| 7a2e83f3d1 | |||
| e7fc1b8402 | |||
| 5fcdcc5878 | |||
| 4212fc0be3 | |||
| df4ce40739 | |||
| b761630505 | |||
| d1c3358dbf | |||
| 1d1fc7dbfb | |||
| 25aaa703a6 | |||
| 46f3ef23ed | |||
| 80517f0c53 | |||
| 66d1f96e32 | |||
| a93c5808c0 | |||
| f80818c18a | |||
| 6199716c09 | |||
| ab131ed399 | |||
| 43203bfeb3 | |||
| 4873a26880 | |||
| 974b049ee0 | |||
| 3656ec5df5 | |||
| ef4d343e16 | |||
| 71171b24b3 | |||
| 5ce9fa9fac | |||
| 85a702b00a | |||
| aab8727de7 | |||
| 813bb45cf9 | |||
| 3d731e6b06 | |||
| 8e6174ea13 | |||
| 7a5a1fa8b4 | |||
| 1a4c8eff19 | |||
| b345a4ce25 | |||
| d8c37349ca | |||
| 4df3b2ea8f | |||
| 8bc683e03a | |||
| c176a9abf2 | |||
| 72553e5f8b | |||
| de09965876 | |||
| 7eb14ad036 | |||
| 0e0c917fdd | |||
| 7eb58d1add | |||
| 405cba645a | |||
| 359b7c807c | |||
| dbed5d9e5d | |||
| f999a31664 | |||
| 96c795c789 | |||
| 874c5d8017 | |||
| 2c76751c63 | |||
| 483b99e557 | |||
| b653c45320 | |||
| 3f0f9c3f4b | |||
| d899dc7d86 | |||
| 21567088a1 | |||
| 5425210cb0 | |||
| 0484d69538 | |||
| ac5c764daa | |||
| 15df6b802c | |||
| a1e1267239 | |||
| 5fcaa73d20 | |||
| c57d273c7e | |||
| 51a1db0680 | |||
| a3d46cd293 | |||
| 18ec1c1f5f | |||
| a9d4bea9de | |||
| 10b05f95ab | |||
| b5be8e5237 | |||
| 4117dcb97f | |||
| 0cb422d33e | |||
| da1ed55fd7 | |||
| bb17f1c456 | |||
| b2f88e75d5 | |||
| 767e7b653e | |||
| 2a7969716a | |||
| 3e10bceb43 | |||
| 6f293615bb | |||
| 69ce111281 | |||
| 85c8ad018a | |||
| 385ebd1d39 | |||
| 4227a8613e | |||
| 2dc8947b00 | |||
| 851f4dc58c | |||
| 366fc28ec5 | |||
| 277d690686 | |||
| 077584fb57 | |||
| 1bd05de773 | |||
| 1ae84a73dc | |||
| f5dad62ab0 | |||
| 2d90b9783a | |||
| dd174bf6a5 | |||
| fcd20b3aaa | |||
| d97a71d96a | |||
| e7b631a991 | |||
| 948e36478b | |||
| ec52741cf8 | |||
| 08c3a54e6d | |||
| 75af19ab83 | |||
| 13c89c1d6f | |||
| c091cb95c8 | |||
| 65aa0a7c08 | |||
| 080c019684 | |||
| 0e1962c291 | |||
| e0aea226a4 | |||
| 687ea7d1d6 | |||
| b7146268c2 | |||
| 0952059c60 | |||
| bbf5575b49 | |||
| 6b8e913f91 | |||
| 51e5f1c715 | |||
| 910f48bff0 | |||
| 62325d7a21 | |||
| dc07de9957 | |||
| d65524d349 | |||
| fd989d0186 | |||
| 390874565e | |||
| ae64dc48d2 | |||
| 8a68a0d4b1 | |||
| 82cdd4442e | |||
| 112ce528d9 | |||
| 950f84d012 | |||
| 025a57a325 | |||
| 9d374f3ba1 | |||
| d4aec3b8aa | |||
| ffe8f70946 | |||
| febf9ec669 | |||
| 6bff990d59 | |||
| d50afd94eb | |||
| d43bab9c88 | |||
| b6b63cd82d | |||
| d7534e098c | |||
| 1af55ec86e | |||
| 3b72707fd9 | |||
| 55ee06402e | |||
| bd19b570e5 | |||
| d0735aad31 | |||
| 73594b9088 | |||
| 6075ed5410 | |||
| 07ff14c6b1 | |||
| 36ac94468a | |||
| 3c3bd139e3 | |||
| 82b2127e55 | |||
| 5f2bb415de | |||
| c2a6dfb3b4 | |||
| 04bcc53012 | |||
| d9330bfb7a | |||
| 938f96bef8 | |||
| 4ea0868f6a | |||
| d032b9fcb1 | |||
| a0efbb105e | |||
| 6cfd239217 | |||
| 8378a2c555 | |||
| fd8ced4f1a | |||
| 261b5b8d14 | |||
| 0fb684544f | |||
| 22f9c9a48d | |||
| b6c3acfa13 | |||
| 8b94ac7e1d | |||
| a676e2818a | |||
| b5c2ad4c07 | |||
| d3100f6996 | |||
| 3a13c5225b | |||
| eab3626848 | |||
| 7a34a9d370 | |||
| 7045412fe6 | |||
| 7027fa91c3 | |||
| 8e9f7f91ef | |||
| e6923a0d0a | |||
| 3250976709 | |||
| cb5b731e92 | |||
| 34a7c0dfc6 | |||
| 009b6bf71d | |||
| d01da00809 | |||
| a76ca27f22 | |||
| 1a73b13d50 | |||
| 073af762c5 | |||
| b5c8b4218c | |||
| 00605a37ab | |||
| 0f61df80e2 | |||
| d1777b48ea | |||
| fae4036670 | |||
| 57a32ea52e | |||
| 5412ad4202 | |||
| ab9bc8b9e1 | |||
| 2c4090b63e | |||
| 61287d5dbc | |||
| 0cc37c1a9e | |||
| c877b327a5 | |||
| a27c9a7463 | |||
| 16c99bed06 | |||
| 045bc32798 | |||
| f1dbd7c784 | |||
| cb9c673af1 | |||
| 53ed64afa3 | |||
| 23d313e12d | |||
| 02313fc924 | |||
| 860937591d | |||
| 9388f1c81a | |||
| d7c4f439b9 | |||
| 4273d2354c | |||
| ee1c215278 | |||
| da2260f55c | |||
| bdeff1dbad | |||
| bd421f23da | |||
| c63b439722 | |||
| b501e937de | |||
| afa65e0fbd | |||
| 16945492e8 | |||
| 3c879914ca | |||
| d4f8d9b94d | |||
| f44e6aca6f | |||
| af273c5762 | |||
| c24ba07751 | |||
| 1fb1dadd9d | |||
| 83536d7f3d | |||
| c2be32783f | |||
| 6654608aba | |||
| 6f29265913 | |||
| 95b42d6e9b | |||
| e76975388e | |||
| 43ebb11793 | |||
| e6d373d9bd | |||
| bea83a3635 | |||
| ba73236ca1 | |||
| 4120d516b4 | |||
| 09a46fccad | |||
| 7b27f39f74 | |||
| e4b1754e8b | |||
| 3114bf59de | |||
| 30b87e13c6 | |||
| 45d2ca6e25 | |||
| 42e3fae980 | |||
| 07c4ab264c | |||
| bdaa3b5875 | |||
| 42696d0564 | |||
| 3c0c5a3494 | |||
| 743c3dabee | |||
| 3b8708f8e6 | |||
| 977042e66c | |||
| a91f42e9f0 | |||
| c4d5388081 | |||
| f73a0fcfe8 | |||
| 7ff6e42f5b | |||
| cdd1e3f107 | |||
| 6c6ade4f7f | |||
| 30ad692bc2 | |||
| 24b3ea4881 | |||
| 560689d9ee | |||
| aa763403c6 | |||
| 96c0c3562d | |||
| e48eaf584c | |||
| 790cd6bab3 | |||
| 55ae2bd91c | |||
| d431931501 | |||
| 425d15c850 | |||
| 58affda938 | |||
| c4eb2a60de | |||
| 46dca8c095 | |||
| b752278398 | |||
| 6a659a2131 | |||
| 019397b0ea | |||
| 947a400909 | |||
| 7f52beb2a2 | |||
| 283d42af86 | |||
| d36d1e3041 | |||
| a83bc25bd0 | |||
| 3555ada3b5 | |||
| 1cb93d9688 | |||
| 6f9fc5e071 | |||
| 96d466328c | |||
| 846e7844db | |||
| 4e0b0837d9 | |||
| 8ba5cda095 | |||
| 7ac33bce28 | |||
| d8a0c166ac | |||
| c1858fe925 | |||
| dae8e4c34c | |||
| 3d7c219fd1 | |||
| 03781463e4 | |||
| dcd9074136 | |||
| 7fbd26c8c3 | |||
| 9677a55375 | |||
| eb51cda617 | |||
| 09bdde6f91 | |||
| 784b2ed996 | |||
| 9bee4a2365 | |||
| f8946b99f3 | |||
| a17cbf4448 | |||
| b0422e0c0f | |||
| 9d0bf8b3cf | |||
| 8c5e8ca019 | |||
| 0327adb8da | |||
| 5769fa63a5 | |||
| 1d09006b7c | |||
| e194ab9729 | |||
| 45a6173916 | |||
| b21baba006 | |||
| 45c1b4695c | |||
| 9b2b28423d | |||
| 9d00c2ddc6 | |||
| dc66676663 | |||
| 1d600e2c63 | |||
| 5ce177a7b5 | |||
| d181a333d7 | |||
| 3694cb4ed7 | |||
| 051048e0ae | |||
| 61ee6e42cf | |||
| 5bd618a799 | |||
| 7794a4cc7b | |||
| 6d01d32dc2 | |||
| be71def203 | |||
| 7a2de410bd | |||
| b019e8b32d | |||
| cb4f773eb1 | |||
| 29fe9f4943 | |||
| 63edfa852d | |||
| 66fabb6836 | |||
| f29ea49185 | |||
| 7620a3df92 | |||
| 577bcfdc88 | |||
| 33ec09edd0 | |||
| 7b3c463fa2 | |||
| 0e541af5e1 | |||
| e39822e732 | |||
| c7973e7b58 | |||
| 4941052d57 | |||
| 0022abc6e0 | |||
| acef77b0e8 | |||
| 4ddd580347 | |||
| 42cc4db077 | |||
| 8e3f976441 | |||
| 190d9ae13e | |||
| d6deec1a29 | |||
| e8dcbd09ff | |||
| 382f1eb6f7 | |||
| 2853410ebc | |||
| 2bde6a83ed | |||
| 83b1fa0f0f | |||
| 3a2a05d307 | |||
| 2460766e30 | |||
| 3eb01eaabc | |||
| 35bca4166d | |||
| f2db938fdc | |||
| ff0c68afc6 | |||
| 67babf7566 | |||
| f71788d2d4 | |||
| 5eebdcc85c | |||
| 4b89de9634 | |||
| b81d5b199b | |||
| a21559cd0d | |||
| 6cd3f87318 | |||
| f9e416341f | |||
| 8a2149d2f8 | |||
| e0f6e3b1ef | |||
| 8948f4d302 | |||
| d13ff6830b | |||
| b8aee33e88 | |||
| 17639f3a67 | |||
| cb77b5a21c | |||
| 012ba2049e | |||
| 6acfcc9966 | |||
| f6839c4184 | |||
| 1fbdcb7391 | |||
| 50a65d06d4 | |||
| 20855e5bfc | |||
| 2e4317e457 | |||
| 89dd41ef43 | |||
| 7674b0325b | |||
| 8a5141b58c | |||
| a819be0b5a | |||
| 7de4730822 | |||
| b4815b291e | |||
| 68da3d7d30 | |||
| ca13385dee | |||
| 9216128eb6 | |||
| 3d1fe09540 | |||
| 07b8e04afe | |||
| 01039a969a | |||
| a020db5bc6 | |||
| a91baf2acd | |||
| 1caa2b2ba2 | |||
| a7e9f732a2 | |||
| c8794722a2 | |||
| bbdb3e454e | |||
| fa693494d0 | |||
| 02c38a25ea | |||
| d5d60a8c7a | |||
| 864aaae017 | |||
| 413173270d | |||
| b8eb6606f1 | |||
| 83226c8f3d | |||
| 8a96cddb4f | |||
| e32f3191e4 | |||
| acb5c3c1d7 | |||
| cf13b49b3c | |||
| 2049765969 | |||
| 6b2af1f4a3 | |||
| 00d080cbfb | |||
| d0ce5438a8 | |||
| 05914c4e14 | |||
| a040d03df0 | |||
| a29e0d5fb4 | |||
| ea7697fd66 | |||
| ab86296256 | |||
| 9bb384788f | |||
| 6fc4eb033b | |||
| 8a8abf5b96 | |||
| 01e68bec68 | |||
| 7b6488d166 | |||
| 6c4e4b366a | |||
| 1defa938ba | |||
| efaac8ff76 | |||
| 9246312c68 | |||
| d4bbea616f | |||
| 1be8e1a60f | |||
| 0f1147a226 | |||
| dc7d0fa8d6 | |||
| 1fc4686490 | |||
| 8378020a3e | |||
| bce31ea9a4 | |||
| 5b4337a436 | |||
| ac0953a221 | |||
| bfdb17d653 | |||
| 3159e80897 | |||
| 35d3fb1916 | |||
| 4be51bbe51 | |||
| 2416bd6ad5 | |||
| b3697b1871 | |||
| 483fab8427 | |||
| 95ae04d553 | |||
| a6d8e77f9e | |||
| ae4814d1cb | |||
| fef4f81744 | |||
| 65057348e9 | |||
| 4d32e509e0 | |||
| 8e336e1f7b | |||
| ee7078705b | |||
| 48fe845ff4 | |||
| 99c882110a | |||
| a4f1323a81 | |||
| aad1bb8805 | |||
| d2eb1d35db | |||
| d6fa2a8f3c | |||
| 32b074efd4 | |||
| 787d801722 | |||
| 91c3f2766a | |||
| 911a20a4e3 | |||
| 8e04a0e2b5 | |||
| 6225463af1 | |||
| d4badacd4a | |||
| 63bf04bb70 | |||
| 2529c9bca2 | |||
| bc09d2f610 | |||
| d530d7a296 | |||
| bea0977a86 | |||
| 634b2782d8 | |||
| 0e28cfa326 | |||
| 3d2ca8a02d | |||
| e0e673e7bb | |||
| 0d4cea1e22 | |||
| b643f45c01 | |||
| b5b30a07b8 | |||
| 09ea9b0193 | |||
| cf76ac7893 | |||
| 83b370669e | |||
| 6735cf17cf | |||
| 422686dd2c | |||
| 147dc0343c | |||
| cdff9c4666 | |||
| 25db434e47 | |||
| 5a8aeadcd9 | |||
| bdceb9c3cf | |||
| f43e0daa69 | |||
| c24c668299 | |||
| 41df40eed3 | |||
| b1975cc3ff | |||
| 369e2c3c36 | |||
| e2ec4b05db | |||
| 7493b13348 | |||
| 0a65aa16cd | |||
| 64a4ece0fe | |||
| 71664cca3e | |||
| b21177f685 | |||
| cde6aa4c26 | |||
| 5264fc0d90 | |||
| 7aa2004f27 | |||
| 40a34d1e55 | |||
| 4204b459e2 | |||
| cbe80e9447 | |||
| 0cfd8d3c7b | |||
| ddfa61c56f | |||
| 7e340bc13a | |||
| f7dd3be27c | |||
| 3ff06ed9d1 | |||
| 08d6a26b23 | |||
| 37e472d78a | |||
| 8cc04e01e9 | |||
| c199d04b91 | |||
| 102d5d0a9c | |||
| 8e7b44811e | |||
| ab1be9943b | |||
| 61b702272b | |||
| 84bce0043d | |||
| f54528f2d5 | |||
| b66911f4f7 | |||
| 352c14331d | |||
| 7ea91f344c | |||
| d2d5b6c823 | |||
| 4405c2287a | |||
| 288a72f739 | |||
| 60391a8b89 | |||
| f2d4da9514 | |||
| 648ad24e6d | |||
| 5d28cd9930 | |||
| 43974d1685 | |||
| 39ccc7fb4a | |||
| d391a46b0e | |||
| 59dd638dd7 | |||
| 3062061270 | |||
| 5b0a64964e | |||
| 70e3517d3a | |||
| 08b64abb03 | |||
| 84a3179974 | |||
| fa56ad8791 | |||
| f584cb78f1 | |||
| ff3ac2b9f3 | |||
| deabcfba28 | |||
| 85775604a8 | |||
| bf9b99818d | |||
| 93a8c182bc | |||
| 7febaf9db2 | |||
| 21583279c6 | |||
| df11e09d3a | |||
| 0b2ebbcdd8 | |||
| 28be0954f9 | |||
| 44164b2948 | |||
| d9d4f171f6 | |||
| 4c73d1d0b6 | |||
| 0bf71971f9 | |||
| ff19fb8950 | |||
| 68a37fcf72 | |||
| 2caf16ccc6 | |||
| b38ee36698 | |||
| 3ee9c3de7b | |||
| 0fce289926 | |||
| b1e7f83d11 | |||
| ff28f82240 | |||
| d5082be405 | |||
| 3e830a32ea | |||
| c8a99ceed7 | |||
| 602ddd4013 | |||
| 33443955c7 | |||
| 8f8b6ac1fe | |||
| 8bb0795b1e | |||
| 0055560d3e | |||
| dcc209deea | |||
| a2cb7527f8 | |||
| 523c6ab69c | |||
| 91220b0b5f | |||
| f87dac8efa | |||
| 44b75be97a | |||
| 500ed4cdf9 | |||
| 498bd77076 | |||
| 29afb6e67f | |||
| 01d10b58dd | |||
| cf7aed9abf | |||
| 1b57531b28 | |||
| 375c096e82 | |||
| 89b0daae1d | |||
| 754c21a32f | |||
| 4928cdde43 | |||
| 057e5f6ab0 | |||
| 6d66d6f627 | |||
| be28e9bec4 | |||
| b923c4bcaa | |||
| 3a749c1864 | |||
| d16d5b7918 | |||
| 8484515722 | |||
| b9efff6e97 | |||
| 061557c438 | |||
| df09ccfbd3 | |||
| 16c7e140d0 | |||
| 967924a1ff | |||
| e028a25df2 | |||
| d3fcd11201 | |||
| 97b9056b0b | |||
| 00ba52672b | |||
| efe16b4269 | |||
| 5e94ad2189 | |||
| 53bb582c81 | |||
| 4c20ec19ab | |||
| a7042ea012 | |||
| f700285415 | |||
| ed1fc22f6b | |||
| c3c0c764fd | |||
| 746e810c58 | |||
| 2df5d21a9e | |||
| d664b99e1c | |||
| 74fe3b09a2 | |||
| efea0e8414 | |||
| 1dc4d0e04d | |||
| 84f06a96b3 | |||
| 4a7cc3306a | |||
| 5d576bc727 | |||
| 19a7a8e6b1 | |||
| 064f64afd0 | |||
| bfa5e5aab9 | |||
| f59bda57cf | |||
| 6887c265ad | |||
| 7db31ff5b5 | |||
| 6bee1bb85d | |||
| c604c5f6ff | |||
| ed5e6aa550 | |||
| 6b78d0f63e | |||
| fea1819ef8 | |||
| 45586277ab | |||
| 8ff3383246 | |||
| 7879dc5e43 | |||
| 392084e436 | |||
| 256e1cb778 | |||
| e131f77de3 | |||
| a60082e5e5 | |||
| 81bb8f8f4a | |||
| a9af75d9df | |||
| 30758848fa | |||
| 00c024bd2c | |||
| a69d28fb13 | |||
| ac818ccf34 | |||
| 533335bad9 | |||
| 37c99ae1fe | |||
| d9b232b87b | |||
| b8df611d0d | |||
| e0b5beed02 | |||
| f92cf97f63 | |||
| 9ae11bc525 | |||
| ef1426a938 | |||
| 456b02e2d9 | |||
| abb156a7b1 | |||
| ca8917a43c | |||
| 8ce75007ff | |||
| 76ced9aeeb | |||
| 42eeb67039 | |||
| c11be659b3 | |||
| 2a44d98015 | |||
| 885fa2fb1a | |||
| 8bcc2135ca | |||
| a26d2fb50b | |||
| ad3a8b3af3 | |||
| 918a450e63 | |||
| 596053c720 | |||
| abb83b43bc | |||
| 6f67a36a58 | |||
| 9cbc9bb3d5 | |||
| 1cbf3c5643 | |||
| 8f35d56e6a | |||
| b5773ba668 | |||
| fa5847ee64 | |||
| b561fbc9e5 | |||
| 305a0b6b95 | |||
| 12a16097cf | |||
| c8986bea40 | |||
| bcf01ecf6a | |||
| a334fbffb7 | |||
| fc8f55195f | |||
| dbd79cbc65 | |||
| 436686dd1b | |||
| 227e754f4a | |||
| 3d43358b86 | |||
| f05f32b92e | |||
| 7628b9aaa7 | |||
| 9ab8f1dee9 | |||
| 092ea034ec | |||
| c4e5261b31 | |||
| 2baf2b9abb | |||
| 87a6b05e54 | |||
| 0925dfdf80 | |||
| cf294fae0a | |||
| 12e77cd6bf | |||
| 867ae1b807 | |||
| db9f91b336 | |||
| df0792d4dd | |||
| 0e0569fe23 | |||
| daa37370e3 | |||
| b35c731ba5 | |||
| 06879089a1 | |||
| b96d530808 | |||
| 8f3c8f37c1 | |||
| e4c7a9cf15 | |||
| 935e2356db | |||
| 3b392a671d | |||
| 4ceb09d609 | |||
| 3f8ea7f8fd | |||
| 95525f2132 | |||
| d15b46dede | |||
| f2fc7581a3 | |||
| e96010d24d | |||
| 35fc39b9f2 | |||
| 7129625ab5 | |||
| e480356447 | |||
| 46ea2de300 | |||
| 2152ded3c4 | |||
| da0e8a2e3e | |||
| da540e8680 | |||
| a12ad52c7e | |||
| 38b5fa297b | |||
| d50475974c | |||
| ada891f2d9 | |||
| e7467e5434 | |||
| fa4a692574 | |||
| 9c60ef2c33 | |||
| 634633d12e | |||
| b3fd0c50ca | |||
| acb267668e | |||
| 344cffef60 | |||
| ef652d4518 | |||
| a66f9bbf28 | |||
| 3e5fbcf2bd | |||
| ba2de9e150 | |||
| b638e04629 | |||
| 45a5f012c2 | |||
| 1a66094800 | |||
| f7afe481bb | |||
| f04bf43962 | |||
| 3964ed1f3c | |||
| 98fa8eef5d | |||
| c7b47eef4d | |||
| 9270bb3bf5 | |||
| ad2f6f6f22 | |||
| 62749f9ffe | |||
| e623f9df3a | |||
| 2b79de7d82 | |||
| cdcc3b4c4a | |||
| e757569628 | |||
| 4f88cdf7db | |||
| 406c13aea2 | |||
| c95b1e4fad | |||
| 43c480fa33 | |||
| cb25bbb2ed | |||
| 2602a05fb2 | |||
| c31da6c2ae | |||
| 17d51ab286 | |||
| e576cb97ec | |||
| 217fed9c9c | |||
| 030f4efbe1 | |||
| 33b3828abc | |||
| f2876fd02c | |||
| ee9ba71f3c | |||
| 6a5d5d960f | |||
| 910409cdfb | |||
| d78b36b60e | |||
| ca2f83db88 | |||
| bf591f55ea | |||
| 99aa09349e | |||
| a391b635ed | |||
| 4f1ff34dce | |||
| 9a23a305b5 | |||
| 539e7e9066 | |||
| 6b1173a1b5 | |||
| 23f8e158ad | |||
| 578061be8c | |||
| 84816b84e7 | |||
| 0ad2f5bb8a | |||
| e415b4b2ee | |||
| 7d6ecaaa1b | |||
| 588461c53a | |||
| 25c23bd653 | |||
| 1a033de259 | |||
| 850f8d2b2e | |||
| 39374095eb | |||
| f4fe8913fa | |||
| 9a5203770b | |||
| 64b0f5161c | |||
| 8e12609746 | |||
| 703d09c556 | |||
| 7b238a32b4 | |||
| c72fa990fa | |||
| f8fc929cbf | |||
| 744aaeb5d6 | |||
| 515088ff45 | |||
| 96b64df989 | |||
| f51c22c67e | |||
| 010c412268 | |||
| 7d204684e1 | |||
| 08f47694ee | |||
| e61977a368 | |||
| 72ae83e6ef | |||
| 80aa0c2443 | |||
| 9e0eb3fd4a | |||
| 6fe4bad240 | |||
| 859089e91d | |||
| c4fa25321e | |||
| 96a6f26b1e | |||
| 3f0d125207 | |||
| cb5821fffc | |||
| f79420a9b0 | |||
| a7b4dc985f | |||
| 38562744ab | |||
| 3d413ced59 | |||
| 0002ce0a9a | |||
| 140d3580e9 | |||
| cf5cb406d0 | |||
| 8c3f3d56c8 | |||
| d33831dfe4 | |||
| b68f1256ca | |||
| 3d3b019e60 | |||
| f1a801d693 | |||
| de33e15ae0 | |||
| abd87c2a48 | |||
| f691e91560 | |||
| 7dafb0c29f | |||
| 5037d92e36 | |||
| d765e08017 | |||
| 4958613ad9 | |||
| 489667c847 | |||
| ccedfad51f | |||
| 841ac5cdb3 | |||
| a8b6202fe9 | |||
| 4708c67dcc | |||
| f705b508a1 | |||
| 6030fb08ec | |||
| a5b152e73a | |||
| 087a57fc11 | |||
| 95e44f99a2 | |||
| b8bb2acd64 | |||
| eb3e806e0f | |||
| 33911aca93 | |||
| 63c6e64cbe | |||
| 358c8aed3b | |||
| 4ac994454b | |||
| 5d6f080663 | |||
| 33f0e3b9ba | |||
| ef809de9a2 | |||
| 628c53513c | |||
| 876362cd80 | |||
| 1a9efd65a1 | |||
| 121918c33a | |||
| b46c3bd142 | |||
| 566b563626 | |||
| 397af1cc86 | |||
| bdfa2a3039 | |||
| 38001410b8 | |||
| c2693fc46a | |||
| 9db84af7a9 | |||
| 0172138084 | |||
| 0e913ec78b | |||
| 6a5d0181b0 | |||
| 7684ed382e | |||
| 0d47acf044 | |||
| 2d09b10f36 | |||
| 77c99ad33e | |||
| a41531944c | |||
| 9b2d9406d3 | |||
| 1776a5b424 | |||
| 419b921487 | |||
| c1f7404912 | |||
| 1c07f40d43 | |||
| d2e6a42eb8 | |||
| 5a7960bb14 | |||
| 27d48f68b4 | |||
| 9465c6cba3 | |||
| 3088be1dde | |||
| 845a8175fa | |||
| af27fb570c | |||
| 181bb137fb | |||
| 220646c7ac | |||
| 4513deb2a1 | |||
| e1001156b4 | |||
| ba2c534622 | |||
| 72f06a784e | |||
| 04e862b6bd | |||
| 7f4af89d60 | |||
| f68bb699eb | |||
| c1d19e2ade | |||
| e4c7e8b27f | |||
| c63c483b9b | |||
| 4b04696feb | |||
| 9ffbba7259 | |||
| cc0b927388 | |||
| cbcf2d55da | |||
| f7c70f60e1 | |||
| 7c96b0f71c | |||
| 2002032be3 | |||
| ed6ff293ea | |||
| 65e66353b2 | |||
| 4fa87804d9 | |||
| 17587a9b30 | |||
| 37c552c705 | |||
| 95c6e72f1b | |||
| 4fa49e6f2e | |||
| 3aaa3d0671 | |||
| 9c013f19c4 | |||
| c7b6929dec | |||
| 4dca932ec2 | |||
| 997fcf8ec7 | |||
| eb8c8ec4ce | |||
| f4ed61c4ca | |||
| 70d3434952 | |||
| 450d75227b | |||
| 6e61980fa9 | |||
| 777e243bcd | |||
| 46d9c25968 | |||
| e26223a7d8 | |||
| 2098165229 | |||
| c7e9d84585 | |||
| 8a92d5e5a9 | |||
| 8b8f187cb3 | |||
| 02e3faf194 | |||
| 7e8e05a0fc | |||
| c3ca12a8a5 | |||
| d82b24927b | |||
| 7d9d05b1e8 | |||
| cc96919672 | |||
| 1f0f5a4317 | |||
| cd9218ec09 | |||
| 7256a0d1ab | |||
| e2f5191844 | |||
| d964a2d53c | |||
| 7ace06834b | |||
| 0a789d3bd1 | |||
| 9c44c3ebd0 | |||
| 67650666fc | |||
| b5e343cfb0 | |||
| 68f72a88a4 | |||
| a225540c48 | |||
| 3fdc1fdeda | |||
| 0748e9eed3 | |||
| f420e07a24 | |||
| 885a56ecf4 | |||
| 4e4e80f3c4 | |||
| 4942ac4c5e | |||
| 20f83ac751 | |||
| 6b2b20cca2 | |||
| 9db392b410 | |||
| 444e87d70e | |||
| d8a6b6b08c | |||
| 2658c298f0 | |||
| 0ca4f9630a | |||
| 718c9aba92 | |||
| 078f1f8878 | |||
| c76eeea020 | |||
| f919368d0b | |||
| ccb442fe44 | |||
| d8c0dd862b | |||
| 1ed418b176 | |||
| 59efd837cd | |||
| 60d23bedde | |||
| d08f74179f | |||
| 70ad084bcd | |||
| 818ef1ccb0 | |||
| f6369f7b44 | |||
| 5f8295a588 | |||
| 5c51788dc3 | |||
| 24e6d57273 | |||
| be95d34122 | |||
| 359cdd106d | |||
| 0bc5b10a78 | |||
| efd4eda62a | |||
| d4c169e6bb | |||
| c12f3d443c | |||
| db7ecea006 | |||
| 2b4136faa7 | |||
| ca09ce8cf4 | |||
| 533087b333 | |||
| 80afcbb737 | |||
| 845faf037f | |||
| 1f160ced02 | |||
| 07e4c07d6e | |||
| 78ab84b021 | |||
| db5551df23 | |||
| ee75395c31 | |||
| 453bb75b7f | |||
| 88d2c8325d | |||
| 8fe9a3dbe2 | |||
| cf6c282b25 | |||
| 773e525a6a | |||
| a7d35d51b8 | |||
| 46eb93dabe | |||
| 43e63f864e | |||
| 8774b53570 | |||
| 2aaec2ca58 | |||
| 5efe36878d | |||
| b9861249b7 | |||
| 2c9b585f4b | |||
| dd5b36ff4a | |||
| 144a942a97 | |||
| ff661d84ee | |||
| ab1b8619f8 | |||
| 0f621bfe5a | |||
| c344f418c5 | |||
| 400cc5a32e | |||
| b6a383b5d8 | |||
| a8215ddec5 | |||
| 954c96ed16 | |||
| ed9a3613f5 | |||
| 29743351de | |||
| fd897464f5 | |||
| d9b0420202 | |||
| 527d907035 | |||
| 6c3270e6d7 | |||
| b7382eb7c3 | |||
| 0cd7d45338 | |||
| 23dcb9699c | |||
| 9603952b2a | |||
| 299eeee9d3 | |||
| 63c7e9c08b | |||
| 42131457bf | |||
| a7370fbe52 | |||
| 41f0a15cea | |||
| dd92f37fc1 | |||
| 46d4d699dd | |||
| 76b594363b | |||
| 107b18d642 | |||
| 9973491503 | |||
| 418081048e | |||
| f22838b99f | |||
| 9bdacea568 | |||
| 6bec0ed0c8 | |||
| 4363955443 | |||
| 47a272c7e8 | |||
| 07ce852917 | |||
| a6ada5c9e3 | |||
| 90c5b1d003 | |||
| 326aca31d7 | |||
| 4fd5ba863b | |||
| 4bd0b221e7 | |||
| f47511f26a | |||
| 2d409eaa1f | |||
| 9d42c4fb7d | |||
| d3ced8f7e2 | |||
| a5715b2ca8 | |||
| c18dd604d8 | |||
| c2656f4ee6 | |||
| 18641d46d3 | |||
| 7ef3a08215 | |||
| 55f1a3b220 | |||
| e2f313e6ea | |||
| 5f99478433 | |||
| 89f0a3663d | |||
| e53dab87e3 | |||
| 6d85c179bb | |||
| 30dafcef49 | |||
| 32d7a7394e | |||
| 3cb4f27433 | |||
| 50692566d5 | |||
| fbb2afd9e1 | |||
| 3de951b153 | |||
| 1b86d467b5 | |||
| e4743ced9b | |||
| 2351056d56 | |||
| 748f9a2393 | |||
| ada15065ea | |||
| 53f1831f59 | |||
| 860ebf18b6 | |||
| ae05ca08ea | |||
| 88e8a56526 | |||
| fb4d714441 | |||
| 2690f63a82 | |||
| ec9855a0a9 | |||
| 984ad8e448 | |||
| ca81b32502 | |||
| 70f190501b | |||
| 432efdbb7c | |||
| 83a821a591 | |||
| 14bdfd4a1e | |||
| 66ff2a38d3 | |||
| 9f7c9cad38 | |||
| 2132226b15 | |||
| 849465b2e1 | |||
| 402b31625b | |||
| 5fc24dbfae | |||
| 8ba65cc2fe | |||
| ee2bffe08e | |||
| c4b5db9002 | |||
| 1a78bdc194 | |||
| a3bb749a6a | |||
| 626f78b230 | |||
| 5d44285c0a | |||
| 2d8091ddcb | |||
| fa6a4698ab | |||
| f40454313f | |||
| 8a256b1bc2 | |||
| 71ac7ea464 | |||
| bbc9f93429 | |||
| 3ba9598b5e | |||
| ffa51986a8 | |||
| 7e20140a00 | |||
| 50d080f54e | |||
| 29829b1c8c | |||
| e84e707d2f | |||
| c12566ae53 | |||
| e6976b5262 | |||
| 81cfdcf7ac | |||
| 9c1ca5b748 | |||
| a0d14f97f4 | |||
| 8fdc70fdc3 | |||
| d93139a458 | |||
| b78dc12db9 | |||
| 2a02f589bf | |||
| bcfa4f8400 | |||
| 042dc900ef | |||
| 6004607bf6 | |||
| 40e4b1ce7f | |||
| a4c5a2295f | |||
| 3d9a75ff51 | |||
| 1216a4dd9d | |||
| 4bc1c402e9 | |||
| 42a758bd68 | |||
| 07a0d0ca43 | |||
| f53b04444b | |||
| 9877c00c92 | |||
| 14aa175193 | |||
| 562766a637 | |||
| 4616911189 | |||
| 1e14a0919f | |||
| e1f0bd152c | |||
| c10e34ad43 | |||
| 714f742560 | |||
| dc5e440fe2 | |||
| d53873dc7f | |||
| 10ab557d8d | |||
| 81147d42a9 | |||
| 8d5e2037d3 | |||
| 7f03e61b5f | |||
| 870ee2fb66 | |||
| a525079282 | |||
| 39aff2bc1b | |||
| b4dc9f1970 | |||
| 37ac3e7a81 | |||
| e3e6428d2a | |||
| 3e3385d1eb | |||
| 0375e74227 | |||
| b6bdeeefdd | |||
| 24b3660f7b | |||
| da2c30cd8d | |||
| 1fad95c0bb | |||
| 6a1b1a9a91 | |||
| 9abf0b6ebe | |||
| 34e90b335b | |||
| e84c82f29d | |||
| 1c929a1a70 | |||
| 1c8878e651 | |||
| 84645d84f7 | |||
| 0cc59501bc | |||
| ef6aaa58ea | |||
| 942ce48241 | |||
| 625ec43c34 | |||
| f184303415 | |||
| 84a17864cd | |||
| 1476247519 | |||
| 0ea95005ee | |||
| ba808f66da | |||
| e8472a502f | |||
| 7d552ab715 | |||
| 00de3a41df | |||
| 36f38187d9 | |||
| dd68b2b788 | |||
| 7a9d37c884 | |||
| 6a904d4be2 | |||
| b5b8408055 | |||
| 86a3228579 | |||
| 560993c0f3 | |||
| a3fe935149 | |||
| a75549aebf | |||
| be3fc7aa09 | |||
| 6cbfc6d74b | |||
| 7ff7a051d9 | |||
| 9a2f156158 | |||
| 38b058636c | |||
| 32523f5dea | |||
| ff70e38dbc | |||
| 8110f52bc3 | |||
| a5a0176433 | |||
| 2d960e3e30 | |||
| b5fa978023 | |||
| 9cc49e9f66 | |||
| dbf63e518f | |||
| 3e218b6e6d | |||
| 2f52f6b8de | |||
| 5d377cf885 | |||
| 62ecacc3cc | |||
| 4ab6d45e50 | |||
| d1bed5e41a | |||
| b907f884ba | |||
| 83a76c928d | |||
| 90d3f3aef2 | |||
| 263df606d2 | |||
| 2a38969e3d | |||
| 300feec18e | |||
| 7c56452eb4 | |||
| 548ec540f2 | |||
| bceba34cc3 | |||
| b07ce2be5c | |||
| 9472cf5e8c | |||
| 82f34e8c6e | |||
| 02b718dac5 | |||
| a65a23acb2 | |||
| 12523e7515 | |||
| d3497f924a | |||
| 75faa54b85 | |||
| 02a9ac9be5 | |||
| d22f9aaf62 | |||
| 68f5e2ab80 | |||
| 4e2b71cb85 | |||
| e229a7ee65 | |||
| abf1f53218 | |||
| 2859219f80 | |||
| e71b7f4533 | |||
| 5ba652fab6 | |||
| 0a3913a2aa | |||
| 118ef8a29f | |||
| b5f7e06605 | |||
| d258d27406 | |||
| 554fb28cdb | |||
| 808901c38b | |||
| 68508d8a86 | |||
| 0daf8d7691 | |||
| 5aeb2038c5 | |||
| d3a1060117 | |||
| 4d22e99076 | |||
| 879607982e | |||
| 5482b31fcc | |||
| f5be0e6807 | |||
| c3a60871c9 | |||
| 2628444866 | |||
| c1a7360c66 | |||
| b8c2bdc9e9 | |||
| 573735ffa9 | |||
| 9b3a9883b0 | |||
| 8cc54d376a | |||
| 26910dbbcc | |||
| b131fea7d1 | |||
| 84bc36d855 | |||
| ea5bd22c28 | |||
| e9f1064d73 | |||
| f3cba203dd | |||
| 0ff15850b7 | |||
| 383563c399 | |||
| 40bbb54f56 | |||
| a09eafb9c1 | |||
| 98b15d5c82 | |||
| b5cb547fe2 | |||
| a7af39fb34 | |||
| 02fa7de016 | |||
| b14ee9a6a4 | |||
| be2181c4cb | |||
| 6cd65812ac | |||
| b282fd1525 | |||
| 7c38e672e3 | |||
| c2685476b0 | |||
| a80e2176de | |||
| bf4de82442 | |||
| 22c736a41c | |||
| a1c5f9d987 | |||
| 1ca1df1403 | |||
| be3b554cde | |||
| efc8f04836 | |||
| 1cf7138430 | |||
| 64c8145f1a | |||
| dc62ad927d | |||
| 61188d8a97 | |||
| f92d5e996e | |||
| 817fb82476 | |||
| 9635f9464e | |||
| 33e0d58e4d | |||
| 5fb951328d | |||
| 5f5255d107 | |||
| 4bf7f83f1e | |||
| cce6d99a74 | |||
| 4060425ac4 | |||
| 5023bec388 | |||
| e0d90874bd | |||
| dd62ba1efd | |||
| ceb8191b43 | |||
| da2e55bb25 | |||
| 23f53dc5df | |||
| 8a3a990e39 | |||
| e3cd2b0b92 | |||
| 1777128590 | |||
| 04a5fe9d77 | |||
| 595a4eb3a3 | |||
| ede9259347 | |||
| d87556fd9f | |||
| 198ce23625 | |||
| 1be83b7b48 | |||
| 2b2b94936f | |||
| 84df8f727e | |||
| 84aaaf6254 | |||
| ce867efaea | |||
| 492d9f5170 | |||
| dcbbf7ba44 | |||
| 04e7f818c3 | |||
| 2713bdf08e | |||
| b2bfaf0e81 | |||
| 25a4be076b | |||
| cbd721a10f | |||
| 3ff8cb5e61 | |||
| 6f144f64b1 | |||
| 93200d1450 | |||
| 3f60533c50 | |||
| 41ab0abac8 | |||
| a5e4fb24ca | |||
| 6d2ce895e4 | |||
| f41a4197b6 | |||
| 200c728342 | |||
| d8571c4f3e | |||
| 233ceaf400 | |||
| e8bca6926a | |||
| 9e8aecae4d | |||
| e75b44695e | |||
| 18784b3ef7 | |||
| 98a241a301 | |||
| 70ee783a7a | |||
| 00cd0bb5b0 | |||
| 02b7365ba3 | |||
| a6da530848 | |||
| f6e6af5e42 | |||
| 6f834aeec0 | |||
| 8bfdde0d51 | |||
| 137ac3c58c | |||
| d7a0d9f7a5 | |||
| 9b477a700d | |||
| 77a9dcf5af | |||
| 1c1f7ade78 | |||
| 4aa593f8c6 | |||
| cefd39f7e9 | |||
| cfeb8fd9a3 | |||
| 241a053252 | |||
| b53726276c | |||
| 7316e4aa48 | |||
| 889629e3c1 | |||
| fbecf5bf7a | |||
| faae87212e | |||
| bfa355e956 | |||
| a2c0001f5a | |||
| 5fa2339018 | |||
| 9ba183b6a6 | |||
| 6d142a7f38 | |||
| 6f157e2923 | |||
| dccb070376 | |||
| f0b70c9424 | |||
| 9a847ec6b6 | |||
| 4a94d6a966 | |||
| ae654452e4 | |||
| 2e71fd89c9 | |||
| caf497ed28 | |||
| bb5dd7176b | |||
| ac83a65d3b | |||
| 147cbfa625 | |||
| 497775c382 | |||
| 3a9b8dc0f8 | |||
| 0fd3452dd6 | |||
| 1d5c277df0 | |||
| 6483efc099 | |||
| 149a9f599d | |||
| 1bd55c4d80 | |||
| ef9abe8f91 | |||
| 461b820ef1 | |||
| 0535353124 | |||
| 19804607ac | |||
| 272de0bd33 | |||
| b53a54a237 | |||
| e57c6d0ade | |||
| 18556f54d3 | |||
| a5c081887e | |||
| 80efd4ab86 | |||
| 3df541689b | |||
| 06c90dc3ca | |||
| d3599a6a0c | |||
| afab5f2515 | |||
| 37e7e74d28 | |||
| 25eff6929b | |||
| 72888ff02d | |||
| 9c572a2771 | |||
| 0f6d6256a2 | |||
| 2191e63803 | |||
| 43d75dc3ec | |||
| 59cae2fde6 | |||
| 4f4c9e5426 | |||
| df03f16a9d | |||
| 57db753218 | |||
| 5d4a2b51ef | |||
| 67c6185f8b | |||
| e3d18f7a1d | |||
| 4bcefaa7b8 | |||
| 9d9551c072 | |||
| 4e6bc5ae71 | |||
| c353c69180 | |||
| 9c0e489f3b | |||
| d4028a9d9f | |||
| 25960c180f | |||
| 926bd0cc55 | |||
| 0d714b4dff | |||
| f5ccc1f103 | |||
| 565e6d963b | |||
| c057d1e3d7 | |||
| 10c3151d9e | |||
| cbf88226b4 | |||
| 4ad53ca762 | |||
| a2ace90898 | |||
| 814e38ef59 | |||
| 871e1ae675 | |||
| 67c973124a | |||
| 42cfc3745d | |||
| ccb4dfa267 | |||
| 8a9606db97 | |||
| c3846a998a | |||
| 0bcaab3c50 | |||
| 6dc5150804 | |||
| 7bba18673a | |||
| de8853bd7c | |||
| 6e938b2693 | |||
| cb41f9263f | |||
| fe3f837a58 | |||
| 65374e38b3 | |||
| 24fd4ef842 | |||
| 5682971722 | |||
| c286c44041 | |||
| 1dc3013376 | |||
| ea61734f3a | |||
| ec521b84e7 | |||
| 62189f8642 | |||
| a8e1085884 | |||
| 2bd76d118a | |||
| b258bd95bd | |||
| ab566986c7 | |||
| 033230f73e | |||
| 063d77928c | |||
| ff71f4b637 | |||
| 88f4b2b2fa | |||
| b64bda64dc | |||
| dfeea949dd | |||
| 54d798f358 | |||
| 4546450587 | |||
| ec6b6e6b09 | |||
| cb5d4df4f0 | |||
| 1a060759f9 | |||
| 44fc2fd771 | |||
| 92dbf3269b | |||
| c6b4de05f3 | |||
| ae3c34c2fe | |||
| 34c19ff041 | |||
| 80c9857475 | |||
| c147a6c010 | |||
| 88a720543e | |||
| e780e3731d | |||
| 853cda603e | |||
| 5f8287f389 | |||
| 8fa7cbc060 | |||
| fe1b0fcac3 | |||
| 6f9a1a907d | |||
| 82bb72fc82 | |||
| 6812ff84b9 | |||
| 3096985466 | |||
| ce9e6d4379 | |||
| 1f376d4b5d | |||
| cfb830a694 | |||
| c072b4a932 | |||
| 9fe67eaeb2 | |||
| 389501239c | |||
| 9512813b58 | |||
| bebf636b4e | |||
| 1db1acf44d | |||
| be58a1c04e | |||
| fbdc98f8bd | |||
| ba79cf292f | |||
| 93b870a139 | |||
| cfa50739d8 | |||
| 86eedf1985 | |||
| 1bc7a1c143 | |||
| d6241d31f8 | |||
| a6465b0b0b | |||
| d1b28b669f | |||
| 68452974f1 | |||
| 09810d0b6c | |||
| 7358c4e3fa | |||
| 7380f4a15e | |||
| dbb11957ae | |||
| 151b72baff | |||
| d716e261b4 | |||
| 7ba21d058c | |||
| bbc64f685e | |||
| 2c28fda5e3 | |||
| 5a9ae02f61 | |||
| da6d971de4 | |||
| b4e7dfd099 | |||
| 39577b31bd | |||
| 6b039c1cb0 | |||
| 2cbff15837 | |||
| 921a36c94d | |||
| 14412871bf | |||
| afed43571d | |||
| 773dc11888 | |||
| e0f6dd4097 | |||
| 711ad05983 | |||
| 2d0a9f334b | |||
| 97996048ae | |||
| 66bd7f758e | |||
| c51158bad1 | |||
| ccf881c054 | |||
| 1f780a0ae8 | |||
| 17ac08af8f | |||
| 214488cf80 | |||
| c6c842adc9 | |||
| 4cb23dc2b1 | |||
| 7c9285061a | |||
| 0edfa973e4 | |||
| 0be20d514e | |||
| e414ae40d4 | |||
| b54b2a2dc2 | |||
| f545c0d7ad | |||
| 18142201bd | |||
| 50320ba868 | |||
| 12b05a7d0a | |||
| 7e19a5a1ff | |||
| 096e3a1629 | |||
| 51a5c4a65e | |||
| e5472b4c67 | |||
| ebf4c07142 | |||
| 4ab8aac38f | |||
| 8d32b1463a | |||
| ec09142445 | |||
| 1e3c713c95 | |||
| 1747170ec3 | |||
| 1e436189b7 | |||
| a247485a38 | |||
| b352cb2701 | |||
| b0b2c3f8d8 | |||
| aba98031f7 | |||
| 66ec241c48 | |||
| 2ee80b41e0 | |||
| c1aa08b266 | |||
| 7b7bdc3d88 | |||
| 71588fda63 | |||
| d4b1b20016 | |||
| 952face2ae | |||
| fa8c7bd460 | |||
| de8c506618 | |||
| 18a7d179f8 | |||
| 56c6e97874 | |||
| b574c01264 | |||
| 6d64ba8ffd | |||
| 6d0a85b7be | |||
| 33d5da9530 | |||
| 2a92004fbf | |||
| 97ec8594d9 | |||
| 713a2cd924 | |||
| 0d4fd3d6e9 | |||
| 7cc9d8dc18 | |||
| f896d59609 | |||
| 707858900a | |||
| de0b361062 | |||
| 111b038fec | |||
| a2f90d5de0 | |||
| 084363b3d6 | |||
| 9c0dfa7aa3 | |||
| c4bf18cf0f | |||
| 9663eb4931 | |||
| da846df924 | |||
| a5bb663f9e | |||
| b234905860 | |||
| f658cfcd03 | |||
| 56297cde91 | |||
| f51c74d09e | |||
| b06da932cd | |||
| f869b73bf5 | |||
| 4f40cb8862 | |||
| 161e9b575c | |||
| 42ccd3f3c1 | |||
| 5dcb37032e | |||
| e42bf2de73 | |||
| 4225404673 | |||
| cbfa388568 | |||
| d471a84125 | |||
| 2cc18422fd | |||
| 4ba60dab16 | |||
| 588e4acbd3 | |||
| 3a67bbe298 | |||
| eb730ecd6e | |||
| 112da2c873 | |||
| 37ff3c0d17 | |||
| 1451bfac12 | |||
| c445e24ff6 | |||
| bb5af66656 | |||
| 85e8b7fc24 | |||
| c4802fe442 | |||
| 91ee1d96b3 | |||
| 19fe40b172 | |||
| 355ac7a0fa | |||
| 361a8a6297 | |||
| 0b68aa85c7 | |||
| 3105b811d4 | |||
| b4d857eb88 | |||
| b438b6df61 | |||
| 15bda0560a | |||
| 3433ea6aef | |||
| 745ebf64ef | |||
| 80ff06ef24 | |||
| f270ac9f9d | |||
| 7d5d89302b | |||
| 155482779e | |||
| d1bacf140c | |||
| 55a38a5e05 | |||
| f21d451c13 | |||
| 29c3e00c27 | |||
| 44d249ad5b | |||
| 4333412169 | |||
| 50c7eaa29a | |||
| b2e168dbec | |||
| a1d4e9c7b3 | |||
| 82d1b48314 | |||
| eaffaa11b7 | |||
| 2c13995625 | |||
| 7b0892e379 | |||
| 483ccab3f7 | |||
| 7f62af6955 | |||
| 857dfc7e27 | |||
| f47ca40974 | |||
| 0256203f44 | |||
| 5bda94794e | |||
| 0d218980f7 | |||
| 73d8c30d9c | |||
| 7abafbeaa1 | |||
| 2a7728a76c | |||
| d266997720 | |||
| d6fb26d425 | |||
| 1eb3658ce5 | |||
| badf679bc3 | |||
| 1e542a0aa1 | |||
| cdbeca245f | |||
| 5a75626625 | |||
| 17e3985671 | |||
| 5a5f151b38 | |||
| 97c8ddf4e1 | |||
| df7414b670 | |||
| 8dfe2f2934 | |||
| 3f3968dda2 | |||
| 3930564f9b | |||
| 90edc1dc49 | |||
| c9f7906e10 | |||
| 1758eabe0e | |||
| 7ec73c9bc7 | |||
| 1b0ad8f8b6 | |||
| 555f76a4ec | |||
| bfb9c5d09c | |||
| 9a72a7e3e7 | |||
| b48c3f1235 | |||
| 85f59566a6 | |||
| 1c3f2c7500 | |||
| 47ca096e48 | |||
| a9da2a0e63 | |||
| aaea98049e | |||
| 93668da99f | |||
| 53c7df0b8e | |||
| 86e8ac39c3 | |||
| a9a10e3e90 | |||
| 1de81a6aa4 | |||
| 540a0e35a1 | |||
| f0ae702933 | |||
| 19be4348aa | |||
| 84ed1fc5ca | |||
| de38f8219c | |||
| 12061f0752 | |||
| a5174f10d5 | |||
| 69eeaf6c46 | |||
| ac25e1b4c6 | |||
| 51ba5b980c | |||
| 26e0800d70 | |||
| d0197aacd7 | |||
| b097c5db44 | |||
| 302f64ad5c | |||
| 23cd2097da | |||
| 58257a6eac | |||
| ea7b982b7b | |||
| 1fcd76db9e | |||
| 66d92cfa51 | |||
| 9dc5ee22ee | |||
| b5df729d97 | |||
| 56e3541b80 | |||
| 9681bce540 | |||
| 7330c729e0 | |||
| 977a19a4c9 | |||
| d226fedf75 | |||
| c9ee1da728 | |||
| d6753501e7 | |||
| c3f62a4ff6 | |||
| 9fb6d7ecf6 | |||
| 48101eee39 | |||
| 16e771f1c3 | |||
| 0aa49a6a94 | |||
| c810619a13 | |||
| 817d277091 | |||
| d3a822e8b3 | |||
| ec8c37744a | |||
| 732c36630c | |||
| 498d2f04b6 | |||
| d167147725 | |||
| 19800971d6 | |||
| 4b457240b9 | |||
| b63c5867d4 | |||
| 598dd8b6c1 | |||
| 6cd399cea7 | |||
| 466033d04c | |||
| 99bca0220b | |||
| a10b9f0ad7 | |||
| 825d549822 | |||
| 03cb06e757 | |||
| 113b33e68d | |||
| f3a327c9c7 | |||
| 2ca4a989e2 | |||
| 7339f2cd4f | |||
| 827897b771 | |||
| eb5064d591 | |||
| 177862f178 | |||
| 7276dcf1e0 | |||
| 7587aec913 | |||
| 7186c93078 | |||
| 727e20584f | |||
| 9c13775eec | |||
| e767ef7d92 | |||
| ce22df4d60 | |||
| 7c4567ae70 | |||
| af1125f6ed | |||
| ee65e65108 | |||
| 5c1f7d54ba | |||
| 21e5af9e9e | |||
| f7d674adf4 | |||
| 5a7de9731a | |||
| f987dc2716 | |||
| bbdc1274e4 | |||
| feb95fe105 | |||
| 91b0d3782a | |||
| d297e9b6d1 | |||
| 6d613cab85 | |||
| a0b6aa13a3 | |||
| a794f5da70 | |||
| e571edb354 | |||
| 84dece39a7 | |||
| f49bf21caa | |||
| 64da0ef94f | |||
| 3177b5ab80 | |||
| ec1b459689 | |||
| 08f7f87035 | |||
| 59189cae2a | |||
| 573dcd034a | |||
| 0f29a8a33b | |||
| 98ec7e4165 | |||
| 46c7d53f6e | |||
| 61758b8c2a | |||
| 42ac9555ac | |||
| f7416f2255 | |||
| 509c92217f | |||
| c0d5ae1e9e | |||
| cf9fbc5ccc | |||
| ad19be83d2 | |||
| 3dd5e41bda | |||
| 639a45611e | |||
| 773299c317 | |||
| ff39b10761 | |||
| 5fd5472fd3 | |||
| adf94802b0 | |||
| 15378a412f | |||
| 32da16f652 | |||
| 075d92e4bd | |||
| a7cbcd284c | |||
| 5682d508a3 | |||
| c738b5703f | |||
| 6875fce2cb | |||
| 57b7ad6732 | |||
| 8377db323b | |||
| 78d768ea4d | |||
| 9dee654eee | |||
| a02f49916a | |||
| 8d25e9b760 | |||
| 62ebbb2b5f | |||
| e62caa725f | |||
| db5f81e6be | |||
| 15cba3da22 | |||
| ca93182f68 | |||
| b1a2b6774d | |||
| ebc98099d3 | |||
| e2137f94f8 | |||
| 3fdc081548 | |||
| 57cc2d2e3a | |||
| b883d2a95c | |||
| cfcb35c7ee | |||
| 7f4899b885 | |||
| 719d656a64 | |||
| 04476ed463 | |||
| 7ef7b1d107 | |||
| 0cb9ab5704 | |||
| 4d109b948a | |||
| efd8b191ff | |||
| 8273a31512 | |||
| 7baebd85c7 | |||
| e43f6afd8a | |||
| ec3430db27 | |||
| a2c0551349 | |||
| a66719b865 | |||
| b201543014 | |||
| e269f31056 | |||
| 0c1db66be6 | |||
| 5e46507f04 | |||
| f3616abec2 | |||
| 86ec46dbe7 | |||
| 863b5df0c2 | |||
| 929b3b8e95 | |||
| f79393c46e | |||
| 559dd1df71 | |||
| cb959bdee7 | |||
| ac1b0ec8b3 | |||
| fe779a29b4 | |||
| b4a1d10037 | |||
| 03856d30ec | |||
| e2e3e444ec | |||
| cd98d150d6 | |||
| 92814ae535 | |||
| 3268c2a509 | |||
| a09b6c1431 | |||
| 31c560093d | |||
| 728bd0450d | |||
| 3df3fdbcfe | |||
| 296756dac4 | |||
| 7cb766f3d0 | |||
| 5e94b287b9 | |||
| e9eb8f17d3 | |||
| dfbaf9de21 | |||
| 4dcca0da4a | |||
| ea85d4f62e | |||
| bef1a3ecd9 | |||
| 22be75508e | |||
| dba8241c5c | |||
| b3d0348821 | |||
| 3006a438e2 | |||
| d2f74481cd | |||
| d29ad223a7 | |||
| c62ccd0790 | |||
| 8ff240cbe9 | |||
| 4a5769d37e | |||
| 7d27ee7937 | |||
| a927a6fa70 | |||
| be4db68d00 | |||
| 6e3807cf78 | |||
| ff9adfc30f | |||
| a6037b60eb | |||
| a0b95cc24c | |||
| a2536b576e | |||
| 0e544040fc | |||
| 940957fa98 | |||
| 2e6b808f6d | |||
| 9ff4734659 | |||
| 7a08f483b9 | |||
| c2b10e969e | |||
| 7bf9b10963 | |||
| 6402cbef06 | |||
| b8855bf73b | |||
| 5453efadb5 | |||
| 8246405c31 | |||
| 777f16f3c1 | |||
| 11860ff21a | |||
| 0a6df56dd2 | |||
| 9dd643ecab | |||
| d8d01384b1 | |||
| d0b3bbd488 | |||
| e8c458f2c4 | |||
| e324e50713 | |||
| d506ba7317 | |||
| 2c960813de | |||
| 1d98bc75b0 | |||
| 19dddb3cc0 | |||
| 863dd2423d | |||
| 4cc5e92b83 | |||
| fe626f3442 | |||
| 50ad9a7a92 | |||
| a73ec96f5c | |||
| 1d072d4544 | |||
| 4997acd8bc | |||
| 94e202c596 | |||
| 9044060816 | |||
| 3be1a34725 | |||
| 41405b83ef | |||
| 2133355fbb | |||
| dd7e9ad43f | |||
| 460b8208d3 | |||
| 5c040a3b03 | |||
| 7dd7643a76 | |||
| 12a6ea1097 | |||
| fb71414096 | |||
| a814a95c34 | |||
| 3732359043 | |||
| 18d637c2fb | |||
| 48a7d2a112 | |||
| 4639020a8e | |||
| 27da3def3c | |||
| 77027adad3 | |||
| 76dd83ac58 | |||
| eb40143b76 | |||
| 1cda346174 | |||
| f534abdb6f | |||
| 5204a38528 | |||
| fcb9c1e3b7 | |||
| 5af41f8d4e | |||
| 592c9de77a | |||
| f67f4edc21 | |||
| a251835a79 | |||
| ba8a57e381 | |||
| 75f581924e | |||
| 10f2a8327c | |||
| 127a16b4d6 | |||
| f9ded2b857 | |||
| 622412992f | |||
| 5156c3481c | |||
| 1d3d4ac36e | |||
| 3449dc9d2c | |||
| acd01910ac | |||
| e16bcafce0 | |||
| b8e664aad8 | |||
| b365394ff4 | |||
| e53549d7cc | |||
| 743e99af85 | |||
| 9616fe8d44 | |||
| 4ae6361599 | |||
| 89c12e6ba9 | |||
| 5d6bc82d8a | |||
| a4f3fe5a49 | |||
| 3a58a3490a | |||
| e2408702ea | |||
| 3f1f27e5aa | |||
| bbd0ec2edd | |||
| eb22b19d98 | |||
| 45b9d44fd0 | |||
| a92003f0f5 | |||
| 4ac9387ef2 | |||
| a24b5e8145 | |||
| 7244e3978b | |||
| 41cabf5e0e | |||
| 99c7dc4b29 | |||
| 338bc03cd6 | |||
| 985260144e | |||
| b487925fbf | |||
| 07a89443b3 | |||
| 54ebb2d96d | |||
| 34a7cd1f6c | |||
| 455b8fc709 | |||
| 591b422302 | |||
| fc73c34a03 | |||
| cc1c6e3088 | |||
| 966f7124c4 | |||
| 1cebec5af4 | |||
| d31b5097ff | |||
| 21a8fe4025 | |||
| 6a4adcfe0c | |||
| c9acd46cd3 | |||
| 6018113a3d | |||
| fe0825957a | |||
| d0cef51e35 | |||
| 2aa0d11f4c | |||
| b8a1aa3f3c | |||
| 80e95bc77b | |||
| 87af0312cb | |||
| e988c9aa14 | |||
| cdee98f690 | |||
| 268c619ad6 | |||
| 352434ae35 | |||
| b1e39bae32 | |||
| 44a2c929c4 | |||
| 42a9865fac | |||
| c730863162 | |||
| b58db19127 | |||
| 2820c2d942 | |||
| 0be172c675 | |||
| b26fd77190 | |||
| 9317a1703a | |||
| f166279507 | |||
| 60216519af | |||
| ed83d31b22 | |||
| cf6e2955c7 | |||
| 4e3a183e2b | |||
| 0879855d3f | |||
| 2358552961 | |||
| 35b461b60c | |||
| 1b5cfc3920 | |||
| 4a179ccc1e | |||
| 4921578af3 | |||
| 55fb1f2044 | |||
| ad38aae7b0 | |||
| 7e0840ef09 | |||
| 03fcb5f2f7 | |||
| 0c4ea86e03 | |||
| 209bb9763e | |||
| 187de6807e | |||
| b8c9302018 | |||
| a86fccffc0 | |||
| a448adec93 | |||
| d45ff62a57 | |||
| 3c50fc6a71 | |||
| 292a120f74 | |||
| a7868787a4 | |||
| 5275f4fbeb | |||
| ba25ec7ad0 | |||
| 301b4124ef | |||
| a936a97e5e | |||
| c390d18399 | |||
| 43df9bff48 | |||
| c15a9ea7cd | |||
| 271742b306 | |||
| ebdadd81c0 | |||
| 1364855303 | |||
| b5d862b231 | |||
| 048a8adaa1 | |||
| 4b7b999d43 | |||
| d02fa83af5 | |||
| 89045095b1 | |||
| 0de721e67d | |||
| c7c171a1d3 | |||
| a2500148fd | |||
| 99b8a3726b | |||
| 8b426e0f6c | |||
| caca561997 | |||
| 385bd78021 | |||
| 8ca127e93f | |||
| ac9b8aaf68 | |||
| 4939f56f1b | |||
| 659e950722 | |||
| 5186ae4334 | |||
| 64cd38e2c3 | |||
| cc29c887c5 | |||
| 7fd7dd7cc8 | |||
| 9ac3088b72 | |||
| 9745885f1b | |||
| 818189e8e0 | |||
| 2ccaaebfc9 | |||
| 82e75f2170 | |||
| 1b265fd95b | |||
| 7a8905e0bc | |||
| 6d8ba6d30c | |||
| 74544c9a9e | |||
| 3314bb7694 | |||
| c920bac33e | |||
| e6881ef617 | |||
| 94a7bb43f6 | |||
| 97841f0cbe | |||
| de392bd4de | |||
| f2a3e5f2fc | |||
| 20a2566fe6 | |||
| 96bd411839 | |||
| 773196fd84 | |||
| 26dc8b4963 | |||
| 06a9202c7c | |||
| 634786917d | |||
| 4ea839c30a | |||
| 10f6ea382b | |||
| 929414e31f | |||
| cd87a3e773 | |||
| 7a3168dcfd | |||
| 888f8fb01a | |||
| 6f06ec5aa0 | |||
| e10cb3f2bb | |||
| 2edd3cb5ab | |||
| d6d3dc83f5 | |||
| ae392691b1 | |||
| 35a202d280 | |||
| e9a2d8a232 | |||
| b76b99ca2e | |||
| 8960ea9c10 | |||
| 9f94acb580 | |||
| c9811fd518 | |||
| e25fb1b178 | |||
| 779a03d008 | |||
| f2a972aeea | |||
| 2a9163a28b | |||
| 20a628089d | |||
| cf01d917d1 | |||
| 1544da7e12 | |||
| 94a533508e | |||
| 98cdfcff81 | |||
| 23ddaa81be | |||
| 667e988c47 | |||
| ea6b20fefa | |||
| e734763eaf | |||
| cb98efc47e | |||
| dbd3d87779 | |||
| 533ae6dd80 | |||
| ca62321b42 | |||
| cd3d2c3731 | |||
| 8b8d29a75d | |||
| 3f97757ec0 | |||
| d5f3299bd2 | |||
| 0b99965731 | |||
| 8f679832fb | |||
| 46564a8e91 | |||
| 09b0dbb60c | |||
| 28d53e7e3f | |||
| 43de39f4ec | |||
| 49df0100c9 | |||
| c020f33c86 | |||
| 813daef2b1 | |||
| 13d4e934ec | |||
| 7721464611 | |||
| 9e42c3e7d1 | |||
| badbc150ab | |||
| 5d57c88e4b | |||
| ec18f82d26 | |||
| 846e5e6bf0 | |||
| 16b6df4d05 | |||
| 9e45bd6d75 | |||
| d01436bf14 | |||
| f50e170f8d | |||
| 2dd76d8bd2 | |||
| b9956391f0 | |||
| fdfd0e2355 | |||
| 548dd2fdc6 | |||
| cdcdd7abdc | |||
| d5c8795e75 | |||
| ceb0ac7538 | |||
| 305026c37d | |||
| f95367d12e | |||
| ac962c43e8 | |||
| 26efc70042 | |||
| 7f431e1485 | |||
| 1292046646 | |||
| d54b455007 | |||
| ca55d8942e | |||
| f00fdcb7c8 | |||
| 5443fdf064 | |||
| c33e49661c | |||
| 92bd0c362e | |||
| fe8cafdd99 | |||
| f0f8c4e4cf | |||
| f8e2e23082 | |||
| b915c65c41 | |||
| 4e7154e817 | |||
| 7992cf9e26 | |||
| ea958ea6c0 | |||
| 9b32de5fb2 | |||
| b71d0d058f | |||
| d36e353d9b | |||
| 02106bd64b | |||
| e68d6ae19a | |||
| 16ae480430 | |||
| 0b222c06a1 | |||
| ff340e90d6 | |||
| 4125141d01 | |||
| 2f40ad53b5 | |||
| 2d3674eff7 | |||
| 560e60b856 | |||
| 7cf20234b8 | |||
| ecab0a0f45 | |||
| 587722ed5c | |||
| bd7d9b3d5e | |||
| 60787a2aa9 | |||
| 9675ac3c14 | |||
| ca9a2f5997 | |||
| 652eb4f5a7 | |||
| 6ffbad747d | |||
| af46c596a9 | |||
| 1fcc218f26 | |||
| 39a5d674c9 | |||
| bdfe6ae1cf | |||
| 06e6c65ada | |||
| 8a7e4ff1c9 | |||
| f0a1e1c338 | |||
| 843af67483 | |||
| 80d251c2d1 | |||
| a1c2907cce | |||
| c1369f113c | |||
| 8ea5bc5330 | |||
| 285dda4795 | |||
| fae423177b | |||
| 51bf43289d | |||
| 3fd87eee9a | |||
| ad14ab5d08 | |||
| aab68767fd | |||
| 84f10229da | |||
| 3a6ce31103 | |||
| da7675a853 | |||
| 09726f8df0 | |||
| 8072ce6954 | |||
| 1e870e49c8 | |||
| eeca712a3e | |||
| 29c3e455e4 | |||
| c23a777783 | |||
| 0439279602 | |||
| 27c81d451b | |||
| 339881f288 | |||
| ef6bfb8ea3 | |||
| 4bd33fbbe2 | |||
| 8a5f097808 | |||
| 239eb9705d | |||
| fee141ea2b | |||
| ec7c121e03 | |||
| 8b047fd18c | |||
| 0c04868bef | |||
| deaf16e6ae | |||
| 389cac3e0a | |||
| c04968818f | |||
| 3a391d79d2 | |||
| d1a8019c31 | |||
| 1c211b0a41 | |||
| 7c1ba6d92b | |||
| c0953eecf3 | |||
| 7f70826023 | |||
| a7aaa6fc88 | |||
| a72b3bf705 | |||
| 71656df0bd | |||
| 7d5cff0aa9 | |||
| 4e27396a09 | |||
| 52a72a11b0 | |||
| c17886489b | |||
| ade2f106ba | |||
| a096b2bc1a | |||
| de65390680 | |||
| bd5f92c121 | |||
| ad53a6bc09 | |||
| f3b2c8056e | |||
| 1e90903d54 | |||
| 8537875537 | |||
| a988e562a0 | |||
| d014a54181 | |||
| 4e5d57bf92 | |||
| 6f8ebbb9f0 | |||
| 131ac32596 | |||
| 56017d023c | |||
| 3afdc6cd31 | |||
| 52a89eac77 | |||
| 0cdd311832 | |||
| 1d5b52ef5b | |||
| 3f37be4509 | |||
| 1636af1585 | |||
| 127409955c | |||
| 423ce5e41b | |||
| 770b7e8358 | |||
| 0daf08ceed | |||
| 1cda2259c6 | |||
| 5879080f33 | |||
| cd3ad8bff9 | |||
| 033a748069 | |||
| de890dbd7f | |||
| 7c5ae09a6e | |||
| e587e3b087 | |||
| d698b3cfe7 | |||
| a01680fa2d | |||
| 531fc0fb22 | |||
| bb8e513598 | |||
| f56679cd9f | |||
| 9885e050a1 | |||
| 68be5cbe4b | |||
| 3315c83e0b | |||
| ca9b6f4063 | |||
| cc490b616f | |||
| 988f9eb4eb | |||
| c41eeb2c8b | |||
| 881963ca7b | |||
| db6733083c | |||
| 77851b090a | |||
| e0ada505aa | |||
| 3179f33b67 | |||
| 9bbd72b65f | |||
| 68ace1358b | |||
| f7934fd0fe | |||
| 15cbbc04ce | |||
| d4260106cb | |||
| 0a46bd18db | |||
| 0ccbad4077 | |||
| 013ce09846 | |||
| 736ec95572 | |||
| bddf36261d | |||
| cbd74049dd | |||
| 84f3db4548 | |||
| c07ccf0daf | |||
| 3ff4906214 | |||
| 1bc5133c95 | |||
| dcbad389b3 | |||
| dfea057a25 | |||
| 3a6bf029d1 | |||
| 667041558e | |||
| 01e51f2dfd | |||
| 483971d12f | |||
| fad93dab53 | |||
| ff13b7685b | |||
| 4281701957 | |||
| a0ff5dbcd4 | |||
| ed9ac80e68 | |||
| e9662b9b42 | |||
| ba3fc35a14 | |||
| 18471f0162 | |||
| 71073fd0e5 | |||
| 2e1ee6bb28 | |||
| 239f14acaf | |||
| e87a62c180 | |||
| cb275fc4b6 | |||
| 4042dee884 | |||
| 2d3dc8e024 | |||
| 530759fc81 | |||
| 0f29850d7f | |||
| e060c39790 | |||
| 612d7ad529 | |||
| a310e88905 | |||
| bda959e8d6 | |||
| bd9d12c185 | |||
| adf509dc81 | |||
| 22f5fc43dd | |||
| 49b10b2d4a | |||
| 2f218b7567 | |||
| 1bb0894894 | |||
| 39da15fec2 | |||
| 9ff66b9650 | |||
| c5353b46b1 | |||
| 25aa214ffa | |||
| 28279b7ec2 | |||
| 126170bee7 | |||
| 8ce7935d49 | |||
| f756c9a7f9 | |||
| a59905b98f | |||
| f0d74185a1 | |||
| 06937e026f | |||
| 65acaf7058 | |||
| 7fc574daf3 | |||
| a93eb8d2d1 | |||
| 9fbd15f700 | |||
| cec96d866c | |||
| 80031306a4 | |||
| 9783781bfb | |||
| aba7302833 | |||
| 61ce10c52e |
@@ -0,0 +1,46 @@
|
||||
# Git files
|
||||
.git
|
||||
.gitignore
|
||||
.github/
|
||||
|
||||
# Documentation
|
||||
README.md
|
||||
*.md
|
||||
docs/
|
||||
|
||||
# Development files
|
||||
.vscode/
|
||||
.idea/
|
||||
*.pyc
|
||||
__pycache__/
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# Docker files (исключаем из копирования в образ)
|
||||
Dockerfile*
|
||||
.dockerignore
|
||||
docker-compose*.yml
|
||||
|
||||
# Build artifacts
|
||||
build/
|
||||
dist/
|
||||
*.egg-info/
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Temporary files
|
||||
*.tmp
|
||||
*.temp
|
||||
.cache/
|
||||
+295
-24
@@ -5,38 +5,88 @@
|
||||
# ===== TELEGRAM BOT =====
|
||||
BOT_TOKEN=
|
||||
ADMIN_IDS=
|
||||
# Ссылка на поддержку: Telegram username (например, @support) или полный URL
|
||||
SUPPORT_USERNAME=@support
|
||||
|
||||
|
||||
# Уведомления администраторов
|
||||
ADMIN_NOTIFICATIONS_ENABLED=true
|
||||
ADMIN_NOTIFICATIONS_CHAT_ID=-1001234567890 # Замени на ID твоего канала (-100) - ПРЕФИКС ЗАКРЫТОГО КАНАЛА! ВСТАВИТЬ СВОЙ ID СРАЗУ ПОСЛЕ (-100) БЕЗ ПРОБЕЛОВ!
|
||||
ADMIN_NOTIFICATIONS_TOPIC_ID=123 # Опционально: ID топика
|
||||
ADMIN_NOTIFICATIONS_TICKET_TOPIC_ID=126 # Опционально: ID топика для тикетов
|
||||
# Автоматические отчеты
|
||||
ADMIN_REPORTS_ENABLED=false
|
||||
ADMIN_REPORTS_CHAT_ID= # Опционально: чат для отчетов (по умолчанию ADMIN_NOTIFICATIONS_CHAT_ID)
|
||||
ADMIN_REPORTS_TOPIC_ID= # ID топика для отчетов
|
||||
ADMIN_REPORTS_SEND_TIME=10:00 # Время отправки (по МСК) ежедневного отчета
|
||||
# Обязательная подписка на канал
|
||||
CHANNEL_SUB_ID= # Опционально ID твоего канала (-100)
|
||||
CHANNEL_IS_REQUIRED_SUB=false # Обязательна ли подписка на канал
|
||||
CHANNEL_LINK= # Опционально ссылка на канал
|
||||
|
||||
# ===== DATABASE =====
|
||||
# Для Docker используйте PostgreSQL:
|
||||
DATABASE_URL=postgresql+asyncpg://remnawave_user:secure_password_123@postgres:5432/remnawave_bot
|
||||
# Для локального запуска без Docker используйте SQLite: sqlite+aiosqlite:///./bot.db
|
||||
# DATABASE_URL=postgresql+asyncpg://remnawave_user:secure_password_123@postgres:5432/remnawave_bot
|
||||
# ===== DATABASE CONFIGURATION =====
|
||||
# Режим базы данных: "auto", "postgresql", "sqlite"
|
||||
DATABASE_MODE=auto
|
||||
|
||||
REDIS_URL=redis://redis:6379/0
|
||||
# Основной URL (можно оставить пустым для автоматического выбора)
|
||||
DATABASE_URL=
|
||||
|
||||
# Пароли для Docker (PostgreSQL/Redis)
|
||||
# PostgreSQL настройки (для Docker и кастомных установок)
|
||||
POSTGRES_HOST=postgres
|
||||
POSTGRES_PORT=5432
|
||||
POSTGRES_DB=remnawave_bot
|
||||
POSTGRES_USER=remnawave_user
|
||||
POSTGRES_PASSWORD=secure_password_123
|
||||
|
||||
# SQLite настройки (для локального запуска)
|
||||
SQLITE_PATH=./data/bot.db
|
||||
LOCALES_PATH=./locales
|
||||
|
||||
# Redis
|
||||
REDIS_URL=redis://redis:6379/0
|
||||
|
||||
# ===== REMNAWAVE API =====
|
||||
REMNAWAVE_API_URL=https://panel.example.com
|
||||
REMNAWAVE_API_KEY=
|
||||
# Для панелей установленных скриптом eGames прописывать ключ в формате XXXXXXX:DDDDDDDD - https://panel.example.com/auth/login?XXXXXXX=DDDDDDDD
|
||||
REMNAWAVE_SECRET_KEY=your_secret_key_here
|
||||
REMNAWAVE_API_KEY=your_api_key_here
|
||||
|
||||
# Тип авторизации: "api_key", "basic_auth"
|
||||
REMNAWAVE_AUTH_TYPE=api_key
|
||||
|
||||
# Для панелей с Basic Auth (опционально)
|
||||
REMNAWAVE_USERNAME=
|
||||
REMNAWAVE_PASSWORD=
|
||||
|
||||
# Для панелей установленных скриптом eGames прописывать ключ в формате XXXXXXX:DDDDDDDD
|
||||
REMNAWAVE_SECRET_KEY=
|
||||
|
||||
# Шаблон описания пользователя в панели Remnawave
|
||||
# Доступные плейсхолдеры:
|
||||
# {full_name} — Имя, Фамилия из Telegram
|
||||
# {username} — @логин из Telegram (c @)
|
||||
# {username_clean} — логин из Telegram (без @)
|
||||
# {telegram_id} — ID Telegram
|
||||
REMNAWAVE_USER_DESCRIPTION_TEMPLATE="Bot user: {full_name} {username}"
|
||||
|
||||
# Шаблон имени пользователя в панели Remnawave
|
||||
# Доступные плейсхолдеры аналогичны описанию выше
|
||||
# {full_name} — Имя, Фамилия из Telegram
|
||||
# {username} — @логин из Telegram (c @)
|
||||
# {username_clean} — логин из Telegram (без @)
|
||||
# {telegram_id} — ID Telegram
|
||||
REMNAWAVE_USER_USERNAME_TEMPLATE="user_{telegram_id}"
|
||||
|
||||
# Режим удаления пользователей из панели RemnaWave
|
||||
# delete - полностью удалить пользователя из панели
|
||||
# disable - только деактивировать пользователя
|
||||
REMNAWAVE_USER_DELETE_MODE=delete
|
||||
|
||||
# ========= ПОДПИСКИ =========
|
||||
# ===== ТРИАЛ ПОДПИСКА =====
|
||||
TRIAL_DURATION_DAYS=3
|
||||
TRIAL_TRAFFIC_LIMIT_GB=10
|
||||
TRIAL_DEVICE_LIMIT=1
|
||||
TRIAL_SQUAD_UUID=
|
||||
TRIAL_PAYMENT_ENABLED=false
|
||||
TRIAL_ACTIVATION_PRICE=0
|
||||
|
||||
# ===== ПЛАТНАЯ ПОДПИСКА =====
|
||||
# Сколько устройств доступно по дефолту при покупке платной подписки
|
||||
@@ -48,8 +98,13 @@ MAX_DEVICES_LIMIT=15
|
||||
# Дефолт параметры для подписок выданных через админку
|
||||
DEFAULT_TRAFFIC_LIMIT_GB=100
|
||||
|
||||
# Плюсовать дни триала при покупке платной подписке
|
||||
TRIAL_ADD_REMAINING_DAYS_TO_PAID=false
|
||||
|
||||
# ===== ГЛОБАЛЬНЫЙ ПАРАМЕТР ДЛЯ ВСЕХ ПОДПИСОК =====
|
||||
DEFAULT_TRAFFIC_RESET_STRATEGY=MONTH
|
||||
# Сбрасывать трафик при каждой оплате подписки
|
||||
RESET_TRAFFIC_ON_PAYMENT=false
|
||||
|
||||
# ===== НАСТРОЙКИ ТРАФИКА =====
|
||||
# Режим выбора трафика:
|
||||
@@ -67,24 +122,46 @@ FIXED_TRAFFIC_LIMIT_GB=100
|
||||
AVAILABLE_SUBSCRIPTION_PERIODS=30,90,180
|
||||
AVAILABLE_RENEWAL_PERIODS=30,90,180
|
||||
|
||||
# ===== НАСТРОЙКИ ПРОСТОЙ ПОКУПКИ =====
|
||||
# Включить упрощённую покупку из меню
|
||||
SIMPLE_SUBSCRIPTION_ENABLED=false
|
||||
# Стандартный период (должен совпадать с одним из AVAILABLE_SUBSCRIPTION_PERIODS)
|
||||
SIMPLE_SUBSCRIPTION_PERIOD_DAYS=30
|
||||
# Сколько устройств выдаётся в рамках простой подписки
|
||||
SIMPLE_SUBSCRIPTION_DEVICE_LIMIT=1
|
||||
# Лимит трафика в ГБ (0 — безлимит)
|
||||
SIMPLE_SUBSCRIPTION_TRAFFIC_GB=0
|
||||
# UUID сквада (оставьте пустым, чтобы использовать сквады по умолчанию)
|
||||
# SIMPLE_SUBSCRIPTION_SQUAD_UUID=
|
||||
|
||||
# ===== ЦЕНЫ (в копейках) =====
|
||||
BASE_SUBSCRIPTION_PRICE=0
|
||||
|
||||
# Цены за периоды
|
||||
PRICE_14_DAYS=7000
|
||||
PRICE_30_DAYS=9900
|
||||
PRICE_30_DAYS=1000
|
||||
PRICE_60_DAYS=25900
|
||||
PRICE_90_DAYS=36900
|
||||
PRICE_180_DAYS=69900
|
||||
PRICE_360_DAYS=109900
|
||||
|
||||
# Скидка для Базовых Юзеров (Для других Промогрупп не работает!)
|
||||
BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED=false
|
||||
# 60:10 = скидка 10% на все доп услуги. 90:20 = 20% за 90 дней и тд
|
||||
BASE_PROMO_GROUP_PERIOD_DISCOUNTS=60:10,90:20,180:40,360:70
|
||||
|
||||
# Выводимые пакеты трафика и их цены в копейках
|
||||
TRAFFIC_PACKAGES_CONFIG="5:2000:false,10:3500:false,25:7000:false,50:11000:true,100:15000:true,250:17000:false,500:19000:false,1000:19500:true,0:20000:true"
|
||||
TRAFFIC_PACKAGES_CONFIG="5:2000:false,10:3500:false,25:7000:false,50:11000:true,100:15000:true,250:17000:false,500:19000:false,1000:19500:true,0:0:true"
|
||||
|
||||
# Цена за дополнительное устройство (DEFAULT_DEVICE_LIMIT идет бесплатно!)
|
||||
PRICE_PER_DEVICE=5000
|
||||
PRICE_PER_DEVICE=10000
|
||||
# Включить выбор количества устройств при покупке и продлении
|
||||
DEVICES_SELECTION_ENABLED=true
|
||||
# Единое количество устройств для режима без выбора (0 — не назначать устройства)
|
||||
DEVICES_SELECTION_DISABLED_AMOUNT=0
|
||||
|
||||
# ===== РЕФЕРАЛЬНАЯ СИСТЕМА =====
|
||||
REFERRAL_PROGRAM_ENABLED=true
|
||||
REFERRAL_MINIMUM_TOPUP_KOPEKS=10000
|
||||
REFERRAL_FIRST_TOPUP_BONUS_KOPEKS=10000
|
||||
REFERRAL_INVITER_BONUS_KOPEKS=10000
|
||||
@@ -96,21 +173,24 @@ REFERRAL_NOTIFICATION_RETRY_ATTEMPTS=3
|
||||
|
||||
# ===== АВТОПРОДЛЕНИЕ =====
|
||||
AUTOPAY_WARNING_DAYS=3,1
|
||||
DEFAULT_AUTOPAY_ENABLED=true
|
||||
DEFAULT_AUTOPAY_DAYS_BEFORE=3
|
||||
MIN_BALANCE_FOR_AUTOPAY_KOPEKS=10000
|
||||
|
||||
# ===== ПЛАТЕЖНЫЕ СИСТЕМЫ =====
|
||||
|
||||
# Telegram Stars (работает автоматически)
|
||||
TELEGRAM_STARS_ENABLED=true
|
||||
TELEGRAM_STARS_RATE_RUB=1.3
|
||||
TELEGRAM_STARS_ENABLED=false
|
||||
TELEGRAM_STARS_RATE_RUB=1.79
|
||||
|
||||
# Tribute (https://tribute.app)
|
||||
TRIBUTE_ENABLED=false
|
||||
TRIBUTE_API_KEY=
|
||||
TRIBUTE_WEBHOOK_SECRET=your_webhook_secret
|
||||
TRIBUTE_DONATE_LINK=
|
||||
TRIBUTE_WEBHOOK_PATH=/tribute-webhook
|
||||
TRIBUTE_WEBHOOK_HOST=0.0.0.0
|
||||
# Примечание: индивидуальные *_WEBHOOK_PORT используются только для старой схемы запуска.
|
||||
# В режиме единого FastAPI сервера оставьте эти значения по умолчанию.
|
||||
TRIBUTE_WEBHOOK_PORT=8081
|
||||
|
||||
# YooKassa (https://yookassa.ru)
|
||||
@@ -120,6 +200,9 @@ YOOKASSA_SECRET_KEY=
|
||||
YOOKASSA_RETURN_URL=
|
||||
YOOKASSA_DEFAULT_RECEIPT_EMAIL=receipts@yourdomain.com
|
||||
|
||||
# Включить оплату через СБП (Систему быстрых платежей)
|
||||
YOOKASSA_SBP_ENABLED=false
|
||||
|
||||
# Настройки чеков для налоговой
|
||||
YOOKASSA_VAT_CODE=1
|
||||
# Коды НДС:
|
||||
@@ -158,11 +241,26 @@ YOOKASSA_PAYMENT_SUBJECT=service
|
||||
|
||||
# Webhook настройки
|
||||
YOOKASSA_WEBHOOK_PATH=/yookassa-webhook
|
||||
YOOKASSA_WEBHOOK_HOST=0.0.0.0
|
||||
YOOKASSA_WEBHOOK_PORT=8082
|
||||
YOOKASSA_WEBHOOK_SECRET=your_webhook_secret
|
||||
|
||||
# Лимиты сумм пополнения через YooKassa (в копейках)
|
||||
YOOKASSA_MIN_AMOUNT_KOPEKS=5000
|
||||
YOOKASSA_MAX_AMOUNT_KOPEKS=1000000
|
||||
|
||||
# Быстрый выбор суммы пополнения через YooKassa
|
||||
YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED=true
|
||||
|
||||
# Отключить отображение кнопок выбора суммы пополнения (оставить только ввод вручную)
|
||||
DISABLE_TOPUP_BUTTONS=false
|
||||
|
||||
# Автоматическая проверка зависших пополнений и повторные обращения к провайдерам
|
||||
PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED=false
|
||||
# Интервал (в минутах) между автоматическими проверками пополнений
|
||||
PAYMENT_VERIFICATION_AUTO_CHECK_INTERVAL_MINUTES=10
|
||||
|
||||
# ===== НАСТРОЙКИ ОПИСАНИЙ ПЛАТЕЖЕЙ =====
|
||||
# Эти настройки позволяют изменить описания платежей,
|
||||
# Эти настройки позволяют изменить описания платежей,
|
||||
# чтобы избежать блокировок платежных систем
|
||||
PAYMENT_SERVICE_NAME=Интернет-сервис
|
||||
PAYMENT_BALANCE_DESCRIPTION=Пополнение баланса
|
||||
@@ -170,16 +268,126 @@ PAYMENT_SUBSCRIPTION_DESCRIPTION=Оплата подписки
|
||||
PAYMENT_BALANCE_TEMPLATE={service_name} - {description}
|
||||
PAYMENT_SUBSCRIPTION_TEMPLATE={service_name} - {description}
|
||||
|
||||
# CRYPTOBOT
|
||||
CRYPTOBOT_ENABLED=false
|
||||
CRYPTOBOT_API_TOKEN=123456789:AAzQcZWQqQAbsfgPnOLr4FHC8Doa4L7KryC
|
||||
CRYPTOBOT_WEBHOOK_SECRET=your_webhook_secret_here
|
||||
CRYPTOBOT_BASE_URL=https://pay.crypt.bot
|
||||
CRYPTOBOT_TESTNET=false
|
||||
CRYPTOBOT_WEBHOOK_PATH=/cryptobot-webhook
|
||||
# Эти порты используются только при ручном запуске старого aiohttp сервера.
|
||||
# В новой конфигурации все вебхуки обрабатываются через порт 8080.
|
||||
CRYPTOBOT_WEBHOOK_PORT=8081
|
||||
CRYPTOBOT_DEFAULT_ASSET=USDT
|
||||
CRYPTOBOT_ASSETS=USDT,TON,BTC,ETH,LTC,BNB,TRX,USDC
|
||||
CRYPTOBOT_INVOICE_EXPIRES_HOURS=24
|
||||
|
||||
# HELEKET
|
||||
HELEKET_ENABLED=false
|
||||
HELEKET_MERCHANT_ID=
|
||||
HELEKET_API_KEY=
|
||||
HELEKET_BASE_URL=https://api.heleket.com/v1
|
||||
HELEKET_DEFAULT_CURRENCY=USDT
|
||||
HELEKET_DEFAULT_NETWORK=
|
||||
HELEKET_INVOICE_LIFETIME=3600
|
||||
HELEKET_MARKUP_PERCENT=0
|
||||
HELEKET_WEBHOOK_PATH=/heleket-webhook
|
||||
HELEKET_WEBHOOK_HOST=0.0.0.0
|
||||
HELEKET_WEBHOOK_PORT=8086
|
||||
HELEKET_CALLBACK_URL=
|
||||
HELEKET_RETURN_URL=
|
||||
HELEKET_SUCCESS_URL=
|
||||
|
||||
# MULENPAY
|
||||
MULENPAY_ENABLED=false
|
||||
MULENPAY_API_KEY=
|
||||
MULENPAY_SECRET_KEY=
|
||||
MULENPAY_SHOP_ID=<ID магазина>
|
||||
# необязательно, есть дефолтные значения
|
||||
MULENPAY_BASE_URL=https://mulenpay.ru/api
|
||||
MULENPAY_WEBHOOK_PATH=/mulenpay-webhook
|
||||
MULENPAY_DESCRIPTION="Пополнение баланса"
|
||||
MULENPAY_LANGUAGE=ru
|
||||
MULENPAY_VAT_CODE=0
|
||||
MULENPAY_PAYMENT_SUBJECT=4
|
||||
MULENPAY_PAYMENT_MODE=4
|
||||
MULENPAY_MIN_AMOUNT_KOPEKS=10000
|
||||
MULENPAY_MAX_AMOUNT_KOPEKS=10000000
|
||||
|
||||
# PAYPALYCH / PAL24
|
||||
PAL24_ENABLED=false
|
||||
PAL24_API_TOKEN=
|
||||
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
|
||||
PAL24_REQUEST_TIMEOUT=30
|
||||
# Отображать кнопку СБП в PayPalych (true - отображать, false - скрывать)
|
||||
PAL24_SBP_BUTTON_VISIBLE=true
|
||||
# Отображать кнопку оплаты картой в PayPalych (true - отображать, false - скрывать)
|
||||
PAL24_CARD_BUTTON_VISIBLE=true
|
||||
|
||||
# PLATEGA
|
||||
PLATEGA_ENABLED=false
|
||||
PLATEGA_MERCHANT_ID=
|
||||
PLATEGA_SECRET=
|
||||
PLATEGA_BASE_URL=https://app.platega.io
|
||||
PLATEGA_RETURN_URL=
|
||||
PLATEGA_FAILED_URL=
|
||||
PLATEGA_CURRENCY=RUB
|
||||
# Список ID активных методов из кабинета Platega (через запятую)
|
||||
PLATEGA_ACTIVE_METHODS=2,10,11,12,13
|
||||
PLATEGA_MIN_AMOUNT_KOPEKS=10000
|
||||
PLATEGA_MAX_AMOUNT_KOPEKS=100000000
|
||||
PLATEGA_WEBHOOK_PATH=/platega-webhook
|
||||
PLATEGA_WEBHOOK_HOST=0.0.0.0
|
||||
PLATEGA_WEBHOOK_PORT=8086
|
||||
|
||||
# ===== ИНТЕРФЕЙС И UX =====
|
||||
|
||||
# Включить логотип для всех сообщений (true - с изображением, false - только текст)
|
||||
ENABLE_LOGO_MODE=true
|
||||
LOGO_FILE=vpn_logo.png
|
||||
|
||||
# Режим главного меню (default - классический режим работы бота, text - режим работы с активным ЛК MiniApp, отключает покупку/управление подпиской в меню, заменяет все кнопками открытия в MiniApp ЛК)
|
||||
MAIN_MENU_MODE=default
|
||||
|
||||
# Скрыть блок с ссылкой подключения в разделе с информацией о подписке
|
||||
HIDE_SUBSCRIPTION_LINK=false
|
||||
|
||||
# Режим работы кнопки "Подключиться"
|
||||
# guide - открывает гайд подключения (режим 1)
|
||||
# miniapp_subscription - открывает ссылку подписки в мини-приложении (режим 2)
|
||||
# miniapp_custom - открывает заданную ссылку в мини-приложении (режим 3)
|
||||
# link - Открывает ссылку напрямую в браузере (режим 4)
|
||||
# happ_cryptolink - Вывод cryptoLink ссылки на подписку Happ (режим 5)
|
||||
CONNECT_BUTTON_MODE=guide
|
||||
|
||||
# URL для режима miniapp_custom (обязателен при CONNECT_BUTTON_MODE=miniapp_custom)
|
||||
MINIAPP_CUSTOM_URL=
|
||||
MINIAPP_STATIC_PATH=miniapp
|
||||
MINIAPP_SERVICE_NAME_EN=Bedolaga VPN
|
||||
MINIAPP_SERVICE_NAME_RU=Bedolaga VPN
|
||||
MINIAPP_SERVICE_DESCRIPTION_EN=Secure & Fast Connection
|
||||
MINIAPP_SERVICE_DESCRIPTION_RU=Безопасное и быстрое подключение
|
||||
|
||||
# Параметры режима happ_cryptolink
|
||||
CONNECT_BUTTON_HAPP_DOWNLOAD_ENABLED=false
|
||||
HAPP_DOWNLOAD_LINK_IOS=
|
||||
HAPP_DOWNLOAD_LINK_ANDROID=
|
||||
HAPP_DOWNLOAD_LINK_MACOS=
|
||||
HAPP_DOWNLOAD_LINK_WINDOWS=
|
||||
# Кнопка (Подключится) с редиректом (тк ссылки с happ:// тг не поддерживает) - Без установленной ссылки на редирект кнопки (подключится) не будет! Пример: https://sub.domain.sub/redirect-page/?redirect_to=
|
||||
HAPP_CRYPTOLINK_REDIRECT_TEMPLATE=
|
||||
|
||||
# Пропустить принятие правил использования бота
|
||||
SKIP_RULES_ACCEPT=false
|
||||
# Пропустить запрос реферального кода
|
||||
SKIP_REFERRAL_CODE=false
|
||||
|
||||
# ===== МОНИТОРИНГ И УВЕДОМЛЕНИЯ =====
|
||||
MONITORING_INTERVAL=60
|
||||
@@ -192,15 +400,67 @@ NOTIFICATION_RETRY_ATTEMPTS=3
|
||||
MONITORING_LOGS_RETENTION_DAYS=30
|
||||
NOTIFICATION_CACHE_HOURS=24
|
||||
|
||||
# ===== СТАТУС СЕРВЕРОВ =====
|
||||
# Режимы: disabled, external_link, external_link_miniapp, xray
|
||||
SERVER_STATUS_MODE=disabled
|
||||
# Ссылка на внешний мониторинг (для режимов external_link и external_link_miniapp)
|
||||
SERVER_STATUS_EXTERNAL_URL=
|
||||
# URL метрик XrayChecker (для режима xray)
|
||||
SERVER_STATUS_METRICS_URL=
|
||||
# Данные Basic Auth (опционально)
|
||||
SERVER_STATUS_METRICS_USERNAME=
|
||||
SERVER_STATUS_METRICS_PASSWORD=
|
||||
# Проверять SSL сертификат при запросе метрик
|
||||
SERVER_STATUS_METRICS_VERIFY_SSL=true
|
||||
# Таймаут запроса к метрикам (в секундах)
|
||||
SERVER_STATUS_REQUEST_TIMEOUT=10
|
||||
# Количество серверов на странице в режиме интеграции
|
||||
SERVER_STATUS_ITEMS_PER_PAGE=10
|
||||
|
||||
# ===== РЕЖИМ ТЕХНИЧЕСКИХ РАБОТ =====
|
||||
MAINTENANCE_MODE=false
|
||||
MAINTENANCE_CHECK_INTERVAL=30
|
||||
MAINTENANCE_AUTO_ENABLE=true
|
||||
MAINTENANCE_MONITORING_ENABLED=true
|
||||
MAINTENANCE_RETRY_ATTEMPTS=1
|
||||
MAINTENANCE_MESSAGE=Ведутся технические работы. Сервис временно недоступен. Попробуйте позже.
|
||||
|
||||
# ===== ЛОКАЛИЗАЦИЯ =====
|
||||
# Укажите язык из AVAILABLE_LANGUAGES. При некорректном значении используется ru.
|
||||
DEFAULT_LANGUAGE=ru
|
||||
AVAILABLE_LANGUAGES=ru,en
|
||||
# Включить выбор языка при старте и отображение кнопки в меню
|
||||
LANGUAGE_SELECTION_ENABLED=true
|
||||
# Часовой пояс
|
||||
TZ=Europe/Moscow # или UTC, America/New_York и т.д.
|
||||
|
||||
# ===== ДОПОЛНИТЕЛЬНЫЕ НАСТРОЙКИ =====
|
||||
# Конфигурация приложений для гайда подключения
|
||||
APP_CONFIG_PATH=app-config.json
|
||||
ENABLE_DEEP_LINKS=true
|
||||
APP_CONFIG_CACHE_TTL=3600
|
||||
|
||||
# ===== СИСТЕМА БЕКАПОВ =====
|
||||
BACKUP_AUTO_ENABLED=true
|
||||
BACKUP_INTERVAL_HOURS=24
|
||||
BACKUP_TIME=03:00
|
||||
# Первый запуск автобекапа выполняется в ближайшее время BACKUP_TIME после старта бота,
|
||||
# далее копии создаются каждые BACKUP_INTERVAL_HOURS.
|
||||
BACKUP_MAX_KEEP=7
|
||||
BACKUP_COMPRESSION=true
|
||||
BACKUP_INCLUDE_LOGS=false
|
||||
BACKUP_LOCATION=/app/data/backups
|
||||
|
||||
# Отправка бэкапов в телеграм
|
||||
BACKUP_SEND_ENABLED=true
|
||||
BACKUP_SEND_CHAT_ID=-100123456789 # Замени на ID твоего канала (-100) - ПРЕФИКС ЗАКРЫТОГО КАНАЛА!
|
||||
# ВСТАВИТЬ СВОЙ ID СРАЗУ ПОСЛЕ (-100) БЕЗ ПРОБЕЛОВ!
|
||||
BACKUP_SEND_TOPIC_ID=123 # Опционально: ID топика
|
||||
|
||||
# ===== ПРОВЕРКА ОБНОВЛЕНИЙ БОТА =====
|
||||
VERSION_CHECK_ENABLED=true
|
||||
VERSION_CHECK_REPO=fr1ngg/remnawave-bedolaga-telegram-bot
|
||||
VERSION_CHECK_INTERVAL_HOURS=1
|
||||
|
||||
# ===== ЛОГИРОВАНИЕ =====
|
||||
LOG_LEVEL=INFO
|
||||
@@ -210,9 +470,20 @@ LOG_FILE=logs/bot.log
|
||||
DEBUG=false
|
||||
WEBHOOK_URL=
|
||||
WEBHOOK_PATH=/webhook
|
||||
WEBHOOK_SECRET_TOKEN=
|
||||
WEBHOOK_DROP_PENDING_UPDATES=true
|
||||
WEBHOOK_MAX_QUEUE_SIZE=1024
|
||||
WEBHOOK_WORKERS=4
|
||||
WEBHOOK_ENQUEUE_TIMEOUT=0.1
|
||||
WEBHOOK_WORKER_SHUTDOWN_TIMEOUT=30.0
|
||||
BOT_RUN_MODE=polling # polling, webhook или both
|
||||
|
||||
# ===== ДОПОЛНИТЕЛЬНЫЕ НАСТРОЙКИ =====
|
||||
# Конфигурация приложений для гайда подключения
|
||||
APP_CONFIG_PATH=app-config.json
|
||||
ENABLE_DEEP_LINKS=true
|
||||
APP_CONFIG_CACHE_TTL=3600
|
||||
# ===== ЕДИНЫЙ ВЕБ-СЕРВЕР =====
|
||||
WEB_API_ENABLED=false
|
||||
WEB_API_HOST=0.0.0.0
|
||||
WEB_API_PORT=8080
|
||||
WEB_API_ALLOWED_ORIGINS=*
|
||||
WEB_API_DOCS_ENABLED=false
|
||||
WEB_API_DEFAULT_TOKEN=
|
||||
WEB_API_DEFAULT_TOKEN_NAME=Bootstrap Token
|
||||
MINIAPP_STATIC_PATH=miniapp
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
---
|
||||
name: Bug Bedolage
|
||||
about: Создать баг-репорт «Бедолаге»
|
||||
title: "[BUG] Заголовок"
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
## Описание / Конфигурация
|
||||
|
||||
Кратко описываем текущую проблему.
|
||||
|
||||
Указываем настройки из `.env` конфига (если таковые имеются):
|
||||
|
||||
```yml
|
||||
|
||||
```
|
||||
|
||||
## Шаги для воспроизведения
|
||||
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
### Фактический результат
|
||||
|
||||
*
|
||||
*
|
||||
*
|
||||
|
||||
### Скрины поведения
|
||||
|
||||
Копируем изображение и вставляем прям сюда, если есть. Если нет — удаляем секцию.
|
||||
|
||||
## Ожидаемое поведение
|
||||
|
||||
Опишите, как должно работать.
|
||||
|
||||
## Дополнительная информация
|
||||
|
||||
Добавляем сюда дополнительную инфу, если есть. Если нет — удаляем секцию.
|
||||
@@ -0,0 +1,32 @@
|
||||
---
|
||||
name: Feat Bedolage
|
||||
about: Создать запрос на фичу «Бедолаге»
|
||||
title: "[FEAT] Заголовок"
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
## Описание / Конфигурация
|
||||
|
||||
Кратко описываем что хотим.
|
||||
|
||||
Указываем настройки из `.env` конфига (если таковые к месту):
|
||||
|
||||
```yml
|
||||
|
||||
```
|
||||
|
||||
## Текущее поведение
|
||||
|
||||
### Скрины поведения
|
||||
|
||||
Копируем изображение и вставляем прям сюда, если есть. Если нет — удаляем секцию.
|
||||
|
||||
## Желаемое поведение
|
||||
|
||||
Опишите, как хотелось бы, чтобы было.
|
||||
|
||||
## Дополнительная информация
|
||||
|
||||
Добавляем сюда дополнительную инфу, если есть. Если нет — удаляем секцию.
|
||||
@@ -0,0 +1,71 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "pip"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "monday"
|
||||
time: "09:00"
|
||||
timezone: "Europe/Moscow"
|
||||
open-pull-requests-limit: 10
|
||||
reviewers:
|
||||
- "Fr1ngg"
|
||||
assignees:
|
||||
- "Fr1ngg"
|
||||
commit-message:
|
||||
prefix: "deps"
|
||||
include: "scope"
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "python"
|
||||
allow:
|
||||
- dependency-type: "all"
|
||||
groups:
|
||||
python-dependencies:
|
||||
patterns:
|
||||
- "*"
|
||||
update-types:
|
||||
- "minor"
|
||||
- "patch"
|
||||
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "monday"
|
||||
time: "10:00"
|
||||
timezone: "Europe/Moscow"
|
||||
open-pull-requests-limit: 5
|
||||
reviewers:
|
||||
- "Fr1ngg"
|
||||
assignees:
|
||||
- "Fr1ngg"
|
||||
commit-message:
|
||||
prefix: "ci"
|
||||
include: "scope"
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "github-actions"
|
||||
groups:
|
||||
github-actions:
|
||||
patterns:
|
||||
- "*"
|
||||
|
||||
- package-ecosystem: "docker"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "tuesday"
|
||||
time: "09:00"
|
||||
timezone: "Europe/Moscow"
|
||||
open-pull-requests-limit: 3
|
||||
reviewers:
|
||||
- "Fr1ngg"
|
||||
assignees:
|
||||
- "Fr1ngg"
|
||||
commit-message:
|
||||
prefix: "docker"
|
||||
include: "scope"
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "docker"
|
||||
@@ -1,28 +1,111 @@
|
||||
name: BedolagaBot
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- main
|
||||
- dev
|
||||
tags:
|
||||
- 'v*'
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Get version info
|
||||
id: version
|
||||
run: |
|
||||
echo "short_sha=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
|
||||
|
||||
echo "build_date=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> $GITHUB_OUTPUT
|
||||
|
||||
# Определяем версию и теги
|
||||
if [[ $GITHUB_REF == refs/tags/* ]]; then
|
||||
VERSION=${GITHUB_REF#refs/tags/}
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||
echo "🏷️ Собираем релизную версию: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/main ]]; then
|
||||
VERSION="v2.6.2-$(git rev-parse --short HEAD)"
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||
echo "🚀 Собираем версию из main: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
|
||||
VERSION="v2.6.2-dev-$(git rev-parse --short HEAD)"
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:dev,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||
echo "🧪 Собираем dev версию: $VERSION"
|
||||
else
|
||||
VERSION="v2.6.2-pr-$(git rev-parse --short HEAD)"
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:pr-$(git rev-parse --short HEAD)"
|
||||
echo "🔀 Собираем PR версию: $VERSION"
|
||||
fi
|
||||
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "tags=$TAGS" >> $GITHUB_OUTPUT
|
||||
echo "should_push=${{ github.event_name != 'pull_request' }}" >> $GITHUB_OUTPUT
|
||||
|
||||
echo "=== Информация о сборке ==="
|
||||
echo "Версия: $VERSION"
|
||||
echo "Коммит: $(git rev-parse --short HEAD)"
|
||||
echo "Теги: $TAGS"
|
||||
echo "Push: ${{ github.event_name != 'pull_request' }}"
|
||||
echo "==========================="
|
||||
|
||||
- name: Login to Docker Hub
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: fr1ngg/remnawave-bedolaga-telegram-bot:latest
|
||||
push: ${{ steps.version.outputs.should_push }}
|
||||
tags: ${{ steps.version.outputs.tags }}
|
||||
build-args: |
|
||||
VERSION=${{ steps.version.outputs.version }}
|
||||
BUILD_DATE=${{ steps.version.outputs.build_date }}
|
||||
VCS_REF=${{ steps.version.outputs.short_sha }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
labels: |
|
||||
org.opencontainers.image.version=${{ steps.version.outputs.version }}
|
||||
org.opencontainers.image.created=${{ steps.version.outputs.build_date }}
|
||||
org.opencontainers.image.revision=${{ steps.version.outputs.short_sha }}
|
||||
org.opencontainers.image.title=Bedolaga RemnaWave Bot
|
||||
org.opencontainers.image.description=Telegram bot for RemnaWave VPN service
|
||||
org.opencontainers.image.source=https://github.com/${{ github.repository }}
|
||||
|
||||
- name: Build Summary
|
||||
if: github.event_name != 'pull_request'
|
||||
run: |
|
||||
echo "## 🚀 Docker Hub Build Summary" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Параметр | Значение |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "|----------|----------|" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| **Версия** | \`${{ steps.version.outputs.version }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| **Коммит** | \`${{ steps.version.outputs.short_sha }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| **Дата сборки** | \`${{ steps.version.outputs.build_date }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| **Registry** | Docker Hub |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| **Ветка** | \`${{ github.ref_name }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| **Статус** | ✅ Опубликован |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### 📋 Опубликованные теги:" >> $GITHUB_STEP_SUMMARY
|
||||
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "${{ steps.version.outputs.tags }}" | tr ',' '\n' >> $GITHUB_STEP_SUMMARY
|
||||
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### 🔗 Ссылки:" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- [Docker Hub Repository](https://hub.docker.com/r/fr1ngg/remnawave-bedolaga-telegram-bot)" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
name: Build and Publish Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
- dev
|
||||
tags:
|
||||
- 'v*'
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
- dev
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: fr1ngg/remnawave-bedolaga-telegram-bot
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -23,15 +24,51 @@ jobs:
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Container Registry
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Get version info
|
||||
id: version
|
||||
run: |
|
||||
echo "short_sha=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
|
||||
echo "build_date=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> $GITHUB_OUTPUT
|
||||
|
||||
if [[ $GITHUB_REF == refs/tags/* ]]; then
|
||||
VERSION=${GITHUB_REF#refs/tags/}
|
||||
echo "🏷️ Building release version: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/main ]]; then
|
||||
VERSION="v2.6.2-$(git rev-parse --short HEAD)"
|
||||
echo "🚀 Building main version: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
|
||||
VERSION="v2.6.2-dev-$(git rev-parse --short HEAD)"
|
||||
echo "🧪 Building dev version: $VERSION"
|
||||
else
|
||||
VERSION="v2.6.2-pr-$(git rev-parse --short HEAD)"
|
||||
echo "🔀 Building PR version: $VERSION"
|
||||
fi
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
# Определяем, нужно ли пушить образ
|
||||
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
|
||||
echo "should_push=false" >> $GITHUB_OUTPUT
|
||||
echo "⚠️ PR - only build without push"
|
||||
else
|
||||
echo "should_push=true" >> $GITHUB_OUTPUT
|
||||
echo "✅ Push/Tag - will push"
|
||||
fi
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
@@ -45,17 +82,24 @@ jobs:
|
||||
type=semver,pattern={{major}}
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=raw,value=dev,enable=${{ github.ref == 'refs/heads/dev' }}
|
||||
type=raw,value=${{ steps.version.outputs.version }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
push: ${{ steps.version.outputs.should_push }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
VERSION=${{ steps.version.outputs.version }}
|
||||
BUILD_DATE=${{ steps.version.outputs.build_date }}
|
||||
VCS_REF=${{ steps.version.outputs.short_sha }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Generate security report
|
||||
uses: docker/scout-action@v1
|
||||
if: github.event_name == 'pull_request'
|
||||
@@ -67,3 +111,31 @@ jobs:
|
||||
only-severities: critical,high
|
||||
write-comment: true
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
continue-on-error: true
|
||||
|
||||
- name: Build Summary
|
||||
if: steps.version.outputs.should_push == 'true'
|
||||
run: |
|
||||
echo "## 🚀 Docker Build Summary" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Parameter | Value |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "|----------|----------|" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| **Version** | \`${{ steps.version.outputs.version }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| **Commit** | \`${{ steps.version.outputs.short_sha }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| **Build Date** | \`${{ steps.version.outputs.build_date }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| **Registry** | \`${{ env.REGISTRY }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| **Image** | \`${{ env.IMAGE_NAME }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| **Branch** | \`${{ github.ref_name }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| **Status** | ✅ Published |" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
- name: Build Summary (No Push)
|
||||
if: steps.version.outputs.should_push == 'false'
|
||||
run: |
|
||||
echo "## 🔨 Docker Build Summary (Test Only)" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Parameter | Value |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "|----------|----------|" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| **Version** | \`${{ steps.version.outputs.version }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| **Commit** | \`${{ steps.version.outputs.short_sha }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| **Build Date** | \`${{ steps.version.outputs.build_date }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| **Status** | ✅ Built successfully (without publishing) |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "⚠️ **Note:** Image built but not published as this is a pull request." >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
# Игнорируем все файлы и папки по умолчанию
|
||||
*
|
||||
docker-compose.override.yml
|
||||
|
||||
# Исключения: разрешаем только нужные файлы
|
||||
!.dockerignore
|
||||
!.env.example
|
||||
!install_bot.sh
|
||||
!Dockerfile
|
||||
!app-config.json
|
||||
!main.py
|
||||
!requirements.txt
|
||||
!docs/
|
||||
!docs/**
|
||||
|
||||
# Разрешаем папку app/ и все её содержимое рекурсивно
|
||||
!app/
|
||||
!app/**
|
||||
!tests/
|
||||
!tests/**
|
||||
|
||||
# Дополнительно разрешаем README и лицензию (опционально)
|
||||
!README.md
|
||||
|
||||
+480
@@ -0,0 +1,480 @@
|
||||
# 🤝 Contributing to Remnawave Bedolaga Bot
|
||||
|
||||
Спасибо за интерес к развитию проекта! Этот документ содержит правила и рекомендации для контрибьюторов.
|
||||
|
||||
## 📋 Содержание
|
||||
|
||||
- [Кодекс поведения](#-кодекс-поведения)
|
||||
- [Как помочь проекту](#-как-помочь-проекту)
|
||||
- [Настройка среды разработки](#-настройка-среды-разработки)
|
||||
- [Стандарты кода](#-стандарты-кода)
|
||||
- [Работа с Git](#-работа-с-git)
|
||||
- [Тестирование](#-тестирование)
|
||||
- [Процесс ревью](#-процесс-ревью)
|
||||
- [Документация](#-документация)
|
||||
|
||||
## 📜 Кодекс поведения
|
||||
|
||||
### Основные принципы
|
||||
|
||||
- **Уважение** - относитесь к участникам проекта с уважением
|
||||
- **Конструктивность** - предлагайте решения, а не только критику
|
||||
- **Открытость** - будьте открыты к обратной связи и новым идеям
|
||||
- **Профессионализм** - поддерживайте высокий уровень обсуждений
|
||||
|
||||
### Недопустимое поведение
|
||||
|
||||
- Оскорбления и личные атаки
|
||||
- Спам и офф-топик сообщения
|
||||
- Публикация приватной информации
|
||||
- Любые формы дискриминации
|
||||
|
||||
## 🚀 Как помочь проекту
|
||||
|
||||
### 🐛 Сообщения о багах
|
||||
|
||||
Перед созданием issue проверьте:
|
||||
- [ ] Аналогичная проблема не была описана ранее
|
||||
- [ ] Вы используете актуальную версию бота
|
||||
- [ ] Проблема воспроизводится стабильно
|
||||
|
||||
**Шаблон для багрепорта:**
|
||||
|
||||
```markdown
|
||||
## 🐛 Описание бага
|
||||
Краткое описание проблемы
|
||||
|
||||
## 🔄 Шаги воспроизведения
|
||||
1. Перейти к '...'
|
||||
2. Нажать на '...'
|
||||
3. Увидеть ошибку
|
||||
|
||||
## ✅ Ожидаемое поведение
|
||||
Что должно было произойти
|
||||
|
||||
## ❌ Фактическое поведение
|
||||
Что произошло на самом деле
|
||||
|
||||
## 🌍 Окружение
|
||||
- Версия бота: [например, 1.0.0]
|
||||
- Python версия: [например, 3.11.7]
|
||||
- ОС: [например, Ubuntu 22.04]
|
||||
- Версия Docker: [если используется]
|
||||
|
||||
## 📋 Логи
|
||||
```
|
||||
Вставьте соответствующие логи
|
||||
```
|
||||
|
||||
## 📷 Скриншоты
|
||||
Если применимо, добавьте скриншоты
|
||||
```
|
||||
|
||||
### 💡 Предложения функций
|
||||
|
||||
Используйте лейбл `enhancement` и опишите:
|
||||
- **Проблему**, которую решает функция
|
||||
- **Предлагаемое решение**
|
||||
- **Альтернативы**, которые вы рассматривали
|
||||
- **Дополнительную информацию** и контекст
|
||||
|
||||
### 📝 Улучшение документации
|
||||
|
||||
- Исправление опечаток
|
||||
- Дополнение примеров
|
||||
- Перевод на другие языки
|
||||
- Улучшение структуры
|
||||
|
||||
## 🛠 Настройка среды разработки
|
||||
|
||||
### Требования
|
||||
|
||||
- Python 3.11+
|
||||
- Docker и Docker Compose
|
||||
- Git
|
||||
- PostgreSQL 15+ (опционально для локальной разработки)
|
||||
- Redis (опционально для локальной разработки)
|
||||
|
||||
### Установка
|
||||
|
||||
1. **Форкните и клонируйте репозиторий:**
|
||||
```bash
|
||||
git clone https://github.com/Fr1ngg/remnawave-bedolaga-telegram-bot.git
|
||||
cd remnawave-bedolaga-telegram-bot
|
||||
```
|
||||
|
||||
2. **Создайте виртуальное окружение:**
|
||||
```bash
|
||||
python -m venv venv
|
||||
source venv/bin/activate # Linux/Mac
|
||||
# или
|
||||
venv\Scripts\activate # Windows
|
||||
```
|
||||
|
||||
3. **Установите зависимости:**
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
pip install -r requirements-dev.txt # если есть dev зависимости
|
||||
```
|
||||
|
||||
4. **Настройте окружение:**
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Отредактируйте .env файл с вашими настройками
|
||||
```
|
||||
|
||||
5. **Запустите через Docker (рекомендуется):**
|
||||
```bash
|
||||
docker compose up -d postgres redis
|
||||
python main.py
|
||||
```
|
||||
|
||||
### Структура проекта
|
||||
|
||||
```
|
||||
bedolaga_bot/
|
||||
├── app/ # Основной код приложения
|
||||
│ ├── handlers/ # Обработчики сообщений
|
||||
│ ├── services/ # Бизнес-логика
|
||||
│ ├── database/ # Модели и CRUD операции
|
||||
│ ├── utils/ # Утилиты
|
||||
│ ├── middlewares/ # Middleware
|
||||
│ └── external/ # Внешние API
|
||||
├── migrations/ # Миграции БД (если нужны)
|
||||
├── tests/ # Тесты (создать при необходимости)
|
||||
├── docs/ # Документация
|
||||
└── requirements.txt # Зависимости
|
||||
```
|
||||
|
||||
## 🎨 Стандарты кода
|
||||
|
||||
### Python стиль
|
||||
|
||||
Мы следуем **PEP 8** с некоторыми исключениями:
|
||||
|
||||
```python
|
||||
# ✅ Хорошо
|
||||
async def get_user_subscription(user_id: int) -> Optional[Subscription]:
|
||||
"""Получает активную подписку пользователя."""
|
||||
async with get_session() as session:
|
||||
result = await session.execute(
|
||||
select(Subscription)
|
||||
.where(Subscription.user_id == user_id)
|
||||
.where(Subscription.is_active == True)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
# ❌ Плохо
|
||||
async def getUserSub(uid):
|
||||
session = get_session()
|
||||
sub = session.query(Subscription).filter(Subscription.user_id==uid,Subscription.is_active==True).first()
|
||||
return sub
|
||||
```
|
||||
|
||||
### Правила именования
|
||||
|
||||
- **Функции и переменные**: `snake_case`
|
||||
- **Классы**: `PascalCase`
|
||||
- **Константы**: `UPPER_CASE`
|
||||
- **Приватные методы**: `_leading_underscore`
|
||||
|
||||
### Типизация
|
||||
|
||||
Обязательно используйте type hints:
|
||||
|
||||
```python
|
||||
from typing import Optional, List, Dict, Any
|
||||
|
||||
async def create_subscription(
|
||||
user_id: int,
|
||||
duration_days: int,
|
||||
traffic_limit_gb: Optional[int] = None
|
||||
) -> Subscription:
|
||||
"""Создает новую подписку."""
|
||||
# implementation
|
||||
```
|
||||
|
||||
### Документация кода
|
||||
|
||||
```python
|
||||
async def calculate_subscription_price(
|
||||
period_days: int,
|
||||
traffic_gb: int,
|
||||
devices_count: int,
|
||||
servers_count: int
|
||||
) -> int:
|
||||
"""
|
||||
Рассчитывает стоимость подписки.
|
||||
|
||||
Args:
|
||||
period_days: Период подписки в днях
|
||||
traffic_gb: Лимит трафика в ГБ (0 = безлимит)
|
||||
devices_count: Количество устройств
|
||||
servers_count: Количество серверов
|
||||
|
||||
Returns:
|
||||
Стоимость в копейках
|
||||
|
||||
Raises:
|
||||
ValueError: Если переданы некорректные параметры
|
||||
"""
|
||||
# implementation
|
||||
```
|
||||
|
||||
### Обработка ошибок
|
||||
|
||||
```python
|
||||
# ✅ Хорошо
|
||||
try:
|
||||
subscription = await subscription_service.create_subscription(user_id, data)
|
||||
await message.answer("✅ Подписка создана успешно!")
|
||||
except RemnaWaveAPIError as e:
|
||||
logger.error(f"RemnaWave API error: {e}")
|
||||
await message.answer("❌ Ошибка при создании подписки. Попробуйте позже.")
|
||||
except ValidationError as e:
|
||||
logger.warning(f"Validation error: {e}")
|
||||
await message.answer("❌ Некорректные данные для создания подписки.")
|
||||
|
||||
# ❌ Плохо
|
||||
try:
|
||||
subscription = await subscription_service.create_subscription(user_id, data)
|
||||
await message.answer("✅ Подписка создана успешно!")
|
||||
except:
|
||||
await message.answer("Ошибка")
|
||||
```
|
||||
|
||||
### Логирование
|
||||
|
||||
```python
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Уровни логирования
|
||||
logger.debug("Детальная информация для отладки")
|
||||
logger.info("Общая информация о работе")
|
||||
logger.warning("Предупреждение о потенциальной проблеме")
|
||||
logger.error("Ошибка, которая не прерывает работу")
|
||||
logger.critical("Критическая ошибка")
|
||||
```
|
||||
|
||||
## 🔄 Работа с Git
|
||||
|
||||
### Ветки
|
||||
|
||||
- `main` - стабильная версия
|
||||
- `dev` - разработка новых функций
|
||||
|
||||
### Коммиты
|
||||
|
||||
Используйте [Conventional Commits](https://www.conventionalcommits.org/):
|
||||
|
||||
```bash
|
||||
# Типы коммитов
|
||||
feat: добавить поддержку CryptoBot платежей
|
||||
fix: исправить ошибку с расчетом цены подписки
|
||||
docs: обновить документацию по настройке
|
||||
style: исправить форматирование кода
|
||||
refactor: рефакторинг сервиса платежей
|
||||
test: добавить тесты для subscription_service
|
||||
chore: обновить зависимости
|
||||
|
||||
# Примеры
|
||||
git commit -m "feat(payments): добавить поддержку YooKassa webhook"
|
||||
git commit -m "fix(subscription): исправить расчет стоимости устройств"
|
||||
git commit -m "docs(readme): обновить инструкцию по установке"
|
||||
```
|
||||
|
||||
### Pull Request процесс
|
||||
|
||||
1. **Создайте ветку** от `dev`:
|
||||
```bash
|
||||
git checkout develop
|
||||
git pull origin develop
|
||||
git checkout -b feature/new-payment-method
|
||||
```
|
||||
|
||||
2. **Разработайте функцию** с соблюдением стандартов
|
||||
|
||||
3. **Протестируйте** изменения локально
|
||||
|
||||
4. **Создайте Pull Request** с описанием:
|
||||
|
||||
```markdown
|
||||
## 📝 Описание
|
||||
Краткое описание изменений
|
||||
|
||||
## 🎯 Мотивация
|
||||
Почему эти изменения нужны?
|
||||
|
||||
## 🔧 Тип изменений
|
||||
- [ ] Bug fix (исправление)
|
||||
- [ ] New feature (новая функция)
|
||||
- [ ] Breaking change (ломающие изменения)
|
||||
- [ ] Documentation update (обновление документации)
|
||||
|
||||
## ✅ Чеклист
|
||||
- [ ] Код соответствует стандартам проекта
|
||||
- [ ] Добавлены/обновлены тесты
|
||||
- [ ] Документация обновлена
|
||||
- [ ] Проверена работа в Docker
|
||||
- [ ] Проверена совместимость с существующим API
|
||||
|
||||
## 🧪 Тестирование
|
||||
Как тестировались изменения:
|
||||
- [ ] Локальное тестирование
|
||||
- [ ] Тестирование с реальным Remnawave API
|
||||
- [ ] Тестирование платежных систем
|
||||
```
|
||||
|
||||
## 🧪 Тестирование
|
||||
|
||||
### Локальное тестирование
|
||||
|
||||
```bash
|
||||
# Запуск с тестовой базой
|
||||
export DATABASE_URL="sqlite:///test.db"
|
||||
export BOT_TOKEN="test_token"
|
||||
python main.py
|
||||
```
|
||||
|
||||
### Тестирование компонентов
|
||||
|
||||
```python
|
||||
# tests/test_subscription_service.py
|
||||
import pytest
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_calculate_price():
|
||||
price = await SubscriptionService.calculate_subscription_price(
|
||||
period_days=30,
|
||||
traffic_gb=100,
|
||||
devices_count=3,
|
||||
servers_count=1
|
||||
)
|
||||
assert price > 0
|
||||
assert isinstance(price, int)
|
||||
```
|
||||
|
||||
### Integration тесты
|
||||
|
||||
Тестируйте интеграцию с:
|
||||
- Remnawave API (с тестовыми данными)
|
||||
- Базой данных
|
||||
- Платежными системами (sandbox режим)
|
||||
|
||||
## 👀 Процесс ревью
|
||||
|
||||
### Требования для ревьюера
|
||||
|
||||
- Проверить соответствие стандартам кода
|
||||
- Убедиться в работоспособности
|
||||
- Проверить безопасность (особенно для платежных функций)
|
||||
- Оценить производительность
|
||||
- Проверить совместимость с существующим API
|
||||
|
||||
### Требования для автора
|
||||
|
||||
- Ответить на все комментарии
|
||||
- Исправить замечания
|
||||
- Обновить документацию при необходимости
|
||||
- Убедиться в прохождении всех проверок
|
||||
|
||||
## 📚 Документация
|
||||
|
||||
### Обновление документации
|
||||
|
||||
При добавлении новых функций обновляйте:
|
||||
|
||||
- `README.md` - если изменился API или конфигурация
|
||||
- Комментарии в коде
|
||||
- Примеры использования
|
||||
- Changelog (если есть)
|
||||
|
||||
### Стиль документации
|
||||
|
||||
- Используйте ясный и понятный язык
|
||||
- Добавляйте примеры кода
|
||||
- Указывайте возможные ошибки и их решения
|
||||
- Используйте эмодзи для улучшения читаемости
|
||||
|
||||
## 🏷 Лейблы Issues и PR
|
||||
|
||||
### Приоритет
|
||||
- `priority:high` - высокий приоритет
|
||||
- `priority:medium` - средний приоритет
|
||||
- `priority:low` - низкий приоритет
|
||||
|
||||
### Тип
|
||||
- `bug` - ошибка
|
||||
- `enhancement` - улучшение
|
||||
- `feature` - новая функция
|
||||
- `documentation` - документация
|
||||
- `question` - вопрос
|
||||
|
||||
### Область
|
||||
- `payments` - платежные системы
|
||||
- `api` - Remnawave API
|
||||
- `database` - база данных
|
||||
- `ui/ux` - интерфейс пользователя
|
||||
- `admin` - админ панель
|
||||
|
||||
## 🔐 Безопасность
|
||||
|
||||
### Сообщения о уязвимостях
|
||||
|
||||
Для сообщений о критических уязвимостях безопасности:
|
||||
- Свяжитесь с [@fringg](https://t.me/fringg) напрямую
|
||||
- Не создавайте публичные issues для уязвимостей
|
||||
- Дайте время на исправление перед публичным раскрытием
|
||||
|
||||
### Рекомендации по безопасности
|
||||
|
||||
- Никогда не коммитьте API ключи и пароли
|
||||
- Используйте переменные окружения для чувствительных данных
|
||||
- Валидируйте все пользовательские данные
|
||||
- Используйте HTTPS для всех внешних запросов
|
||||
|
||||
## 📞 Получение помощи
|
||||
|
||||
### Каналы связи
|
||||
|
||||
- **💬 Telegram Group:** [Bedolaga Chat](https://t.me/+wTdMtSWq8YdmZmVi) - общие вопросы
|
||||
- **🐛 GitHub Issues:** Технические вопросы и баги
|
||||
- **📧 Прямой контакт:** [@fringg](https://t.me/fringg) - только критические вопросы
|
||||
|
||||
### Часто задаваемые вопросы
|
||||
|
||||
**Q: Как настроить локальную разработку без Docker?**
|
||||
A: Установите PostgreSQL и Redis локально, обновите DATABASE_URL и REDIS_URL в .env
|
||||
|
||||
**Q: Можно ли использовать SQLite для разработки?**
|
||||
A: Да, установите DATABASE_MODE=sqlite в .env
|
||||
|
||||
**Q: Как тестировать платежные системы?**
|
||||
A: Используйте тестовые/sandbox режимы платежных систем
|
||||
|
||||
**Q: Что делать, если тесты не проходят?**
|
||||
A: Проверьте конфигурацию .env и убедитесь, что все сервисы запущены
|
||||
|
||||
## 📋 Чеклист для контрибьюторов
|
||||
|
||||
Перед отправкой PR убедитесь:
|
||||
|
||||
- [ ] ✅ Код соответствует стандартам проекта
|
||||
- [ ] 📝 Добавлены комментарии и docstrings
|
||||
- [ ] 🧪 Функциональность протестирована
|
||||
- [ ] 📚 Документация обновлена
|
||||
- [ ] 🔒 Нет чувствительных данных в коде
|
||||
- [ ] 🐳 Изменения работают в Docker
|
||||
- [ ] 📋 PR создан с подробным описанием
|
||||
- [ ] 🏷 Проставлены соответствующие лейблы
|
||||
|
||||
## 🎉 Благодарности
|
||||
|
||||
Спасибо всем, кто вносит вклад в развитие проекта! Ваша помощь делает Bedolaga Bot лучше для всего сообщества.
|
||||
|
||||
---
|
||||
|
||||
**Made with ❤️ by community**
|
||||
+44
-25
@@ -1,43 +1,62 @@
|
||||
FROM python:3.11-slim
|
||||
WORKDIR /app
|
||||
FROM python:3.13-slim AS builder
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gcc \
|
||||
wget \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy requirements first for better caching
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
RUN python -m venv /opt/venv
|
||||
ENV PATH="/opt/venv/bin:$PATH"
|
||||
|
||||
COPY requirements.txt .
|
||||
|
||||
RUN pip install --no-cache-dir --upgrade pip && \
|
||||
pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
FROM python:3.13-slim
|
||||
|
||||
ARG VERSION="v2.6.2"
|
||||
ARG BUILD_DATE
|
||||
ARG VCS_REF
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
wget \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& apt-get clean
|
||||
|
||||
COPY --from=builder /opt/venv /opt/venv
|
||||
ENV PATH="/opt/venv/bin:$PATH"
|
||||
|
||||
# Create non-root user with specific UID/GID for consistency
|
||||
RUN groupadd -g 1000 app && \
|
||||
useradd -u 1000 -g 1000 -m -s /bin/bash app
|
||||
|
||||
# Copy application code
|
||||
COPY . .
|
||||
WORKDIR /app
|
||||
|
||||
COPY --chown=app:app . .
|
||||
|
||||
# Create necessary directories and set permissions
|
||||
RUN mkdir -p logs data && \
|
||||
chown -R app:app /app && \
|
||||
chmod -R 755 /app && \
|
||||
chmod -R 777 logs
|
||||
chown -R app:app /app logs data
|
||||
|
||||
# Switch to non-root user
|
||||
USER app
|
||||
|
||||
# Set environment variables
|
||||
ENV PYTHONPATH=/app
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV PYTHONPATH=/app \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
VERSION=${VERSION} \
|
||||
BUILD_DATE=${BUILD_DATE} \
|
||||
VCS_REF=${VCS_REF}
|
||||
|
||||
# Expose webhook ports
|
||||
EXPOSE 8081
|
||||
EXPOSE 8082
|
||||
EXPOSE 8080
|
||||
|
||||
LABEL org.opencontainers.image.title="Bedolaga RemnaWave Bot" \
|
||||
org.opencontainers.image.description="Telegram bot for RemnaWave VPN service" \
|
||||
org.opencontainers.image.version="${VERSION}" \
|
||||
org.opencontainers.image.created="${BUILD_DATE}" \
|
||||
org.opencontainers.image.revision="${VCS_REF}" \
|
||||
org.opencontainers.image.source="https://github.com/fr1ngg/remnawave-bedolaga-telegram-bot" \
|
||||
org.opencontainers.image.url="https://github.com/fr1ngg/remnawave-bedolaga-telegram-bot" \
|
||||
org.opencontainers.image.vendor="fr1ngg"
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:8081/health || exit 1
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1
|
||||
|
||||
# Run the application
|
||||
CMD ["python", "main.py"]
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
.PHONY: up
|
||||
up: ## Поднять контейнеры (detached)
|
||||
@echo "🚀 Поднимаем контейнеры (detached)..."
|
||||
docker compose up -d --build
|
||||
|
||||
.PHONY: up-follow
|
||||
up-follow: ## Поднять контейнеры с логами
|
||||
@echo "📡 Поднимаем контейнеры (в консоли)..."
|
||||
docker compose up --build
|
||||
|
||||
.PHONY: down
|
||||
down: ## Остановить и удалить контейнеры
|
||||
@echo "🛑 Останавливаем и удаляем контейнеры..."
|
||||
docker compose down
|
||||
|
||||
.PHONY: reload
|
||||
reload: ## Перезапустить контейнеры (detached)
|
||||
@$(MAKE) down
|
||||
@$(MAKE) up
|
||||
|
||||
.PHONY: reload-follow
|
||||
reload-follow: ## Перезапустить контейнеры с логами
|
||||
@$(MAKE) down
|
||||
@$(MAKE) up-follow
|
||||
|
||||
.PHONY: test
|
||||
test: ## Запустить тесты
|
||||
@echo "🧪 Запускаем тесты..."
|
||||
pytest -v
|
||||
|
||||
.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}'
|
||||
@echo ""
|
||||
+382
@@ -0,0 +1,382 @@
|
||||
# 🔐 Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
Мы обеспечиваем поддержку безопасности для следующих версий:
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | ------------------ |
|
||||
| 2.x.x | ✅ Yes |
|
||||
| 1.x.x | ❌ No |
|
||||
| < 1.0 | ❌ No |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
### 🚨 Критические уязвимости
|
||||
|
||||
Для сообщений о критических уязвимостях безопасности:
|
||||
|
||||
**НЕ создавайте публичные Issues или Pull Requests**
|
||||
|
||||
Вместо этого:
|
||||
1. Свяжитесь с [@fringg](https://t.me/fringg) напрямую в Telegram
|
||||
2. Укажите в сообщении "SECURITY VULNERABILITY" в начале
|
||||
3. Опишите уязвимость детально
|
||||
4. Дайте время на исправление (обычно 90 дней)
|
||||
|
||||
### 📧 Контактная информация
|
||||
|
||||
- **Telegram**: [@fringg](https://t.me/fringg)
|
||||
- **Response Time**: 24-48 часов
|
||||
- **Fix Timeline**: 7-30 дней (в зависимости от критичности)
|
||||
|
||||
### 🔍 Что считается уязвимостью безопасности
|
||||
|
||||
**Критические уязвимости:**
|
||||
- SQL инъекции
|
||||
- Remote Code Execution (RCE)
|
||||
- Обход аутентификации
|
||||
- Несанкционированный доступ к данным пользователей
|
||||
- Утечка API ключей или токенов
|
||||
- Bypass платежных проверок
|
||||
|
||||
**Средние уязвимости:**
|
||||
- Cross-Site Scripting (XSS) в веб-компонентах
|
||||
- Небезопасная десериализация
|
||||
- Недостаточная валидация входных данных
|
||||
- Rate limiting bypass
|
||||
|
||||
**Низкие уязвимости:**
|
||||
- Information disclosure
|
||||
- DoS атаки
|
||||
- Проблемы с логированием чувствительных данных
|
||||
|
||||
### 📝 Информация для включения в отчет
|
||||
|
||||
1. **Описание уязвимости**
|
||||
- Тип уязвимости
|
||||
- Затронутые компоненты
|
||||
- Потенциальное воздействие
|
||||
|
||||
2. **Шаги воспроизведения**
|
||||
- Детальные инструкции
|
||||
- Скриншоты или видео (если применимо)
|
||||
- Тестовые данные
|
||||
|
||||
3. **Proof of Concept**
|
||||
- Работающий эксплойт (если безопасно)
|
||||
- Логи или вывод команд
|
||||
|
||||
4. **Предлагаемые исправления**
|
||||
- Возможные решения
|
||||
- Альтернативные подходы
|
||||
|
||||
## Security Measures
|
||||
|
||||
### 🛡️ Текущие меры защиты
|
||||
|
||||
**Аутентификация и авторизация:**
|
||||
- Проверка админ ID через ADMIN_IDS
|
||||
- Валидация всех пользовательских данных
|
||||
- Защита от несанкционированного доступа к админ функциям
|
||||
|
||||
**Защита API:**
|
||||
- Поддержка различных методов аутентификации Remnawave
|
||||
- Безопасная передача куки для защищенных панелей
|
||||
- Rate limiting для предотвращения злоупотреблений
|
||||
- Timeout для внешних запросов
|
||||
|
||||
**Защита данных:**
|
||||
- Хеширование чувствительной информации
|
||||
- Безопасное хранение API ключей
|
||||
- Логирование без записи чувствительных данных
|
||||
- Регулярные бекапы с возможностью восстановления
|
||||
|
||||
**Платежная безопасность:**
|
||||
- Валидация webhook подписей
|
||||
- Проверка сумм платежей
|
||||
- Защита от duplicate payments
|
||||
- Безопасная обработка платежных данных
|
||||
|
||||
**Инфраструктурная безопасность:**
|
||||
- Docker контейнеризация
|
||||
- Изоляция сетевого трафика
|
||||
- Минимальные права доступа
|
||||
- Регулярные обновления зависимостей
|
||||
|
||||
### 🔒 Конфиденциальные данные
|
||||
|
||||
**Что НЕ логируется:**
|
||||
- API ключи и токены
|
||||
- Пароли и секретные ключи
|
||||
- Персональные данные пользователей
|
||||
- Детали платежных транзакций
|
||||
- Webhook секреты
|
||||
|
||||
**Защита в коде:**
|
||||
```python
|
||||
# ✅ Правильно
|
||||
logger.info(f"User {user_id} created subscription")
|
||||
logger.debug(f"Payment webhook received for order {order_id}")
|
||||
|
||||
# ❌ Неправильно
|
||||
logger.info(f"API key: {api_key}")
|
||||
logger.debug(f"Webhook payload: {webhook_data}")
|
||||
```
|
||||
|
||||
### 🌐 Сетевая безопасность
|
||||
|
||||
**HTTPS обязателен:**
|
||||
- Все внешние API вызовы через HTTPS
|
||||
- Webhook endpoints защищены TLS
|
||||
- Проверка SSL сертификатов
|
||||
|
||||
**Firewall конфигурация:**
|
||||
```bash
|
||||
# Открыть только необходимые порты
|
||||
ufw allow 80/tcp # HTTP (redirect to HTTPS)
|
||||
ufw allow 443/tcp # HTTPS
|
||||
ufw deny 8080/tcp # Unified FastAPI сервер доступен только из внутренней сети
|
||||
```
|
||||
|
||||
### 📊 Мониторинг безопасности
|
||||
|
||||
**Автоматические проверки:**
|
||||
- Мониторинг неудачных попыток аутентификации
|
||||
- Отслеживание подозрительной активности
|
||||
- Проверка целостности платежей
|
||||
- Мониторинг состояния внешних сервисов
|
||||
|
||||
**Алерты:**
|
||||
- Множественные неудачные попытки входа
|
||||
- Неожиданные изменения в конфигурации
|
||||
- Критические ошибки в платежных системах
|
||||
- Недоступность Remnawave API
|
||||
|
||||
## Best Practices for Developers
|
||||
|
||||
### 🔐 Безопасное программирование
|
||||
|
||||
**Input Validation:**
|
||||
```python
|
||||
# ✅ Правильно
|
||||
def validate_user_id(user_id: str) -> int:
|
||||
if not user_id.isdigit():
|
||||
raise ValueError("Invalid user ID format")
|
||||
uid = int(user_id)
|
||||
if uid <= 0:
|
||||
raise ValueError("User ID must be positive")
|
||||
return uid
|
||||
|
||||
# ❌ Неправильно
|
||||
def get_user(user_id):
|
||||
return session.execute(f"SELECT * FROM users WHERE id = {user_id}")
|
||||
```
|
||||
|
||||
**SQL Injection Prevention:**
|
||||
```python
|
||||
# ✅ Правильно - используйте SQLAlchemy ORM
|
||||
async def get_user_subscriptions(user_id: int):
|
||||
result = await session.execute(
|
||||
select(Subscription).where(Subscription.user_id == user_id)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
# ❌ Неправильно - raw SQL без параметров
|
||||
async def get_user_subscriptions(user_id: int):
|
||||
result = await session.execute(
|
||||
f"SELECT * FROM subscriptions WHERE user_id = {user_id}"
|
||||
)
|
||||
```
|
||||
|
||||
**API Keys Management:**
|
||||
```python
|
||||
# ✅ Правильно
|
||||
class Config:
|
||||
REMNAWAVE_API_KEY: str = os.getenv("REMNAWAVE_API_KEY", "")
|
||||
|
||||
def validate_required_env(self):
|
||||
if not self.REMNAWAVE_API_KEY:
|
||||
raise ValueError("REMNAWAVE_API_KEY is required")
|
||||
|
||||
# ❌ Неправильно
|
||||
API_KEY = "your_api_key_here" # Никогда не хардкодьте ключи
|
||||
```
|
||||
|
||||
**Error Handling:**
|
||||
```python
|
||||
# ✅ Правильно - не раскрывайте внутреннюю информацию
|
||||
try:
|
||||
result = await remnawave_api.create_subscription(data)
|
||||
except RemnaWaveAPIError as e:
|
||||
logger.error(f"RemnaWave API error: {e}")
|
||||
await message.answer("Временные проблемы с сервисом. Попробуйте позже.")
|
||||
|
||||
# ❌ Неправильно - раскрытие внутренних деталей
|
||||
except Exception as e:
|
||||
await message.answer(f"Error: {str(e)}")
|
||||
```
|
||||
|
||||
### 🔍 Code Review Security Checklist
|
||||
|
||||
- [ ] Нет хардкода API ключей или паролей
|
||||
- [ ] Все пользовательские данные валидируются
|
||||
- [ ] SQL запросы используют параметризированные запросы
|
||||
- [ ] Ошибки логируются без раскрытия чувствительной информации
|
||||
- [ ] Внешние запросы используют таймауты
|
||||
- [ ] Webhook подписи проверяются
|
||||
- [ ] Rate limiting применяется где необходимо
|
||||
- [ ] Авторизация проверяется для защищенных операций
|
||||
|
||||
### 📋 Environment Variables Security
|
||||
|
||||
**Обязательные переменные:**
|
||||
```env
|
||||
# Критически важные - должны быть установлены
|
||||
BOT_TOKEN=your_bot_token
|
||||
REMNAWAVE_API_KEY=your_api_key
|
||||
ADMIN_IDS=123456789,987654321
|
||||
|
||||
# Webhook секреты - генерируйте случайные значения
|
||||
TRIBUTE_WEBHOOK_SECRET=another_random_secret
|
||||
CRYPTOBOT_WEBHOOK_SECRET=yet_another_secret
|
||||
```
|
||||
|
||||
**Генерация безопасных секретов:**
|
||||
```bash
|
||||
# Генерация случайного секрета
|
||||
openssl rand -hex 32
|
||||
|
||||
# Или используйте Python
|
||||
python -c "import secrets; print(secrets.token_hex(32))"
|
||||
```
|
||||
|
||||
### 🐳 Docker Security
|
||||
|
||||
**Dockerfile best practices:**
|
||||
```dockerfile
|
||||
# Не запускайтесь под root
|
||||
RUN groupadd -r appuser && useradd -r -g appuser appuser
|
||||
USER appuser
|
||||
|
||||
# Минимальные права на файлы
|
||||
COPY --chown=appuser:appuser . /app
|
||||
|
||||
# Удаляйте ненужные пакеты
|
||||
RUN apt-get remove --purge -y build-essential && \
|
||||
apt-get autoremove -y && \
|
||||
apt-get clean
|
||||
```
|
||||
|
||||
**docker-compose.yml security:**
|
||||
```yaml
|
||||
services:
|
||||
bot:
|
||||
# Ограничьте ресурсы
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 512M
|
||||
cpus: '0.5'
|
||||
|
||||
# Не пробрасывайте порты наружу без необходимости
|
||||
ports:
|
||||
- "127.0.0.1:8080:8080" # Только localhost
|
||||
|
||||
# Read-only root filesystem
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp
|
||||
```
|
||||
|
||||
## Security Updates
|
||||
|
||||
### 📅 График обновлений
|
||||
|
||||
- **Критические уязвимости**: Исправляются в течение 24-48 часов
|
||||
- **Высокий приоритет**: Исправляются в течение 7 дней
|
||||
- **Средний приоритет**: Исправляются в течение 30 дней
|
||||
- **Низкий приоритет**: Исправляются в следующем планируемом релизе
|
||||
|
||||
### 🔄 Процесс обновления
|
||||
|
||||
1. **Уведомление**: Администраторы получают уведомление о доступных обновлениях
|
||||
2. **Тестирование**: Обновления тестируются в изолированной среде
|
||||
3. **Развертывание**: Постепенное развертывание с возможностью отката
|
||||
4. **Мониторинг**: Наблюдение за стабильностью после обновления
|
||||
|
||||
### 📢 Уведомления о безопасности
|
||||
|
||||
Подпишитесь на уведомления:
|
||||
- **Telegram**: [Bedolaga Chat](https://t.me/+wTdMtSWq8YdmZmVi)
|
||||
- **GitHub**: Watch repository для получения уведомлений о релизах
|
||||
- **RSS**: Следите за GitHub Releases
|
||||
|
||||
## Compliance and Standards
|
||||
|
||||
### 📜 Соответствие стандартам
|
||||
|
||||
**GDPR Compliance:**
|
||||
- Минимизация сбора данных
|
||||
- Право на удаление данных
|
||||
- Уведомление о нарушениях безопасности
|
||||
- Согласие на обработку данных
|
||||
|
||||
**PCI DSS (для платежей):**
|
||||
- Не храним данные банковских карт
|
||||
- Используем сертифицированные платежные провайдеры
|
||||
- Шифрование чувствительных данных
|
||||
- Регулярные аудиты безопасности
|
||||
|
||||
### 🛡️ Third-party Security
|
||||
|
||||
**Используемые сервисы:**
|
||||
- **Telegram API**: Официальный API с высоким уровнем безопасности
|
||||
- **Remnawave**: VPN панель с собственными мерами безопасности
|
||||
- **YooKassa**: PCI DSS сертифицированный платежный провайдер
|
||||
- **CryptoBot**: Криптовалютные платежи с блокчейн безопасностью
|
||||
|
||||
**Регулярные проверки:**
|
||||
- Мониторинг безопасности dependencies
|
||||
- Обновление библиотек и зависимостей
|
||||
- Проверка CVE для используемых компонентов
|
||||
|
||||
## Incident Response
|
||||
|
||||
### 🚨 В случае инцидента безопасности
|
||||
|
||||
1. **Немедленно**:
|
||||
- Изолируйте затронутую систему
|
||||
- Сохраните логи и доказательства
|
||||
- Уведомите команду разработки
|
||||
|
||||
2. **В течение часа**:
|
||||
- Оцените масштаб инцидента
|
||||
- Определите затронутых пользователей
|
||||
- Начните процедуры восстановления
|
||||
|
||||
3. **В течение дня**:
|
||||
- Устраните уязвимость
|
||||
- Восстановите нормальную работу
|
||||
- Уведомите пользователей (если необходимо)
|
||||
|
||||
4. **После инцидента**:
|
||||
- Проведите post-mortem анализ
|
||||
- Обновите процедуры безопасности
|
||||
- Внедрите дополнительные защитные меры
|
||||
|
||||
### 📊 Отчетность
|
||||
|
||||
**Внутренние отчеты:**
|
||||
- Еженедельные отчеты о безопасности
|
||||
- Ежемесячные аудиты логов
|
||||
- Квартальные обзоры политик безопасности
|
||||
|
||||
**Внешние уведомления:**
|
||||
- Уведомление пользователей о критических обновлениях
|
||||
- Публикация Security Advisories при необходимости
|
||||
- Сотрудничество с исследователями безопасности
|
||||
|
||||
---
|
||||
|
||||
**Помните**: Безопасность - это постоянный процесс, а не одноразовая настройка. Регулярно обновляйте зависимости, следите за новостями безопасности и не стесняйтесь сообщать о подозрительной активности.
|
||||
+636
-141
@@ -1,163 +1,658 @@
|
||||
{
|
||||
"ios": [
|
||||
{
|
||||
"id": "happ",
|
||||
"name": "Happ",
|
||||
"isFeatured": true,
|
||||
"urlScheme": "happ://add/",
|
||||
"installationStep": {
|
||||
"buttons": [
|
||||
{
|
||||
"buttonLink": "https://apps.apple.com/us/app/happ-proxy-utility/id6504287215",
|
||||
"buttonText": {
|
||||
"en": "App Store [EU]",
|
||||
"ru": "App Store [EU]"
|
||||
"config": {
|
||||
"additionalLocales": [
|
||||
"ru",
|
||||
"zh",
|
||||
"fa"
|
||||
],
|
||||
"branding": {
|
||||
"name": "Subscription",
|
||||
"logoUrl": "https://raw.githubusercontent.com/Fr1ngg/remnawave-bedolaga-telegram-bot/bf0c1ce711a26fa2f24559e7e4443820e68d758b/assets/bedolaga_app3.svg",
|
||||
"supportUrl": "https://t.me"
|
||||
}
|
||||
},
|
||||
"platforms": {
|
||||
"ios": [
|
||||
{
|
||||
"id": "happ",
|
||||
"name": "Happ",
|
||||
"isFeatured": true,
|
||||
"urlScheme": "happ://add/",
|
||||
"installationStep": {
|
||||
"buttons": [
|
||||
{
|
||||
"buttonLink": "https://apps.apple.com/us/app/happ-proxy-utility/id6504287215",
|
||||
"buttonText": {
|
||||
"en": "Open in App Store [EU]",
|
||||
"fa": "باز کردن در App Store [EU]",
|
||||
"ru": "Открыть в App Store [EU]",
|
||||
"zh": "在 App Store 中打开 [EU]"
|
||||
}
|
||||
},
|
||||
{
|
||||
"buttonLink": "https://apps.apple.com/ru/app/happ-proxy-utility-plus/id6746188973",
|
||||
"buttonText": {
|
||||
"en": "Open in App Store [RU]",
|
||||
"fa": "باز کردن در App Store [RU]",
|
||||
"ru": "Открыть в App Store [RU]",
|
||||
"zh": "在 App Store 中打开 [RU]"
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": {
|
||||
"en": "Open the page in App Store and install the app. Launch it, in the VPN configuration permission window click Allow and enter your passcode.",
|
||||
"fa": "صفحه را در App Store باز کنید و برنامه را نصب کنید. آن را اجرا کنید، در پنجره مجوز پیکربندی VPN روی Allow کلیک کنید و رمز عبور خود را وارد کنید.",
|
||||
"ru": "Откройте страницу в App Store и установите приложение. Запустите его, в окне разрешения VPN-конфигурации нажмите Allow и введите свой пароль.",
|
||||
"zh": "在 App Store 中打开页面并安装应用。启动应用后,在 VPN 配置权限窗口中点击\"允许\"并输入您的密码。"
|
||||
}
|
||||
},
|
||||
"addSubscriptionStep": {
|
||||
"description": {
|
||||
"en": "Click the button below — the app will open and the subscription will be added automatically",
|
||||
"fa": "برای افزودن خودکار اشتراک روی دکمه زیر کلیک کنید - برنامه باز خواهد شد",
|
||||
"ru": "Нажмите кнопку ниже — приложение откроется, и подписка добавится автоматически.",
|
||||
"zh": "点击下方按钮 — 应用将打开并自动添加订阅"
|
||||
}
|
||||
},
|
||||
"connectAndUseStep": {
|
||||
"description": {
|
||||
"en": "In the main section, click the large power button in the center to connect to VPN. Don't forget to select a server from the server list. If needed, choose another server from the server list.",
|
||||
"fa": "در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. فراموش نکنید که یک سرور را از لیست سرورها انتخاب کنید. در صورت نیاز، سرور دیگری را از لیست سرورها انتخاب کنید.",
|
||||
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. Не забудьте выбрать сервер в списке серверов. При необходимости выберите другой сервер из списка серверов.",
|
||||
"zh": "在主界面中,点击中央的大电源按钮连接到 VPN。别忘了从服务器列表中选择一个服务器。如有需要,可从服务器列表中选择其他服务器。"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "streisand",
|
||||
"name": "Streisand",
|
||||
"isFeatured": false,
|
||||
"urlScheme": "streisand://import/",
|
||||
"installationStep": {
|
||||
"buttons": [
|
||||
{
|
||||
"buttonLink": "https://apps.apple.com/ru/app/streisand/id6450534064",
|
||||
"buttonText": {
|
||||
"en": "Open in App Store",
|
||||
"fa": "باز کردن در App Store",
|
||||
"ru": "Открыть в App Store",
|
||||
"zh": "在 App Store 中打开"
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": {
|
||||
"en": "Open the page in App Store and install the app. Launch it, in the VPN configuration permission window click Allow and enter your passcode.",
|
||||
"fa": "صفحه را در App Store باز کنید و برنامه را نصب کنید. آن را اجرا کنید، در پنجره مجوز پیکربندی VPN روی Allow کلیک کنید و رمز عبور خود را وارد کنید.",
|
||||
"ru": "Откройте страницу в App Store и установите приложение. Запустите его, в окне разрешения VPN-конфигурации нажмите Allow и введите свой пароль.",
|
||||
"zh": "在 App Store 中打开页面并安装应用。启动应用后,在 VPN 配置权限窗口中点击\"允许\"并输入您的密码。"
|
||||
}
|
||||
},
|
||||
"addSubscriptionStep": {
|
||||
"description": {
|
||||
"en": "Click the button below — the app will open and the subscription will be added automatically",
|
||||
"fa": "برای افزودن خودکار اشتراک روی دکمه زیر کلیک کنید - برنامه باز خواهد شد",
|
||||
"ru": "Нажмите кнопку ниже — приложение откроется, и подписка добавится автоматически.",
|
||||
"zh": "点击下方按钮 — 应用将打开并自动添加订阅"
|
||||
}
|
||||
},
|
||||
"connectAndUseStep": {
|
||||
"description": {
|
||||
"en": "In the main section, click the large power button in the center to connect to VPN. Don't forget to select a server from the server list. If needed, choose another server from the server list.",
|
||||
"fa": "در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. فراموش نکنید که یک سرور را از لیست سرورها انتخاب کنید. در صورت نیاز، سرور دیگری را از لیست سرورها انتخاب کنید.",
|
||||
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. Не забудьте выбрать сервер в списке серверов. При необходимости выберите другой сервер из списка серверов.",
|
||||
"zh": "在主界面中,点击中央的大电源按钮连接到 VPN。别忘了从服务器列表中选择一个服务器。如有需要,可从服务器列表中选择其他服务器。"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "shadowrocket",
|
||||
"name": "Shadowrocket",
|
||||
"isFeatured": false,
|
||||
"urlScheme": "sub://",
|
||||
"isNeedBase64Encoding": true,
|
||||
"installationStep": {
|
||||
"buttons": [
|
||||
{
|
||||
"buttonLink": "https://apps.apple.com/ru/app/shadowrocket/id932747118",
|
||||
"buttonText": {
|
||||
"en": "Open in App Store",
|
||||
"fa": "باز کردن در App Store",
|
||||
"ru": "Открыть в App Store",
|
||||
"zh": "在 App Store 中打开"
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": {
|
||||
"en": "Open the page in App Store and install the app. Launch it, in the VPN configuration permission window click Allow and enter your passcode.",
|
||||
"fa": "صفحه را در App Store باز کنید و برنامه را نصب کنید. آن را اجرا کنید، در پنجره مجوز پیکربندی VPN روی Allow کلیک کنید و رمز عبور خود را وارد کنید.",
|
||||
"ru": "Откройте страницу в App Store и установите приложение. Запустите его, в окне разрешения VPN-конфигурации нажмите Allow и введите свой пароль.",
|
||||
"zh": "在 App Store 中打开页面并安装应用。启动应用后,在 VPN 配置权限窗口中点击\"允许\"并输入您的密码。"
|
||||
}
|
||||
},
|
||||
"addSubscriptionStep": {
|
||||
"description": {
|
||||
"en": "Click the button below — the app will open and the subscription will be added automatically",
|
||||
"fa": "برای افزودن خودکار اشتراک روی دکمه زیر کلیک کنید - برنامه باز خواهد شد",
|
||||
"ru": "Нажмите кнопку ниже — приложение откроется, и подписка добавится автоматически.",
|
||||
"zh": "点击下方按钮 — 应用将打开并自动添加订阅"
|
||||
}
|
||||
},
|
||||
"connectAndUseStep": {
|
||||
"description": {
|
||||
"en": "In the main section, click the large power button in the center to connect to VPN. Don't forget to select a server from the server list. If needed, choose another server from the server list.",
|
||||
"fa": "در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. فراموش نکنید که یک سرور را از لیست سرورها انتخاب کنید. در صورت نیاز، سرور دیگری را از لیست سرورها انتخاب کنید.",
|
||||
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. Не забудьте выбрать сервер в списке серверов. При необходимости выберите другой сервер из списка серверов.",
|
||||
"zh": "在主界面中,点击中央的大电源按钮连接到 VPN。别忘了从服务器列表中选择一个服务器。如有需要,可从服务器列表中选择其他服务器。"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"android": [
|
||||
{
|
||||
"id": "happ",
|
||||
"name": "Happ",
|
||||
"isFeatured": true,
|
||||
"urlScheme": "happ://add/",
|
||||
"installationStep": {
|
||||
"buttons": [
|
||||
{
|
||||
"buttonLink": "https://play.google.com/store/apps/details?id=com.happproxy",
|
||||
"buttonText": {
|
||||
"en": "Open in Google Play",
|
||||
"fa": "باز کردن در Google Play",
|
||||
"ru": "Открыть в Google Play",
|
||||
"zh": "在 Google Play 中打开"
|
||||
}
|
||||
},
|
||||
{
|
||||
"buttonLink": "https://github.com/Happ-proxy/happ-android/releases/latest/download/Happ.apk",
|
||||
"buttonText": {
|
||||
"en": "Download APK",
|
||||
"fa": "دانلود APK",
|
||||
"ru": "Скачать APK",
|
||||
"zh": "下载 APK"
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": {
|
||||
"en": "Open the page in Google Play and install the app. Or install the app directly from the APK file if Google Play is not working.",
|
||||
"fa": "صفحه را در Google Play باز کنید و برنامه را نصب کنید. یا برنامه را مستقیماً از فایل APK نصب کنید، اگر Google Play کار نمی کند.",
|
||||
"ru": "Откройте страницу в Google Play и установите приложение. Или установите приложение из APK файла напрямую, если Google Play не работает.",
|
||||
"zh": "在 Google Play 中打开页面并安装应用。如果 Google Play 无法使用,也可以直接从 APK 文件安装应用。"
|
||||
}
|
||||
},
|
||||
"addSubscriptionStep": {
|
||||
"description": {
|
||||
"en": "Click the button below to add subscription",
|
||||
"fa": "برای افزودن اشتراک روی دکمه زیر کلیک کنید",
|
||||
"ru": "Нажмите кнопку ниже, чтобы добавить подписку",
|
||||
"zh": "点击下方按钮添加订阅"
|
||||
}
|
||||
},
|
||||
"connectAndUseStep": {
|
||||
"description": {
|
||||
"en": "Open the app and connect to the server",
|
||||
"fa": "برنامه را باز کنید و به سرور متصل شوید",
|
||||
"ru": "Откройте приложение и подключитесь к серверу",
|
||||
"zh": "打开应用并连接到服务器"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "clash-meta",
|
||||
"name": "Clash Meta",
|
||||
"isFeatured": false,
|
||||
"urlScheme": "clash://install-config?url=",
|
||||
"installationStep": {
|
||||
"buttons": [
|
||||
{
|
||||
"buttonLink": "https://github.com/MetaCubeX/ClashMetaForAndroid/releases/download/v2.11.7/cmfa-2.11.7-meta-universal-release.apk",
|
||||
"buttonText": {
|
||||
"en": "Download APK",
|
||||
"fa": "دانلود APK",
|
||||
"ru": "Скачать APK",
|
||||
"zh": "下载 APK"
|
||||
}
|
||||
},
|
||||
{
|
||||
"buttonLink": "https://f-droid.org/packages/com.github.metacubex.clash.meta/",
|
||||
"buttonText": {
|
||||
"en": "Open in F-Droid",
|
||||
"fa": "در F-Droid باز کنید",
|
||||
"ru": "Открыть в F-Droid",
|
||||
"zh": "在 F-Droid 中打开"
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": {
|
||||
"en": "Download and install Clash Meta APK",
|
||||
"fa": "دانلود و نصب Clash Meta APK",
|
||||
"ru": "Скачайте и установите Clash Meta APK",
|
||||
"zh": "下载并安装 Clash Meta APK"
|
||||
}
|
||||
},
|
||||
"addSubscriptionStep": {
|
||||
"description": {
|
||||
"en": "Tap the button to import configuration",
|
||||
"fa": "برای وارد کردن پیکربندی روی دکمه ضربه بزنید",
|
||||
"ru": "Нажмите кнопку, чтобы импортировать конфигурацию",
|
||||
"zh": "点击按钮导入配置"
|
||||
}
|
||||
},
|
||||
"connectAndUseStep": {
|
||||
"description": {
|
||||
"en": "Open Clash Meta and tap on Connect",
|
||||
"fa": "Clash Meta را باز کنید و روی اتصال ضربه بزنید",
|
||||
"ru": "Откройте Clash Meta и нажмите Подключиться",
|
||||
"zh": "打开 Clash Meta 并点击连接"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"macos": [
|
||||
{
|
||||
"id": "clash-verge",
|
||||
"name": "Clash Verge",
|
||||
"isFeatured": true,
|
||||
"urlScheme": "clash://install-config?url=",
|
||||
"installationStep": {
|
||||
"buttons": [
|
||||
{
|
||||
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases/download/v2.2.2/Clash.Verge_2.2.2_x64-setup.exe",
|
||||
"buttonText": {
|
||||
"en": "Windows",
|
||||
"fa": "ویندوز",
|
||||
"ru": "Windows",
|
||||
"zh": "Windows"
|
||||
}
|
||||
},
|
||||
{
|
||||
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases/download/v2.2.2/Clash.Verge_2.2.2_x64.dmg",
|
||||
"buttonText": {
|
||||
"en": "macOS (Intel)",
|
||||
"fa": "مک (اینتل)",
|
||||
"ru": "macOS (Intel)",
|
||||
"zh": "macOS (Intel)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases/download/v2.2.2/Clash.Verge_2.2.2_aarch64.dmg",
|
||||
"buttonText": {
|
||||
"en": "macOS (Apple Silicon)",
|
||||
"fa": "مک (Apple Silicon)",
|
||||
"ru": "macOS (Apple Silicon)",
|
||||
"zh": "macOS (Apple Silicon)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases",
|
||||
"buttonText": {
|
||||
"en": "Linux",
|
||||
"fa": "لینوکس",
|
||||
"ru": "Linux",
|
||||
"zh": "Linux"
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": {
|
||||
"en": "Choose the version for your device, click the button below and install the app.",
|
||||
"fa": "نسخه مناسب برای دستگاه خود را انتخاب کنید، دکمه زیر را فشار دهید و برنامه را نصب کنید",
|
||||
"ru": "Выберите подходящую версию для вашего устройства, нажмите на кнопку ниже и установите приложение.",
|
||||
"zh": "选择适合您设备的版本,点击下方按钮并安装应用。"
|
||||
}
|
||||
},
|
||||
"additionalBeforeAddSubscriptionStep": {
|
||||
"buttons": [],
|
||||
"description": {
|
||||
"en": "After launching the app, you can change the language in settings. In the left panel, find the gear icon, then navigate to Verge 设置 and select 语言设置.",
|
||||
"fa": "پس از راهاندازی برنامه، میتوانید زبان را در تنظیمات تغییر دهید. در پنل سمت چپ، نماد چرخ دنده را پیدا کنید، سپس به Verge 设置 بروید و 语言设置 را انتخاب کنید.",
|
||||
"ru": "После запуска приложения вы можете сменить язык в настройках. В левой панели найдите иконку шестеренки, далее ориентируйтесь на Verge 设置 и выберите пункт 语言设置.",
|
||||
"zh": "启动应用后,您可以在设置中更改语言。在左侧面板找到齿轮图标,然后导航到 Verge 设置并选择语言设置。"
|
||||
},
|
||||
{
|
||||
"buttonLink": "https://apps.apple.com/ru/app/happ-proxy-utility-plus/id6746188973",
|
||||
"buttonText": {
|
||||
"en": "App Store [RU]",
|
||||
"ru": "App Store [RU]"
|
||||
}
|
||||
"title": {
|
||||
"en": "Change language",
|
||||
"fa": "تغییر زبان",
|
||||
"ru": "Смена языка",
|
||||
"zh": "更改语言"
|
||||
}
|
||||
],
|
||||
"description": {
|
||||
"en": "Open the page in App Store and install the app. Launch it, in the VPN configuration permission window click Allow and enter your passcode.",
|
||||
"ru": "Откройте страницу в App Store и установите приложение. Вернись на страницу с подпиской"
|
||||
}
|
||||
},
|
||||
"addSubscriptionStep": {
|
||||
"description": {
|
||||
"en": "Click the button below — the app will open and the subscription will be added automatically",
|
||||
"ru": "Нажмите кнопку выше — (Подключить подписку) приложение откроется, и подписка добавится автоматически."
|
||||
}
|
||||
},
|
||||
"connectAndUseStep": {
|
||||
"description": {
|
||||
"en": "In the main section, click the large power button in the center to connect to VPN. Don't forget to select a server from the server list. If needed, choose another server from the server list.",
|
||||
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. Не забудьте выбрать сервер в списке серверов. При необходимости выберите другой сервер из списка серверов."
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"android": [
|
||||
{
|
||||
"id": "happ",
|
||||
"name": "Happ",
|
||||
"isFeatured": true,
|
||||
"urlScheme": "happ://add/",
|
||||
"installationStep": {
|
||||
"buttons": [
|
||||
{
|
||||
"buttonLink": "https://play.google.com/store/apps/details?id=com.happproxy",
|
||||
"buttonText": {
|
||||
"en": "Google Play",
|
||||
"ru": "Google Play"
|
||||
}
|
||||
},
|
||||
"addSubscriptionStep": {
|
||||
"description": {
|
||||
"en": "Click the button below to add subscription",
|
||||
"fa": "برای افزودن اشتراک روی دکمه زیر کلیک کنید",
|
||||
"ru": "Нажмите кнопку ниже, чтобы добавить подписку",
|
||||
"zh": "点击下方按钮添加订阅"
|
||||
}
|
||||
},
|
||||
"additionalAfterAddSubscriptionStep": {
|
||||
"buttons": [],
|
||||
"title": {
|
||||
"en": "If the subscription is not added",
|
||||
"fa": "اگر اشتراک در برنامه نصب نشده است",
|
||||
"ru": "Если подписка не добавилась",
|
||||
"zh": "如果订阅未添加"
|
||||
},
|
||||
{
|
||||
"buttonLink": "https://github.com/Happ-proxy/happ-android/releases/latest/download/Happ.apk",
|
||||
"buttonText": {
|
||||
"en": "Download APK",
|
||||
"ru": "Скачать APK"
|
||||
}
|
||||
"description": {
|
||||
"en": "If nothing happens after clicking the button, add the subscription manually. Click the Get Link button in the top right corner of this page, copy the link. In Clash Verge, go to the Profiles section and paste the link in the text field, then click the Import button.",
|
||||
"fa": "اگر پس از کلیک روی دکمه اتفاقی نیفتاد، اشتراک را به صورت دستی اضافه کنید. در گوشه بالا سمت راست این صفحه روی دکمه دریافت لینک کلیک کنید، لینک را کپی کنید. در Clash Verge به بخش پروفایلها بروید و لینک را در فیلد متنی وارد کنید، سپس روی دکمه وارد کردن کلیک کنید.",
|
||||
"ru": "Если после нажатия на кнопку ничего не произошло, добавьте подписку вручную. Нажмите на этой страницу кнопку Получить ссылку в правом верхнем углу, скопируйте ссылку. В Clash Verge перейдите в раздел Профили и вставьте ссылку в текстовое поле, затем нажмите на кнопку Импорт.",
|
||||
"zh": "如果点击按钮后没有反应,请手动添加订阅。点击此页面右上角的获取链接按钮,复制链接。在 Clash Verge 中,转到配置文件部分,将链接粘贴到文本字段中,然后点击导入按钮。"
|
||||
}
|
||||
},
|
||||
"connectAndUseStep": {
|
||||
"description": {
|
||||
"en": "You can select a server in the Proxy section, and enable VPN in the Settings section. Set the TUN Mode switch to ON.",
|
||||
"fa": "میتوانید در بخش پروکسی سرور را انتخاب کنید و در بخش تنظیمات VPN را فعال کنید. کلید TUN Mode را در حالت روشن قرار دهید.",
|
||||
"ru": "Выбрать сервер можно в разделе Прокси, включить VPN можно в разделе Настройки. Установите переключатель TUN Mode в положение ВКЛ.",
|
||||
"zh": "您可以在代理部分选择服务器,在设置部分启用 VPN。将 TUN 模式开关设置为开启。"
|
||||
}
|
||||
],
|
||||
"description": {
|
||||
"en": "Open the page in Google Play and install the app. Or install the app directly from the APK file if Google Play is not working.",
|
||||
"ru": "Откройте страницу в Google Play и установите приложение. Или установите приложение из APK файла напрямую, если Google Play не работает."
|
||||
}
|
||||
},
|
||||
"addSubscriptionStep": {
|
||||
"description": {
|
||||
"en": "Click the button below to add subscription",
|
||||
"ru": "Нажмите кнопку выше — (Подключить подписку) приложение откроется, и подписка добавится автоматически."
|
||||
}
|
||||
},
|
||||
"connectAndUseStep": {
|
||||
"description": {
|
||||
"en": "Open the app and connect to the server",
|
||||
"ru": "Откройте приложение и подключитесь к серверу"
|
||||
{
|
||||
"id": "hiddify",
|
||||
"name": "Hiddify",
|
||||
"isFeatured": false,
|
||||
"urlScheme": "hiddify://import/",
|
||||
"installationStep": {
|
||||
"buttons": [
|
||||
{
|
||||
"buttonLink": "https://github.com/hiddify/hiddify-app/releases/download/v2.5.7/Hiddify-Windows-Setup-x64.exe",
|
||||
"buttonText": {
|
||||
"en": "Windows",
|
||||
"fa": "ویندوز",
|
||||
"ru": "Windows",
|
||||
"zh": "Windows"
|
||||
}
|
||||
},
|
||||
{
|
||||
"buttonLink": "https://github.com/hiddify/hiddify-app/releases/download/v2.5.7/Hiddify-MacOS.dmg",
|
||||
"buttonText": {
|
||||
"en": "macOS",
|
||||
"fa": "مک",
|
||||
"ru": "macOS",
|
||||
"zh": "macOS"
|
||||
}
|
||||
},
|
||||
{
|
||||
"buttonLink": "https://github.com/hiddify/hiddify-app/releases/download/v2.5.7/Hiddify-Linux-x64.AppImage",
|
||||
"buttonText": {
|
||||
"en": "Linux",
|
||||
"fa": "لینوکس",
|
||||
"ru": "Linux",
|
||||
"zh": "Linux"
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": {
|
||||
"en": "In the main section, click the large power button in the center to connect to VPN. If needed, select a different server in the Proxy section",
|
||||
"fa": "در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. در صورت نیاز، سرور دیگری را در بخش پروکسی انتخاب کنید",
|
||||
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. При необходимости выберите другой сервер в разделе Прокси.",
|
||||
"zh": "在主界面中,点击中央的大电源按钮连接 VPN。如有需要,可在代理部分选择不同的服务器"
|
||||
}
|
||||
},
|
||||
"addSubscriptionStep": {
|
||||
"description": {
|
||||
"en": "Click the button below to add subscription",
|
||||
"fa": "برای افزودن اشتراک روی دکمه زیر کلیک کنید",
|
||||
"ru": "Нажмите кнопку ниже, чтобы добавить подписку",
|
||||
"zh": "点击下方按钮添加订阅"
|
||||
}
|
||||
},
|
||||
"connectAndUseStep": {
|
||||
"description": {
|
||||
"en": "In the main section, click the large power button in the center to connect to VPN. Don't forget to select a server from the server list. If needed, select a different server from the server list.",
|
||||
"fa": "در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. فراموش نکنید که یک سرور را از لیست سرورها انتخاب کنید. در صورت نیاز، سرور دیگری را از لیست سرورها انتخاب کنید.",
|
||||
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. Не забудьте выбрать сервер в списке серверов. При необходимости выберите другой сервер из списка серверов.",
|
||||
"zh": "在主界面中,点击中央的大电源按钮连接到 VPN。别忘了从服务器列表中选择一个服务器。如有需要,可从服务器列表中选择其他服务器。"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"pc": [
|
||||
{
|
||||
"id": "happ",
|
||||
"name": "Happ",
|
||||
"isFeatured": false,
|
||||
"urlScheme": "happ://add/",
|
||||
"installationStep": {
|
||||
"buttons": [
|
||||
{
|
||||
"buttonLink": "https://apps.apple.com/us/app/happ-proxy-utility/id6504287215?l=ru",
|
||||
"buttonText": {
|
||||
"en": "Mac Os",
|
||||
"ru": "Mac Os"
|
||||
],
|
||||
"windows": [
|
||||
{
|
||||
"id": "clash-verge",
|
||||
"name": "Clash Verge",
|
||||
"isFeatured": true,
|
||||
"urlScheme": "clash://install-config?url=",
|
||||
"installationStep": {
|
||||
"buttons": [
|
||||
{
|
||||
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases/download/v2.2.2/Clash.Verge_2.2.2_x64-setup.exe",
|
||||
"buttonText": {
|
||||
"en": "Windows",
|
||||
"fa": "ویندوز",
|
||||
"ru": "Windows",
|
||||
"zh": "Windows"
|
||||
}
|
||||
},
|
||||
{
|
||||
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases/download/v2.2.2/Clash.Verge_2.2.2_x64.dmg",
|
||||
"buttonText": {
|
||||
"en": "macOS (Intel)",
|
||||
"fa": "مک (اینتل)",
|
||||
"ru": "macOS (Intel)",
|
||||
"zh": "macOS (Intel)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases/download/v2.2.2/Clash.Verge_2.2.2_aarch64.dmg",
|
||||
"buttonText": {
|
||||
"en": "macOS (Apple Silicon)",
|
||||
"fa": "مک (Apple Silicon)",
|
||||
"ru": "macOS (Apple Silicon)",
|
||||
"zh": "macOS (Apple Silicon)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases",
|
||||
"buttonText": {
|
||||
"en": "Linux",
|
||||
"fa": "لینوکس",
|
||||
"ru": "Linux",
|
||||
"zh": "Linux"
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": {
|
||||
"en": "Choose the version for your device, click the button below and install the app.",
|
||||
"fa": "نسخه مناسب برای دستگاه خود را انتخاب کنید، دکمه زیر را فشار دهید و برنامه را نصب کنید",
|
||||
"ru": "Выберите подходящую версию для вашего устройства, нажмите на кнопку ниже и установите приложение.",
|
||||
"zh": "选择适合您设备的版本,点击下方按钮并安装应用。"
|
||||
}
|
||||
},
|
||||
"additionalBeforeAddSubscriptionStep": {
|
||||
"buttons": [],
|
||||
"description": {
|
||||
"en": "After launching the app, you can change the language in settings. In the left panel, find the gear icon, then navigate to Verge 设置 and select 语言设置.",
|
||||
"fa": "پس از راهاندازی برنامه، میتوانید زبان را در تنظیمات تغییر دهید. در پنل سمت چپ، نماد چرخ دنده را پیدا کنید، سپس به Verge 设置 بروید و 语言设置 را انتخاب کنید.",
|
||||
"ru": "После запуска приложения вы можете сменить язык в настройках. В левой панели найдите иконку шестеренки, далее ориентируйтесь на Verge 设置 и выберите пункт 语言设置.",
|
||||
"zh": "启动应用后,您可以在设置中更改语言。在左侧面板找到齿轮图标,然后导航到 Verge 设置并选择语言设置。"
|
||||
},
|
||||
{
|
||||
"buttonLink": "https://github.com/Happ-proxy/happ-desktop/releases/latest/download/setup-Happ.x86.exe",
|
||||
"buttonText": {
|
||||
"en": "Windows",
|
||||
"ru": "Windows"
|
||||
}
|
||||
"title": {
|
||||
"en": "Change language",
|
||||
"fa": "تغییر زبان",
|
||||
"ru": "Смена языка",
|
||||
"zh": "更改语言"
|
||||
}
|
||||
},
|
||||
"addSubscriptionStep": {
|
||||
"description": {
|
||||
"en": "Click the button below to add subscription",
|
||||
"fa": "برای افزودن اشتراک روی دکمه زیر کلیک کنید",
|
||||
"ru": "Нажмите кнопку ниже, чтобы добавить подписку",
|
||||
"zh": "点击下方按钮添加订阅"
|
||||
}
|
||||
},
|
||||
"additionalAfterAddSubscriptionStep": {
|
||||
"buttons": [],
|
||||
"title": {
|
||||
"en": "If the subscription is not added",
|
||||
"fa": "اگر اشتراک در برنامه نصب نشده است",
|
||||
"ru": "Если подписка не добавилась",
|
||||
"zh": "如果订阅未添加"
|
||||
},
|
||||
"description": {
|
||||
"en": "If nothing happens after clicking the button, add the subscription manually. Click the Get Link button in the top right corner of this page, copy the link. In Clash Verge, go to the Profiles section and paste the link in the text field, then click the Import button.",
|
||||
"fa": "اگر پس از کلیک روی دکمه اتفاقی نیفتاد، اشتراک را به صورت دستی اضافه کنید. در گوشه بالا سمت راست این صفحه روی دکمه دریافت لینک کلیک کنید، لینک را کپی کنید. در Clash Verge به بخش پروفایلها بروید و لینک را در فیلد متنی وارد کنید، سپس روی دکمه وارد کردن کلیک کنید.",
|
||||
"ru": "Если после нажатия на кнопку ничего не произошло, добавьте подписку вручную. Нажмите на этой страницу кнопку Получить ссылку в правом верхнем углу, скопируйте ссылку. В Clash Verge перейдите в раздел Профили и вставьте ссылку в текстовое поле, затем нажмите на кнопку Импорт.",
|
||||
"zh": "如果点击按钮后没有反应,请手动添加订阅。点击此页面右上角的获取链接按钮,复制链接。在 Clash Verge 中,转到配置文件部分,将链接粘贴到文本字段中,然后点击导入按钮。"
|
||||
}
|
||||
},
|
||||
"connectAndUseStep": {
|
||||
"description": {
|
||||
"en": "You can select a server in the Proxy section, and enable VPN in the Settings section. Set the TUN Mode switch to ON.",
|
||||
"fa": "میتوانید در بخش پروکسی سرور را انتخاب کنید و در بخش تنظیمات VPN را فعال کنید. کلید TUN Mode را در حالت روشن قرار دهید.",
|
||||
"ru": "Выبрать сервер можно в разделе Прокси, включить VPN можно в разделе Настройки. Установите переключатель TUN Mode в положение ВКЛ.",
|
||||
"zh": "您可以在代理部分选择服务器,在设置部分启用 VPN。将 TUN 模式开关设置为开启。"
|
||||
}
|
||||
],
|
||||
"description": {
|
||||
"en": "Choose the version for your device, click the button below and install the app.",
|
||||
"ru": "Выберите подходящую версию для вашего устройства, нажмите на кнопку ниже и установите приложение"
|
||||
}
|
||||
},
|
||||
"addSubscriptionStep": {
|
||||
"description": {
|
||||
"en": "Click the button below to add subscription",
|
||||
"ru": "Нажмите кнопку выше — (Подключить подписку) приложение откроется, и подписка добавится автоматически."
|
||||
}
|
||||
},
|
||||
"connectAndUseStep": {
|
||||
"description": {
|
||||
"en": "You can select a server",
|
||||
"ru": "Выберете локацию, включить VPN"
|
||||
{
|
||||
"id": "hiddify",
|
||||
"name": "Hiddify",
|
||||
"isFeatured": false,
|
||||
"urlScheme": "hiddify://import/",
|
||||
"installationStep": {
|
||||
"buttons": [
|
||||
{
|
||||
"buttonLink": "https://github.com/hiddify/hiddify-app/releases/download/v2.5.7/Hiddify-Windows-Setup-x64.exe",
|
||||
"buttonText": {
|
||||
"en": "Windows",
|
||||
"fa": "ویندوز",
|
||||
"ru": "Windows",
|
||||
"zh": "Windows"
|
||||
}
|
||||
},
|
||||
{
|
||||
"buttonLink": "https://github.com/hiddify/hiddify-app/releases/download/v2.5.7/Hiddify-MacOS.dmg",
|
||||
"buttonText": {
|
||||
"en": "macOS",
|
||||
"fa": "مک",
|
||||
"ru": "macOS",
|
||||
"zh": "macOS"
|
||||
}
|
||||
},
|
||||
{
|
||||
"buttonLink": "https://github.com/hiddify/hiddify-app/releases/download/v2.5.7/Hiddify-Linux-x64.AppImage",
|
||||
"buttonText": {
|
||||
"en": "Linux",
|
||||
"fa": "لینوکس",
|
||||
"ru": "Linux",
|
||||
"zh": "Linux"
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": {
|
||||
"en": "In the main section, click the large power button in the center to connect to VPN. If needed, select a different server in the Proxy section",
|
||||
"fa": "در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. در صورت نیاز، سرور دیگری را در بخش پروکسی انتخاب کنید",
|
||||
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. При необходимости выберите другой сервер в разделе Прокси.",
|
||||
"zh": "在主界面中,点击中央的大电源按钮连接 VPN。如有需要,可在代理部分选择不同的服务器"
|
||||
}
|
||||
},
|
||||
"addSubscriptionStep": {
|
||||
"description": {
|
||||
"en": "Click the button below to add subscription",
|
||||
"fa": "برای افزودن اشتراک روی دکمه زیر کلیک کنید",
|
||||
"ru": "Нажмите кнопку ниже, чтобы добавить подписку",
|
||||
"zh": "点击下方按钮添加订阅"
|
||||
}
|
||||
},
|
||||
"connectAndUseStep": {
|
||||
"description": {
|
||||
"en": "In the main section, click the large power button in the center to connect to VPN. Don't forget to select a server from the server list. If needed, select a different server from the server list.",
|
||||
"fa": "در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. فراموش نکنید که یک سرور را از لیست سرورها انتخاب کنید. در صورت نیاز، سرور دیگری را از لیست سرورها انتخاب کنید.",
|
||||
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. Не забудьте выбрать сервер в списке серверов. При необходимости выберите другой сервер из списка серверов.",
|
||||
"zh": "在主界面中,点击中央的大电源按钮连接到 VPN。别忘了从服务器列表中选择一个服务器。如有需要,可从服务器列表中选择其他服务器。"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"tv": [
|
||||
{
|
||||
"id": "vpn4tv",
|
||||
"name": "VPN4TV",
|
||||
"isFeatured": true,
|
||||
"urlScheme": "",
|
||||
"installationStep": {
|
||||
"buttons": [
|
||||
{
|
||||
"buttonLink": "https://play.google.com/store/apps/details?id=com.vpn4tv.hiddify",
|
||||
"buttonText": {
|
||||
"en": "Google Play",
|
||||
"ru": "Google Play"
|
||||
],
|
||||
"linux": [],
|
||||
"androidTV": [
|
||||
{
|
||||
"id": "new-app-androidtv-1760203310792",
|
||||
"name": "Happ",
|
||||
"isFeatured": true,
|
||||
"urlScheme": "happ://add/",
|
||||
"installationStep": {
|
||||
"buttons": [
|
||||
{
|
||||
"buttonLink": "https://play.google.com/store/apps/details?id=com.vpn4tv.hiddify",
|
||||
"buttonText": {
|
||||
"en": "Google Play",
|
||||
"ru": "Button TextGoogle Play",
|
||||
"zh": "Button Text",
|
||||
"fa": "Button Text"
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": {
|
||||
"en": "Open the page in Google Play and install the app. Or install the app directly from the APK file if Google Play is not working.",
|
||||
"ru": "Откройте страницу в Google Play и установите приложение",
|
||||
"zh": "-",
|
||||
"fa": "-"
|
||||
}
|
||||
},
|
||||
"addSubscriptionStep": {
|
||||
"description": {
|
||||
"en": "Click the button below to add subscription",
|
||||
"ru": "Нажмите кнопку выше — (Скопировать ссылку подписки) ты скопируешь свою подписку, далее на телевизоре открой VPN4TV, следуя инструкция передай telegram боту ссылку, которую ты скопировал",
|
||||
"zh": "-",
|
||||
"fa": "-"
|
||||
}
|
||||
},
|
||||
"connectAndUseStep": {
|
||||
"description": {
|
||||
"en": "Open the app and connect to the server",
|
||||
"ru": "Приложение автоматически обновится и загрузит нужные конфиги на твой телевизор, подключай VPN",
|
||||
"zh": "-",
|
||||
"fa": "-"
|
||||
}
|
||||
],
|
||||
"description": {
|
||||
"en": "Open the page in Google Play and install the app. Or install the app directly from the APK file if Google Play is not working.",
|
||||
"ru": "Откройте страницу в Google Play и установите приложение"
|
||||
}
|
||||
},
|
||||
"addSubscriptionStep": {
|
||||
"description": {
|
||||
"en": "Click the button below to add subscription",
|
||||
"ru": "Нажмите кнопку выше — (Скопировать ссылку подписки) ты скопируешь свою подписку, далее на телевизоре открой VPN4TV, следуя инструкция передай telegram боту ссылку, которую ты скопировал"
|
||||
}
|
||||
},
|
||||
"connectAndUseStep": {
|
||||
"description": {
|
||||
"en": "Open the app and connect to the server",
|
||||
"ru": "Приложение автоматически обновится и загрузит нужные конфиги на твой телевизор, подключай VPN"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
],
|
||||
"appleTV": [
|
||||
{
|
||||
"id": "new-app-appletv-1760203488851",
|
||||
"name": "Happ",
|
||||
"isFeatured": true,
|
||||
"urlScheme": "happ://add/",
|
||||
"installationStep": {
|
||||
"buttons": [
|
||||
{
|
||||
"buttonLink": "https://play.google.com/store/apps/details?id=com.vpn4tv.hiddify",
|
||||
"buttonText": {
|
||||
"en": "Google Play",
|
||||
"ru": "Google Play",
|
||||
"zh": "Button Text",
|
||||
"fa": "Button Text"
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": {
|
||||
"en": "Open the page in Google Play and install the app. Or install the app directly from the APK file if Google Play is not working.",
|
||||
"ru": "Откройте страницу в Google Play и установите приложение",
|
||||
"zh": "-",
|
||||
"fa": "-"
|
||||
}
|
||||
},
|
||||
"addSubscriptionStep": {
|
||||
"description": {
|
||||
"en": "Click the button below to add subscription",
|
||||
"ru": "Нажмите кнопку выше — (Скопировать ссылку подписки) ты скопируешь свою подписку, далее на телевизоре открой VPN4TV, следуя инструкция передай telegram боту ссылку, которую ты скопировал",
|
||||
"zh": "-",
|
||||
"fa": "-"
|
||||
}
|
||||
},
|
||||
"connectAndUseStep": {
|
||||
"description": {
|
||||
"en": "Open the app and connect to the server",
|
||||
"ru": "Приложение автоматически обновится и загрузит нужные конфиги на твой телевизор, подключай VPN",
|
||||
"zh": "-",
|
||||
"fa": "-"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+105
-22
@@ -5,28 +5,67 @@ from aiogram.fsm.storage.memory import MemoryStorage
|
||||
import redis.asyncio as redis
|
||||
|
||||
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.maintenance import MaintenanceMiddleware
|
||||
from app.middlewares.display_name_restriction import DisplayNameRestrictionMiddleware
|
||||
from app.services.maintenance_service import maintenance_service
|
||||
from app.utils.cache import cache
|
||||
|
||||
from app.handlers import (
|
||||
start, menu, subscription, balance, promocode,
|
||||
referral, support, common
|
||||
start,
|
||||
menu,
|
||||
subscription,
|
||||
balance,
|
||||
promocode,
|
||||
referral,
|
||||
support,
|
||||
server_status,
|
||||
common,
|
||||
tickets,
|
||||
)
|
||||
from app.handlers import polls as user_polls
|
||||
from app.handlers import simple_subscription
|
||||
from app.handlers.admin import (
|
||||
main as admin_main, 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, servers as admin_servers,
|
||||
maintenance as admin_maintenance
|
||||
main as admin_main,
|
||||
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,
|
||||
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,
|
||||
pricing as admin_pricing,
|
||||
privacy_policy as admin_privacy_policy,
|
||||
public_offer as admin_public_offer,
|
||||
faq as admin_faq,
|
||||
payments as admin_payments,
|
||||
)
|
||||
from app.handlers.stars_payments import register_stars_handlers
|
||||
|
||||
from app.utils.message_patch import patch_message_methods
|
||||
|
||||
patch_message_methods()
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -66,25 +105,46 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
logger.info("Используется MemoryStorage для FSM")
|
||||
storage = MemoryStorage()
|
||||
|
||||
|
||||
dp = Dispatcher(storage=storage)
|
||||
|
||||
dp.message.middleware(GlobalErrorMiddleware())
|
||||
dp.callback_query.middleware(GlobalErrorMiddleware())
|
||||
dp.pre_checkout_query.middleware(GlobalErrorMiddleware())
|
||||
dp.message.middleware(LoggingMiddleware())
|
||||
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)
|
||||
dp.message.middleware(ThrottlingMiddleware())
|
||||
dp.callback_query.middleware(ThrottlingMiddleware())
|
||||
|
||||
if settings.CHANNEL_IS_REQUIRED_SUB:
|
||||
from app.middlewares.channel_checker import ChannelCheckerMiddleware
|
||||
|
||||
channel_checker_middleware = ChannelCheckerMiddleware()
|
||||
dp.message.middleware(channel_checker_middleware)
|
||||
dp.callback_query.middleware(channel_checker_middleware)
|
||||
logger.info("🔒 Обязательная подписка включена - ChannelCheckerMiddleware активирован")
|
||||
else:
|
||||
logger.info("🔓 Обязательная подписка отключена - ChannelCheckerMiddleware не зарегистрирован")
|
||||
dp.message.middleware(AuthMiddleware())
|
||||
dp.callback_query.middleware(AuthMiddleware())
|
||||
dp.pre_checkout_query.middleware(AuthMiddleware())
|
||||
dp.message.middleware(MaintenanceMiddleware())
|
||||
dp.callback_query.middleware(MaintenanceMiddleware())
|
||||
dp.message.middleware(ThrottlingMiddleware())
|
||||
dp.callback_query.middleware(ThrottlingMiddleware())
|
||||
dp.message.middleware(SubscriptionStatusMiddleware())
|
||||
dp.callback_query.middleware(SubscriptionStatusMiddleware())
|
||||
start.register_handlers(dp)
|
||||
menu.register_handlers(dp)
|
||||
subscription.register_handlers(dp)
|
||||
balance.register_handlers(dp)
|
||||
balance.register_balance_handlers(dp)
|
||||
promocode.register_handlers(dp)
|
||||
referral.register_handlers(dp)
|
||||
support.register_handlers(dp)
|
||||
server_status.register_handlers(dp)
|
||||
tickets.register_handlers(dp)
|
||||
admin_main.register_handlers(dp)
|
||||
admin_users.register_handlers(dp)
|
||||
admin_subscriptions.register_handlers(dp)
|
||||
@@ -96,19 +156,42 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
admin_rules.register_handlers(dp)
|
||||
admin_remnawave.register_handlers(dp)
|
||||
admin_statistics.register_handlers(dp)
|
||||
admin_polls.register_handlers(dp)
|
||||
admin_promo_groups.register_handlers(dp)
|
||||
admin_campaigns.register_handlers(dp)
|
||||
admin_promo_offers.register_handlers(dp)
|
||||
admin_maintenance.register_handlers(dp)
|
||||
|
||||
admin_user_messages.register_handlers(dp)
|
||||
admin_updates.register_handlers(dp)
|
||||
admin_backup.register_handlers(dp)
|
||||
admin_system_logs.register_handlers(dp)
|
||||
admin_welcome_text.register_welcome_text_handlers(dp)
|
||||
admin_tickets.register_handlers(dp)
|
||||
admin_reports.register_handlers(dp)
|
||||
admin_bot_configuration.register_handlers(dp)
|
||||
admin_pricing.register_handlers(dp)
|
||||
admin_privacy_policy.register_handlers(dp)
|
||||
admin_public_offer.register_handlers(dp)
|
||||
admin_faq.register_handlers(dp)
|
||||
admin_payments.register_handlers(dp)
|
||||
common.register_handlers(dp)
|
||||
|
||||
register_stars_handlers(dp)
|
||||
logger.info("🌟 Зарегистрированы обработчики Telegram Stars платежей")
|
||||
user_polls.register_handlers(dp)
|
||||
simple_subscription.register_simple_subscription_handlers(dp)
|
||||
logger.info("⭐ Зарегистрированы обработчики Telegram Stars платежей")
|
||||
logger.info("⚡ Зарегистрированы обработчики простой покупки")
|
||||
logger.info("⚡ Зарегистрированы обработчики простой подписки")
|
||||
|
||||
try:
|
||||
await maintenance_service.start_monitoring()
|
||||
logger.info("Мониторинг техработ запущен")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка запуска мониторинга техработ: {e}")
|
||||
if settings.is_maintenance_monitoring_enabled():
|
||||
try:
|
||||
await maintenance_service.start_monitoring()
|
||||
logger.info("Мониторинг техработ запущен")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка запуска мониторинга техработ: {e}")
|
||||
else:
|
||||
logger.info("Мониторинг техработ отключен настройками")
|
||||
|
||||
logger.info("🛡️ GlobalErrorMiddleware активирован - бот защищен от устаревших callback queries")
|
||||
logger.info("Бот успешно настроен")
|
||||
|
||||
return bot, dp
|
||||
|
||||
+1233
-68
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,23 @@
|
||||
"""Database package exports."""
|
||||
|
||||
from .database import (
|
||||
DatabaseManager,
|
||||
batch_ops,
|
||||
close_db,
|
||||
db_manager,
|
||||
get_db,
|
||||
get_db_read_only,
|
||||
get_pool_metrics,
|
||||
init_db,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"DatabaseManager",
|
||||
"batch_ops",
|
||||
"close_db",
|
||||
"db_manager",
|
||||
"get_db",
|
||||
"get_db_read_only",
|
||||
"get_pool_metrics",
|
||||
"init_db",
|
||||
]
|
||||
@@ -0,0 +1,491 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from sqlalchemy import and_, func, select, update, delete
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import (
|
||||
AdvertisingCampaign,
|
||||
AdvertisingCampaignRegistration,
|
||||
Subscription,
|
||||
SubscriptionConversion,
|
||||
SubscriptionStatus,
|
||||
Transaction,
|
||||
TransactionType,
|
||||
User,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def create_campaign(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
name: str,
|
||||
start_parameter: str,
|
||||
bonus_type: str,
|
||||
created_by: Optional[int] = 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,
|
||||
is_active: bool = True,
|
||||
) -> AdvertisingCampaign:
|
||||
campaign = AdvertisingCampaign(
|
||||
name=name,
|
||||
start_parameter=start_parameter,
|
||||
bonus_type=bonus_type,
|
||||
balance_bonus_kopeks=balance_bonus_kopeks or 0,
|
||||
subscription_duration_days=subscription_duration_days,
|
||||
subscription_traffic_gb=subscription_traffic_gb,
|
||||
subscription_device_limit=subscription_device_limit,
|
||||
subscription_squads=subscription_squads or [],
|
||||
created_by=created_by,
|
||||
is_active=is_active,
|
||||
)
|
||||
|
||||
db.add(campaign)
|
||||
await db.commit()
|
||||
await db.refresh(campaign)
|
||||
|
||||
logger.info(
|
||||
"📣 Создана рекламная кампания %s (start=%s, bonus=%s)",
|
||||
campaign.name,
|
||||
campaign.start_parameter,
|
||||
campaign.bonus_type,
|
||||
)
|
||||
return campaign
|
||||
|
||||
|
||||
async def get_campaign_by_id(
|
||||
db: AsyncSession, campaign_id: int
|
||||
) -> Optional[AdvertisingCampaign]:
|
||||
result = await db.execute(
|
||||
select(AdvertisingCampaign)
|
||||
.options(selectinload(AdvertisingCampaign.registrations))
|
||||
.where(AdvertisingCampaign.id == campaign_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_campaign_by_start_parameter(
|
||||
db: AsyncSession,
|
||||
start_parameter: str,
|
||||
*,
|
||||
only_active: bool = False,
|
||||
) -> Optional[AdvertisingCampaign]:
|
||||
stmt = select(AdvertisingCampaign).where(
|
||||
AdvertisingCampaign.start_parameter == start_parameter
|
||||
)
|
||||
if only_active:
|
||||
stmt = stmt.where(AdvertisingCampaign.is_active.is_(True))
|
||||
|
||||
result = await db.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_campaigns_list(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
offset: int = 0,
|
||||
limit: int = 20,
|
||||
include_inactive: bool = True,
|
||||
) -> List[AdvertisingCampaign]:
|
||||
stmt = (
|
||||
select(AdvertisingCampaign)
|
||||
.options(selectinload(AdvertisingCampaign.registrations))
|
||||
.order_by(AdvertisingCampaign.created_at.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
if not include_inactive:
|
||||
stmt = stmt.where(AdvertisingCampaign.is_active.is_(True))
|
||||
|
||||
result = await db.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def get_campaigns_count(
|
||||
db: AsyncSession, *, is_active: Optional[bool] = None
|
||||
) -> int:
|
||||
stmt = select(func.count(AdvertisingCampaign.id))
|
||||
if is_active is not None:
|
||||
stmt = stmt.where(AdvertisingCampaign.is_active.is_(is_active))
|
||||
|
||||
result = await db.execute(stmt)
|
||||
return result.scalar_one() or 0
|
||||
|
||||
|
||||
async def update_campaign(
|
||||
db: AsyncSession,
|
||||
campaign: AdvertisingCampaign,
|
||||
**kwargs,
|
||||
) -> AdvertisingCampaign:
|
||||
allowed_fields = {
|
||||
"name",
|
||||
"start_parameter",
|
||||
"bonus_type",
|
||||
"balance_bonus_kopeks",
|
||||
"subscription_duration_days",
|
||||
"subscription_traffic_gb",
|
||||
"subscription_device_limit",
|
||||
"subscription_squads",
|
||||
"is_active",
|
||||
}
|
||||
|
||||
update_data = {}
|
||||
for key, value in kwargs.items():
|
||||
if key in allowed_fields and value is not None:
|
||||
update_data[key] = value
|
||||
|
||||
if not update_data:
|
||||
return campaign
|
||||
|
||||
update_data["updated_at"] = datetime.utcnow()
|
||||
|
||||
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)
|
||||
return campaign
|
||||
|
||||
|
||||
async def delete_campaign(db: AsyncSession, campaign: AdvertisingCampaign) -> bool:
|
||||
await db.execute(
|
||||
delete(AdvertisingCampaign).where(AdvertisingCampaign.id == campaign.id)
|
||||
)
|
||||
await db.commit()
|
||||
logger.info("🗑️ Удалена рекламная кампания %s", campaign.name)
|
||||
return True
|
||||
|
||||
|
||||
async def get_campaign_registration_by_user(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
) -> Optional[AdvertisingCampaignRegistration]:
|
||||
result = await db.execute(
|
||||
select(AdvertisingCampaignRegistration)
|
||||
.options(selectinload(AdvertisingCampaignRegistration.campaign))
|
||||
.where(AdvertisingCampaignRegistration.user_id == user_id)
|
||||
.limit(1)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def record_campaign_registration(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
campaign_id: int,
|
||||
user_id: int,
|
||||
bonus_type: str,
|
||||
balance_bonus_kopeks: int = 0,
|
||||
subscription_duration_days: Optional[int] = None,
|
||||
) -> AdvertisingCampaignRegistration:
|
||||
existing = await db.execute(
|
||||
select(AdvertisingCampaignRegistration).where(
|
||||
and_(
|
||||
AdvertisingCampaignRegistration.campaign_id == campaign_id,
|
||||
AdvertisingCampaignRegistration.user_id == user_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
registration = existing.scalar_one_or_none()
|
||||
if registration:
|
||||
return registration
|
||||
|
||||
registration = AdvertisingCampaignRegistration(
|
||||
campaign_id=campaign_id,
|
||||
user_id=user_id,
|
||||
bonus_type=bonus_type,
|
||||
balance_bonus_kopeks=balance_bonus_kopeks or 0,
|
||||
subscription_duration_days=subscription_duration_days,
|
||||
)
|
||||
db.add(registration)
|
||||
await db.commit()
|
||||
await db.refresh(registration)
|
||||
|
||||
logger.info("📈 Регистрируем пользователя %s в кампании %s", user_id, campaign_id)
|
||||
return registration
|
||||
|
||||
|
||||
async def get_campaign_statistics(
|
||||
db: AsyncSession,
|
||||
campaign_id: int,
|
||||
) -> Dict[str, Optional[int]]:
|
||||
registrations_query = select(AdvertisingCampaignRegistration.user_id).where(
|
||||
AdvertisingCampaignRegistration.campaign_id == campaign_id
|
||||
)
|
||||
registrations_subquery = registrations_query.subquery()
|
||||
|
||||
result = await db.execute(
|
||||
select(
|
||||
func.count(AdvertisingCampaignRegistration.id),
|
||||
func.coalesce(
|
||||
func.sum(AdvertisingCampaignRegistration.balance_bonus_kopeks), 0
|
||||
),
|
||||
func.max(AdvertisingCampaignRegistration.created_at),
|
||||
).where(AdvertisingCampaignRegistration.campaign_id == campaign_id)
|
||||
)
|
||||
count, total_balance, last_registration = result.one()
|
||||
count = count or 0
|
||||
total_balance = total_balance or 0
|
||||
|
||||
subscription_count_result = await db.execute(
|
||||
select(func.count(AdvertisingCampaignRegistration.id)).where(
|
||||
and_(
|
||||
AdvertisingCampaignRegistration.campaign_id == campaign_id,
|
||||
AdvertisingCampaignRegistration.bonus_type == "subscription",
|
||||
)
|
||||
)
|
||||
)
|
||||
subscription_bonuses_issued = subscription_count_result.scalar() or 0
|
||||
|
||||
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),
|
||||
)
|
||||
)
|
||||
deposits_total = 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_from_flag = paid_users_result.scalar() or 0
|
||||
|
||||
conversions_rows = await db.execute(
|
||||
select(
|
||||
SubscriptionConversion.user_id,
|
||||
SubscriptionConversion.first_payment_amount_kopeks,
|
||||
SubscriptionConversion.converted_at,
|
||||
)
|
||||
.where(
|
||||
SubscriptionConversion.user_id.in_(
|
||||
select(registrations_subquery.c.user_id)
|
||||
)
|
||||
)
|
||||
.order_by(SubscriptionConversion.converted_at)
|
||||
)
|
||||
conversion_entries = conversions_rows.all()
|
||||
|
||||
subscription_payments_rows = await db.execute(
|
||||
select(
|
||||
Transaction.user_id,
|
||||
Transaction.amount_kopeks,
|
||||
Transaction.created_at,
|
||||
)
|
||||
.where(
|
||||
Transaction.user_id.in_(select(registrations_subquery.c.user_id)),
|
||||
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
|
||||
Transaction.is_completed.is_(True),
|
||||
)
|
||||
.order_by(Transaction.user_id, Transaction.created_at)
|
||||
)
|
||||
subscription_payments = subscription_payments_rows.all()
|
||||
|
||||
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]] = {}
|
||||
|
||||
for user_id, amount_kopeks, converted_at in conversion_entries:
|
||||
conversion_user_ids.add(user_id)
|
||||
amount_value = int(amount_kopeks or 0)
|
||||
first_payment_amount_by_user[user_id] = amount_value
|
||||
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)
|
||||
subscription_payments_total += amount_value
|
||||
paid_users_from_transactions.add(user_id)
|
||||
|
||||
if user_id not in first_payment_amount_by_user:
|
||||
first_payment_amount_by_user[user_id] = amount_value
|
||||
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
|
||||
):
|
||||
first_payment_amount_by_user[user_id] = amount_value
|
||||
first_payment_time_by_user[user_id] = created_at
|
||||
|
||||
total_revenue = deposits_total + subscription_payments_total
|
||||
|
||||
paid_user_ids = set(paid_users_from_transactions)
|
||||
paid_user_ids.update(conversion_user_ids)
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
|
||||
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))
|
||||
)
|
||||
|
||||
balance_result = await db.execute(
|
||||
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"
|
||||
)
|
||||
)
|
||||
|
||||
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,
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
from sqlalchemy import select, and_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import CryptoBotPayment
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def create_cryptobot_payment(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
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
|
||||
) -> CryptoBotPayment:
|
||||
|
||||
payment = CryptoBotPayment(
|
||||
user_id=user_id,
|
||||
invoice_id=invoice_id,
|
||||
amount=amount,
|
||||
asset=asset,
|
||||
status=status,
|
||||
description=description,
|
||||
payload=payload,
|
||||
bot_invoice_url=bot_invoice_url,
|
||||
mini_app_invoice_url=mini_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}")
|
||||
return payment
|
||||
|
||||
|
||||
async def get_cryptobot_payment_by_invoice_id(
|
||||
db: AsyncSession,
|
||||
invoice_id: str
|
||||
) -> Optional[CryptoBotPayment]:
|
||||
|
||||
result = await db.execute(
|
||||
select(CryptoBotPayment)
|
||||
.options(selectinload(CryptoBotPayment.user))
|
||||
.where(CryptoBotPayment.invoice_id == invoice_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_cryptobot_payment_by_id(
|
||||
db: AsyncSession,
|
||||
payment_id: int
|
||||
) -> Optional[CryptoBotPayment]:
|
||||
|
||||
result = await db.execute(
|
||||
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]:
|
||||
|
||||
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.paid_at = paid_at
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
|
||||
logger.info(f"Обновлен статус CryptoBot платежа {invoice_id}: {status}")
|
||||
return payment
|
||||
|
||||
|
||||
async def link_cryptobot_payment_to_transaction(
|
||||
db: AsyncSession,
|
||||
invoice_id: str,
|
||||
transaction_id: int
|
||||
) -> Optional[CryptoBotPayment]:
|
||||
|
||||
payment = await get_cryptobot_payment_by_invoice_id(db, invoice_id)
|
||||
|
||||
if not payment:
|
||||
return None
|
||||
|
||||
payment.transaction_id = transaction_id
|
||||
payment.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
|
||||
logger.info(f"Связан CryptoBot платеж {invoice_id} с транзакцией {transaction_id}")
|
||||
return payment
|
||||
|
||||
|
||||
async def get_user_cryptobot_payments(
|
||||
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)
|
||||
.order_by(CryptoBotPayment.created_at.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
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)
|
||||
|
||||
result = await db.execute(
|
||||
select(CryptoBotPayment)
|
||||
.options(selectinload(CryptoBotPayment.user))
|
||||
.where(
|
||||
and_(
|
||||
CryptoBotPayment.status == "active",
|
||||
CryptoBotPayment.created_at < cutoff_time
|
||||
)
|
||||
)
|
||||
.order_by(CryptoBotPayment.created_at)
|
||||
)
|
||||
return result.scalars().all()
|
||||
@@ -0,0 +1,275 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Optional
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
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__)
|
||||
|
||||
|
||||
async def upsert_discount_offer(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int,
|
||||
subscription_id: Optional[int],
|
||||
notification_type: str,
|
||||
discount_percent: int,
|
||||
bonus_amount_kopeks: int,
|
||||
valid_hours: int,
|
||||
effect_type: str = "percent_discount",
|
||||
extra_data: Optional[dict] = None,
|
||||
) -> DiscountOffer:
|
||||
"""Create or refresh a discount offer for a user."""
|
||||
|
||||
expires_at = datetime.utcnow() + 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
|
||||
)
|
||||
.order_by(DiscountOffer.created_at.desc())
|
||||
)
|
||||
offer = result.scalars().first()
|
||||
|
||||
if offer and offer.claimed_at is None:
|
||||
offer.discount_percent = discount_percent
|
||||
offer.bonus_amount_kopeks = bonus_amount_kopeks
|
||||
offer.expires_at = expires_at
|
||||
offer.subscription_id = subscription_id
|
||||
offer.effect_type = effect_type
|
||||
offer.extra_data = extra_data
|
||||
else:
|
||||
offer = DiscountOffer(
|
||||
user_id=user_id,
|
||||
subscription_id=subscription_id,
|
||||
notification_type=notification_type,
|
||||
discount_percent=discount_percent,
|
||||
bonus_amount_kopeks=bonus_amount_kopeks,
|
||||
expires_at=expires_at,
|
||||
is_active=True,
|
||||
effect_type=effect_type,
|
||||
extra_data=extra_data,
|
||||
)
|
||||
db.add(offer)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(offer)
|
||||
return offer
|
||||
|
||||
|
||||
async def get_offer_by_id(db: AsyncSession, offer_id: int) -> Optional[DiscountOffer]:
|
||||
result = await db.execute(
|
||||
select(DiscountOffer)
|
||||
.options(
|
||||
selectinload(DiscountOffer.user),
|
||||
selectinload(DiscountOffer.subscription),
|
||||
)
|
||||
.where(DiscountOffer.id == offer_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def list_discount_offers(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
offset: int = 0,
|
||||
limit: int = 50,
|
||||
user_id: Optional[int] = None,
|
||||
notification_type: Optional[str] = None,
|
||||
is_active: Optional[bool] = None,
|
||||
) -> List[DiscountOffer]:
|
||||
stmt = (
|
||||
select(DiscountOffer)
|
||||
.options(
|
||||
selectinload(DiscountOffer.user),
|
||||
selectinload(DiscountOffer.subscription),
|
||||
)
|
||||
.order_by(DiscountOffer.created_at.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
|
||||
if user_id is not None:
|
||||
stmt = stmt.where(DiscountOffer.user_id == user_id)
|
||||
if notification_type:
|
||||
stmt = stmt.where(DiscountOffer.notification_type == notification_type)
|
||||
if is_active is not None:
|
||||
stmt = stmt.where(DiscountOffer.is_active == is_active)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def list_active_discount_offers_for_user(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
) -> List[DiscountOffer]:
|
||||
"""Return active (not yet claimed) offers for a user."""
|
||||
|
||||
now = datetime.utcnow()
|
||||
stmt = (
|
||||
select(DiscountOffer)
|
||||
.options(
|
||||
selectinload(DiscountOffer.user),
|
||||
selectinload(DiscountOffer.subscription),
|
||||
)
|
||||
.where(
|
||||
DiscountOffer.user_id == user_id,
|
||||
DiscountOffer.is_active == True, # noqa: E712
|
||||
DiscountOffer.expires_at > now,
|
||||
)
|
||||
.order_by(DiscountOffer.expires_at.asc())
|
||||
)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def count_discount_offers(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: Optional[int] = None,
|
||||
notification_type: Optional[str] = None,
|
||||
is_active: Optional[bool] = None,
|
||||
) -> int:
|
||||
stmt = select(func.count(DiscountOffer.id))
|
||||
|
||||
if user_id is not None:
|
||||
stmt = stmt.where(DiscountOffer.user_id == user_id)
|
||||
if notification_type:
|
||||
stmt = stmt.where(DiscountOffer.notification_type == notification_type)
|
||||
if is_active is not None:
|
||||
stmt = stmt.where(DiscountOffer.is_active == is_active)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
return int(result.scalar() or 0)
|
||||
|
||||
|
||||
async def mark_offer_claimed(
|
||||
db: AsyncSession,
|
||||
offer: DiscountOffer,
|
||||
*,
|
||||
details: Optional[dict] = None,
|
||||
) -> DiscountOffer:
|
||||
offer.claimed_at = datetime.utcnow()
|
||||
offer.is_active = False
|
||||
await db.commit()
|
||||
await db.refresh(offer)
|
||||
|
||||
try:
|
||||
await log_promo_offer_action(
|
||||
db,
|
||||
user_id=offer.user_id,
|
||||
offer_id=offer.id,
|
||||
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,
|
||||
)
|
||||
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,
|
||||
)
|
||||
|
||||
return offer
|
||||
|
||||
|
||||
async def deactivate_expired_offers(db: AsyncSession) -> int:
|
||||
now = datetime.utcnow()
|
||||
result = await db.execute(
|
||||
select(DiscountOffer).where(
|
||||
DiscountOffer.is_active == True, # noqa: E712
|
||||
DiscountOffer.expires_at < now,
|
||||
)
|
||||
)
|
||||
offers = result.scalars().all()
|
||||
if not offers:
|
||||
return 0
|
||||
|
||||
count = 0
|
||||
log_payloads = []
|
||||
for offer in offers:
|
||||
offer.is_active = False
|
||||
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,
|
||||
}
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
for payload in log_payloads:
|
||||
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"},
|
||||
)
|
||||
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,
|
||||
)
|
||||
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,
|
||||
)
|
||||
|
||||
return count
|
||||
|
||||
|
||||
async def get_latest_claimed_offer_for_user(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
source: Optional[str] = None,
|
||||
) -> Optional[DiscountOffer]:
|
||||
stmt = (
|
||||
select(DiscountOffer)
|
||||
.where(
|
||||
DiscountOffer.user_id == user_id,
|
||||
DiscountOffer.claimed_at.isnot(None),
|
||||
)
|
||||
.order_by(DiscountOffer.claimed_at.desc())
|
||||
)
|
||||
|
||||
if source:
|
||||
stmt = stmt.where(DiscountOffer.notification_type == source)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
return result.scalars().first()
|
||||
@@ -0,0 +1,150 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Iterable, Optional
|
||||
|
||||
from sqlalchemy import delete, func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import FaqPage, FaqSetting
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def get_faq_setting(db: AsyncSession, language: str) -> Optional[FaqSetting]:
|
||||
result = await db.execute(
|
||||
select(FaqSetting).where(FaqSetting.language == language)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def set_faq_enabled(db: AsyncSession, language: str, enabled: bool) -> FaqSetting:
|
||||
setting = await get_faq_setting(db, language)
|
||||
|
||||
if setting:
|
||||
setting.is_enabled = bool(enabled)
|
||||
setting.updated_at = datetime.utcnow()
|
||||
else:
|
||||
setting = FaqSetting(
|
||||
language=language,
|
||||
is_enabled=bool(enabled),
|
||||
)
|
||||
db.add(setting)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(setting)
|
||||
|
||||
logger.info(
|
||||
"✅ Статус FAQ для языка %s обновлен: %s",
|
||||
language,
|
||||
"enabled" if setting.is_enabled else "disabled",
|
||||
)
|
||||
|
||||
return setting
|
||||
|
||||
|
||||
async def upsert_faq_setting(db: AsyncSession, language: str, enabled: bool) -> FaqSetting:
|
||||
return await set_faq_enabled(db, language, enabled)
|
||||
|
||||
|
||||
async def get_faq_pages(
|
||||
db: AsyncSession,
|
||||
language: str,
|
||||
*,
|
||||
include_inactive: bool = False,
|
||||
) -> list[FaqPage]:
|
||||
query = select(FaqPage).where(FaqPage.language == language)
|
||||
|
||||
if not include_inactive:
|
||||
query = query.where(FaqPage.is_active.is_(True))
|
||||
|
||||
query = query.order_by(FaqPage.display_order.asc(), FaqPage.id.asc())
|
||||
|
||||
result = await db.execute(query)
|
||||
pages = list(result.scalars().all())
|
||||
return pages
|
||||
|
||||
|
||||
async def get_faq_page_by_id(db: AsyncSession, page_id: int) -> Optional[FaqPage]:
|
||||
result = await db.execute(select(FaqPage).where(FaqPage.id == page_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def create_faq_page(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
language: str,
|
||||
title: str,
|
||||
content: str,
|
||||
display_order: Optional[int] = 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)
|
||||
)
|
||||
max_order = result.scalar() or 0
|
||||
display_order = max_order + 1
|
||||
|
||||
page = FaqPage(
|
||||
language=language,
|
||||
title=title,
|
||||
content=content,
|
||||
display_order=display_order,
|
||||
is_active=is_active,
|
||||
)
|
||||
|
||||
db.add(page)
|
||||
await db.commit()
|
||||
await db.refresh(page)
|
||||
|
||||
logger.info("✅ Создана страница FAQ %s для языка %s", page.id, language)
|
||||
|
||||
return page
|
||||
|
||||
|
||||
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,
|
||||
) -> FaqPage:
|
||||
if title is not None:
|
||||
page.title = title
|
||||
if content is not None:
|
||||
page.content = content
|
||||
if display_order is not None:
|
||||
page.display_order = display_order
|
||||
if is_active is not None:
|
||||
page.is_active = bool(is_active)
|
||||
|
||||
page.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(page)
|
||||
|
||||
logger.info("✅ Страница FAQ %s обновлена", page.id)
|
||||
|
||||
return 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)
|
||||
|
||||
|
||||
async def bulk_update_order(
|
||||
db: AsyncSession,
|
||||
pages: Iterable[tuple[int, int]],
|
||||
) -> 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())
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import HeleketPayment
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def create_heleket_payment(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int,
|
||||
uuid: str,
|
||||
order_id: str,
|
||||
amount: str,
|
||||
currency: str,
|
||||
status: str,
|
||||
payer_amount: 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,
|
||||
) -> HeleketPayment:
|
||||
payment = HeleketPayment(
|
||||
user_id=user_id,
|
||||
uuid=uuid,
|
||||
order_id=order_id,
|
||||
amount=amount,
|
||||
currency=currency,
|
||||
status=status,
|
||||
payer_amount=payer_amount,
|
||||
payer_currency=payer_currency,
|
||||
exchange_rate=exchange_rate,
|
||||
discount_percent=discount_percent,
|
||||
payment_url=payment_url,
|
||||
expires_at=expires_at,
|
||||
metadata_json=metadata or {},
|
||||
)
|
||||
|
||||
db.add(payment)
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
|
||||
logger.info(
|
||||
"Создан Heleket платеж: uuid=%s order_id=%s amount=%s %s для пользователя %s",
|
||||
uuid,
|
||||
order_id,
|
||||
amount,
|
||||
currency,
|
||||
user_id,
|
||||
)
|
||||
|
||||
return payment
|
||||
|
||||
|
||||
async def get_heleket_payment_by_uuid(
|
||||
db: AsyncSession,
|
||||
uuid: str,
|
||||
) -> Optional[HeleketPayment]:
|
||||
result = await db.execute(
|
||||
select(HeleketPayment)
|
||||
.options(selectinload(HeleketPayment.user))
|
||||
.where(HeleketPayment.uuid == uuid)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_heleket_payment_by_order_id(
|
||||
db: AsyncSession,
|
||||
order_id: str,
|
||||
) -> Optional[HeleketPayment]:
|
||||
result = await db.execute(
|
||||
select(HeleketPayment)
|
||||
.options(selectinload(HeleketPayment.user))
|
||||
.where(HeleketPayment.order_id == order_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_heleket_payment_by_id(
|
||||
db: AsyncSession,
|
||||
payment_id: int,
|
||||
) -> Optional[HeleketPayment]:
|
||||
result = await db.execute(
|
||||
select(HeleketPayment)
|
||||
.options(selectinload(HeleketPayment.user))
|
||||
.where(HeleketPayment.id == payment_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def update_heleket_payment(
|
||||
db: AsyncSession,
|
||||
uuid: str,
|
||||
*,
|
||||
status: 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]:
|
||||
payment = await get_heleket_payment_by_uuid(db, uuid)
|
||||
|
||||
if not payment:
|
||||
logger.error("Heleket платеж с uuid=%s не найден", uuid)
|
||||
return None
|
||||
|
||||
if status is not None:
|
||||
payment.status = status
|
||||
if payer_amount is not None:
|
||||
payment.payer_amount = payer_amount
|
||||
if payer_currency is not None:
|
||||
payment.payer_currency = payer_currency
|
||||
if exchange_rate is not None:
|
||||
payment.exchange_rate = exchange_rate
|
||||
if discount_percent is not None:
|
||||
payment.discount_percent = discount_percent
|
||||
if payment_url is not None:
|
||||
payment.payment_url = payment_url
|
||||
if metadata is not None:
|
||||
existing = dict(payment.metadata_json or {})
|
||||
existing.update(metadata)
|
||||
payment.metadata_json = existing
|
||||
if paid_at is not None:
|
||||
payment.paid_at = paid_at
|
||||
|
||||
payment.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
|
||||
logger.info(
|
||||
"Обновлен Heleket платеж %s: статус=%s payer_amount=%s %s",
|
||||
uuid,
|
||||
payment.status,
|
||||
payment.payer_amount,
|
||||
payment.payer_currency,
|
||||
)
|
||||
|
||||
return payment
|
||||
|
||||
|
||||
async def link_heleket_payment_to_transaction(
|
||||
db: AsyncSession,
|
||||
uuid: str,
|
||||
transaction_id: int,
|
||||
) -> Optional[HeleketPayment]:
|
||||
payment = await get_heleket_payment_by_uuid(db, uuid)
|
||||
|
||||
if not payment:
|
||||
logger.error("Не найден Heleket платеж для связи с транзакцией: %s", uuid)
|
||||
return None
|
||||
|
||||
payment.transaction_id = transaction_id
|
||||
payment.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
|
||||
logger.info(
|
||||
"Heleket платеж %s связан с транзакцией %s",
|
||||
uuid,
|
||||
transaction_id,
|
||||
)
|
||||
|
||||
return payment
|
||||
@@ -0,0 +1,145 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional, Sequence
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import (
|
||||
MainMenuButton,
|
||||
MainMenuButtonActionType,
|
||||
MainMenuButtonVisibility,
|
||||
)
|
||||
|
||||
|
||||
async def count_main_menu_buttons(db: AsyncSession) -> int:
|
||||
result = await db.execute(select(func.count()).select_from(MainMenuButton))
|
||||
return int(result.scalar() or 0)
|
||||
|
||||
|
||||
async def get_main_menu_buttons(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
limit: Optional[int] = None,
|
||||
offset: Optional[int] = None,
|
||||
) -> list[MainMenuButton]:
|
||||
stmt = select(MainMenuButton).order_by(
|
||||
MainMenuButton.display_order.asc(),
|
||||
MainMenuButton.id.asc(),
|
||||
)
|
||||
|
||||
if offset is not None:
|
||||
stmt = stmt.offset(offset)
|
||||
if limit is not None:
|
||||
stmt = stmt.limit(limit)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
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)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_next_display_order(db: AsyncSession) -> int:
|
||||
result = await db.execute(select(func.max(MainMenuButton.display_order)))
|
||||
current_max = result.scalar()
|
||||
return (int(current_max) if current_max is not None else 0) + 1
|
||||
|
||||
|
||||
def _enum_value(value, enum_cls):
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, enum_cls):
|
||||
return value.value
|
||||
return str(value)
|
||||
|
||||
|
||||
async def create_main_menu_button(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
text: str,
|
||||
action_type: MainMenuButtonActionType | str,
|
||||
action_value: str,
|
||||
visibility: MainMenuButtonVisibility | str = MainMenuButtonVisibility.ALL,
|
||||
is_active: bool = True,
|
||||
display_order: Optional[int] = None,
|
||||
) -> MainMenuButton:
|
||||
if display_order is None:
|
||||
display_order = await get_next_display_order(db)
|
||||
|
||||
button = MainMenuButton(
|
||||
text=text,
|
||||
action_type=_enum_value(action_type, MainMenuButtonActionType),
|
||||
action_value=action_value,
|
||||
visibility=_enum_value(visibility, MainMenuButtonVisibility)
|
||||
or MainMenuButtonVisibility.ALL.value,
|
||||
is_active=bool(is_active),
|
||||
display_order=int(display_order),
|
||||
)
|
||||
|
||||
db.add(button)
|
||||
await db.commit()
|
||||
await db.refresh(button)
|
||||
return button
|
||||
|
||||
|
||||
async def update_main_menu_button(
|
||||
db: AsyncSession,
|
||||
button: MainMenuButton,
|
||||
*,
|
||||
text: Optional[str] = None,
|
||||
action_type: MainMenuButtonActionType | str | None = None,
|
||||
action_value: Optional[str] = None,
|
||||
visibility: MainMenuButtonVisibility | str | None = None,
|
||||
is_active: Optional[bool] = None,
|
||||
display_order: Optional[int] = None,
|
||||
) -> MainMenuButton:
|
||||
if text is not None:
|
||||
button.text = text
|
||||
if action_type is not None:
|
||||
button.action_type = _enum_value(action_type, MainMenuButtonActionType)
|
||||
if action_value is not None:
|
||||
button.action_value = action_value
|
||||
if visibility is not None:
|
||||
button.visibility = _enum_value(visibility, MainMenuButtonVisibility)
|
||||
if is_active is not None:
|
||||
button.is_active = bool(is_active)
|
||||
if display_order is not None:
|
||||
button.display_order = int(display_order)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(button)
|
||||
return button
|
||||
|
||||
|
||||
async def delete_main_menu_button(db: AsyncSession, button: MainMenuButton) -> None:
|
||||
await db.delete(button)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def reorder_main_menu_buttons(
|
||||
db: AsyncSession,
|
||||
ordered_ids: Sequence[int],
|
||||
) -> None:
|
||||
if not ordered_ids:
|
||||
return
|
||||
|
||||
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()))
|
||||
)
|
||||
buttons = result.scalars().all()
|
||||
|
||||
for button in buttons:
|
||||
desired_order = order_map.get(button.id)
|
||||
if desired_order is not None:
|
||||
button.display_order = desired_order
|
||||
|
||||
await db.commit()
|
||||
@@ -0,0 +1,120 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import MulenPayPayment
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def create_mulenpay_payment(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int,
|
||||
amount_kopeks: int,
|
||||
uuid: str,
|
||||
description: str,
|
||||
payment_url: Optional[str],
|
||||
mulen_payment_id: Optional[int],
|
||||
currency: str,
|
||||
status: str,
|
||||
metadata: Optional[dict] = None,
|
||||
) -> MulenPayPayment:
|
||||
payment = MulenPayPayment(
|
||||
user_id=user_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
uuid=uuid,
|
||||
description=description,
|
||||
payment_url=payment_url,
|
||||
mulen_payment_id=mulen_payment_id,
|
||||
currency=currency,
|
||||
status=status,
|
||||
metadata_json=metadata or {},
|
||||
)
|
||||
|
||||
db.add(payment)
|
||||
await db.commit()
|
||||
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,
|
||||
)
|
||||
|
||||
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)
|
||||
)
|
||||
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)
|
||||
)
|
||||
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
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def update_mulenpay_payment_status(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
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,
|
||||
) -> MulenPayPayment:
|
||||
payment.status = status
|
||||
if is_paid is not None:
|
||||
payment.is_paid = is_paid
|
||||
if paid_at:
|
||||
payment.paid_at = paid_at
|
||||
if callback_payload is not None:
|
||||
payment.callback_payload = callback_payload
|
||||
if mulen_payment_id is not None and not payment.mulen_payment_id:
|
||||
payment.mulen_payment_id = mulen_payment_id
|
||||
|
||||
payment.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
return payment
|
||||
|
||||
|
||||
async def link_mulenpay_payment_to_transaction(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
payment: MulenPayPayment,
|
||||
transaction_id: int,
|
||||
) -> MulenPayPayment:
|
||||
payment.transaction_id = transaction_id
|
||||
payment.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
return payment
|
||||
@@ -0,0 +1,66 @@
|
||||
import logging
|
||||
from typing import Optional
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, delete
|
||||
|
||||
from app.database.models import SentNotification
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def notification_sent(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
subscription_id: int,
|
||||
notification_type: str,
|
||||
days_before: Optional[int] = None,
|
||||
) -> bool:
|
||||
result = await db.execute(
|
||||
select(SentNotification).where(
|
||||
SentNotification.user_id == user_id,
|
||||
SentNotification.subscription_id == subscription_id,
|
||||
SentNotification.notification_type == notification_type,
|
||||
SentNotification.days_before == days_before,
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none() is not None
|
||||
|
||||
|
||||
async def record_notification(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
subscription_id: int,
|
||||
notification_type: str,
|
||||
days_before: Optional[int] = None,
|
||||
) -> None:
|
||||
notification = SentNotification(
|
||||
user_id=user_id,
|
||||
subscription_id=subscription_id,
|
||||
notification_type=notification_type,
|
||||
days_before=days_before,
|
||||
)
|
||||
db.add(notification)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def clear_notifications(db: AsyncSession, subscription_id: int) -> None:
|
||||
await db.execute(
|
||||
delete(SentNotification).where(
|
||||
SentNotification.subscription_id == subscription_id
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def clear_notification_by_type(
|
||||
db: AsyncSession,
|
||||
subscription_id: int,
|
||||
notification_type: str,
|
||||
) -> None:
|
||||
await db.execute(
|
||||
delete(SentNotification).where(
|
||||
SentNotification.subscription_id == subscription_id,
|
||||
SentNotification.notification_type == notification_type,
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
@@ -0,0 +1,164 @@
|
||||
"""CRUD helpers for PayPalych (Pal24) payments."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import Pal24Payment
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def create_pal24_payment(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int,
|
||||
bill_id: str,
|
||||
amount_kopeks: int,
|
||||
description: Optional[str],
|
||||
status: str,
|
||||
type_: str,
|
||||
currency: str,
|
||||
link_url: Optional[str],
|
||||
link_page_url: Optional[str],
|
||||
order_id: Optional[str] = None,
|
||||
ttl: Optional[int] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> Pal24Payment:
|
||||
payment = Pal24Payment(
|
||||
user_id=user_id,
|
||||
bill_id=bill_id,
|
||||
order_id=order_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
currency=currency,
|
||||
description=description,
|
||||
status=status,
|
||||
type=type_,
|
||||
link_url=link_url,
|
||||
link_page_url=link_page_url,
|
||||
metadata_json=metadata or {},
|
||||
ttl=ttl,
|
||||
)
|
||||
|
||||
db.add(payment)
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
|
||||
logger.info(
|
||||
"Создан Pal24 платеж #%s для пользователя %s: %s копеек (статус %s)",
|
||||
payment.id,
|
||||
user_id,
|
||||
amount_kopeks,
|
||||
status,
|
||||
)
|
||||
|
||||
return payment
|
||||
|
||||
|
||||
async def get_pal24_payment_by_id(db: AsyncSession, payment_id: int) -> Optional[Pal24Payment]:
|
||||
result = await db.execute(
|
||||
select(Pal24Payment).where(Pal24Payment.id == payment_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_pal24_payment_by_bill_id(db: AsyncSession, bill_id: str) -> Optional[Pal24Payment]:
|
||||
result = await db.execute(
|
||||
select(Pal24Payment).where(Pal24Payment.bill_id == bill_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_pal24_payment_by_order_id(db: AsyncSession, order_id: str) -> Optional[Pal24Payment]:
|
||||
result = await db.execute(
|
||||
select(Pal24Payment).where(Pal24Payment.order_id == order_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def update_pal24_payment_status(
|
||||
db: AsyncSession,
|
||||
payment: Pal24Payment,
|
||||
*,
|
||||
status: str,
|
||||
is_active: Optional[bool] = None,
|
||||
is_paid: Optional[bool] = None,
|
||||
paid_at: Optional[datetime] = None,
|
||||
payment_id: Optional[str] = None,
|
||||
payment_status: Optional[str] = None,
|
||||
payment_method: Optional[str] = None,
|
||||
balance_amount: Optional[str] = None,
|
||||
balance_currency: Optional[str] = None,
|
||||
payer_account: Optional[str] = None,
|
||||
callback_payload: Optional[Dict[str, Any]] = None,
|
||||
) -> Pal24Payment:
|
||||
update_values: Dict[str, Any] = {
|
||||
"status": status,
|
||||
}
|
||||
|
||||
if is_active is not None:
|
||||
update_values["is_active"] = is_active
|
||||
if is_paid is not None:
|
||||
update_values["is_paid"] = is_paid
|
||||
if paid_at is not None:
|
||||
update_values["paid_at"] = paid_at
|
||||
if payment_id is not None:
|
||||
update_values["payment_id"] = payment_id
|
||||
if payment_status is not None:
|
||||
update_values["payment_status"] = payment_status
|
||||
if payment_method is not None:
|
||||
update_values["payment_method"] = payment_method
|
||||
if balance_amount is not None:
|
||||
update_values["balance_amount"] = balance_amount
|
||||
if balance_currency is not None:
|
||||
update_values["balance_currency"] = balance_currency
|
||||
if payer_account is not None:
|
||||
update_values["payer_account"] = payer_account
|
||||
if callback_payload is not None:
|
||||
update_values["callback_payload"] = callback_payload
|
||||
|
||||
update_values["last_status"] = status
|
||||
|
||||
await db.execute(
|
||||
update(Pal24Payment)
|
||||
.where(Pal24Payment.id == payment.id)
|
||||
.values(**update_values)
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
|
||||
logger.info(
|
||||
"Обновлен Pal24 платеж %s: статус=%s, is_paid=%s",
|
||||
payment.bill_id,
|
||||
payment.status,
|
||||
payment.is_paid,
|
||||
)
|
||||
|
||||
return payment
|
||||
|
||||
|
||||
async def link_pal24_payment_to_transaction(
|
||||
db: AsyncSession,
|
||||
payment: Pal24Payment,
|
||||
transaction_id: int,
|
||||
) -> Pal24Payment:
|
||||
await db.execute(
|
||||
update(Pal24Payment)
|
||||
.where(Pal24Payment.id == payment.id)
|
||||
.values(transaction_id=transaction_id)
|
||||
)
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
logger.info(
|
||||
"Pal24 платеж %s привязан к транзакции %s",
|
||||
payment.bill_id,
|
||||
transaction_id,
|
||||
)
|
||||
return payment
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
"""CRUD-операции для платежей Platega."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import PlategaPayment
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def create_platega_payment(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int,
|
||||
amount_kopeks: int,
|
||||
currency: str,
|
||||
description: Optional[str],
|
||||
status: str,
|
||||
payment_method_code: int,
|
||||
correlation_id: str,
|
||||
platega_transaction_id: Optional[str],
|
||||
redirect_url: Optional[str],
|
||||
return_url: Optional[str],
|
||||
failed_url: Optional[str],
|
||||
payload: Optional[str],
|
||||
metadata: Optional[dict[str, Any]] = None,
|
||||
expires_at: Optional[datetime] = None,
|
||||
) -> PlategaPayment:
|
||||
payment = PlategaPayment(
|
||||
user_id=user_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
currency=currency,
|
||||
description=description,
|
||||
status=status,
|
||||
payment_method_code=payment_method_code,
|
||||
correlation_id=correlation_id,
|
||||
platega_transaction_id=platega_transaction_id,
|
||||
redirect_url=redirect_url,
|
||||
return_url=return_url,
|
||||
failed_url=failed_url,
|
||||
payload=payload,
|
||||
metadata_json=metadata or {},
|
||||
expires_at=expires_at,
|
||||
)
|
||||
|
||||
db.add(payment)
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
|
||||
logger.info(
|
||||
"Создан Platega платеж #%s (tx=%s) на сумму %s копеек для пользователя %s",
|
||||
payment.id,
|
||||
platega_transaction_id,
|
||||
amount_kopeks,
|
||||
user_id,
|
||||
)
|
||||
|
||||
return payment
|
||||
|
||||
|
||||
async def get_platega_payment_by_id(
|
||||
db: AsyncSession, payment_id: int
|
||||
) -> Optional[PlategaPayment]:
|
||||
result = await db.execute(
|
||||
select(PlategaPayment).where(PlategaPayment.id == payment_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_platega_payment_by_id_for_update(
|
||||
db: AsyncSession, payment_id: int
|
||||
) -> Optional[PlategaPayment]:
|
||||
result = await db.execute(
|
||||
select(PlategaPayment)
|
||||
.where(PlategaPayment.id == payment_id)
|
||||
.with_for_update()
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_platega_payment_by_transaction_id(
|
||||
db: AsyncSession, transaction_id: str
|
||||
) -> Optional[PlategaPayment]:
|
||||
result = await db.execute(
|
||||
select(PlategaPayment).where(
|
||||
PlategaPayment.platega_transaction_id == transaction_id
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_platega_payment_by_correlation_id(
|
||||
db: AsyncSession, correlation_id: str
|
||||
) -> Optional[PlategaPayment]:
|
||||
result = await db.execute(
|
||||
select(PlategaPayment).where(
|
||||
PlategaPayment.correlation_id == correlation_id
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def update_platega_payment(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
payment: PlategaPayment,
|
||||
status: Optional[str] = None,
|
||||
is_paid: Optional[bool] = None,
|
||||
paid_at: Optional[datetime] = None,
|
||||
platega_transaction_id: Optional[str] = None,
|
||||
redirect_url: Optional[str] = None,
|
||||
callback_payload: Optional[dict[str, Any]] = None,
|
||||
metadata: Optional[dict[str, Any]] = None,
|
||||
expires_at: Optional[datetime] = None,
|
||||
) -> PlategaPayment:
|
||||
if status is not None:
|
||||
payment.status = status
|
||||
if is_paid is not None:
|
||||
payment.is_paid = is_paid
|
||||
if paid_at is not None:
|
||||
payment.paid_at = paid_at
|
||||
if platega_transaction_id and not payment.platega_transaction_id:
|
||||
payment.platega_transaction_id = platega_transaction_id
|
||||
if redirect_url is not None:
|
||||
payment.redirect_url = redirect_url
|
||||
if callback_payload is not None:
|
||||
payment.callback_payload = callback_payload
|
||||
if metadata is not None:
|
||||
payment.metadata_json = metadata
|
||||
if expires_at is not None:
|
||||
payment.expires_at = expires_at
|
||||
|
||||
payment.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
return payment
|
||||
|
||||
|
||||
async def link_platega_payment_to_transaction(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
payment: PlategaPayment,
|
||||
transaction_id: int,
|
||||
) -> PlategaPayment:
|
||||
payment.transaction_id = transaction_id
|
||||
payment.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
return payment
|
||||
@@ -0,0 +1,297 @@
|
||||
import logging
|
||||
from typing import Iterable, Sequence
|
||||
|
||||
from sqlalchemy import and_, delete, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import (
|
||||
Poll,
|
||||
PollAnswer,
|
||||
PollOption,
|
||||
PollQuestion,
|
||||
PollResponse,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def create_poll(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
title: str,
|
||||
description: str | None,
|
||||
reward_enabled: bool,
|
||||
reward_amount_kopeks: int,
|
||||
created_by: int | None,
|
||||
questions: Sequence[dict[str, Iterable[str]]],
|
||||
) -> Poll:
|
||||
poll = Poll(
|
||||
title=title,
|
||||
description=description,
|
||||
reward_enabled=reward_enabled,
|
||||
reward_amount_kopeks=reward_amount_kopeks if reward_enabled else 0,
|
||||
created_by=created_by,
|
||||
)
|
||||
db.add(poll)
|
||||
await db.flush()
|
||||
|
||||
for order, question_data in enumerate(questions, start=1):
|
||||
question_text = question_data.get("text", "").strip()
|
||||
if not question_text:
|
||||
continue
|
||||
|
||||
question = PollQuestion(
|
||||
poll_id=poll.id,
|
||||
text=question_text,
|
||||
order=order,
|
||||
)
|
||||
db.add(question)
|
||||
await db.flush()
|
||||
|
||||
for option_order, option_text in enumerate(question_data.get("options", []), start=1):
|
||||
option_text = option_text.strip()
|
||||
if not option_text:
|
||||
continue
|
||||
option = PollOption(
|
||||
question_id=question.id,
|
||||
text=option_text,
|
||||
order=option_order,
|
||||
)
|
||||
db.add(option)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(
|
||||
poll,
|
||||
attribute_names=["questions"],
|
||||
)
|
||||
return poll
|
||||
|
||||
|
||||
async def list_polls(db: AsyncSession) -> list[Poll]:
|
||||
result = await db.execute(
|
||||
select(Poll)
|
||||
.options(
|
||||
selectinload(Poll.questions).options(selectinload(PollQuestion.options))
|
||||
)
|
||||
.order_by(Poll.created_at.desc())
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def get_poll_by_id(db: AsyncSession, poll_id: int) -> Poll | None:
|
||||
result = await db.execute(
|
||||
select(Poll)
|
||||
.options(
|
||||
selectinload(Poll.questions).options(selectinload(PollQuestion.options)),
|
||||
selectinload(Poll.responses),
|
||||
)
|
||||
.where(Poll.id == poll_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def delete_poll(db: AsyncSession, poll_id: int) -> bool:
|
||||
poll = await db.get(Poll, poll_id)
|
||||
if not poll:
|
||||
return False
|
||||
|
||||
await db.delete(poll)
|
||||
await db.commit()
|
||||
logger.info("🗑️ Удалён опрос %s", poll_id)
|
||||
return True
|
||||
|
||||
|
||||
async def create_poll_response(
|
||||
db: AsyncSession,
|
||||
poll_id: int,
|
||||
user_id: int,
|
||||
) -> PollResponse:
|
||||
result = await db.execute(
|
||||
select(PollResponse)
|
||||
.where(
|
||||
and_(
|
||||
PollResponse.poll_id == poll_id,
|
||||
PollResponse.user_id == user_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
response = result.scalar_one_or_none()
|
||||
if response:
|
||||
return response
|
||||
|
||||
response = PollResponse(
|
||||
poll_id=poll_id,
|
||||
user_id=user_id,
|
||||
)
|
||||
db.add(response)
|
||||
await db.commit()
|
||||
await db.refresh(response)
|
||||
return response
|
||||
|
||||
|
||||
async def get_poll_response_by_id(
|
||||
db: AsyncSession,
|
||||
response_id: int,
|
||||
) -> PollResponse | None:
|
||||
result = await db.execute(
|
||||
select(PollResponse)
|
||||
.options(
|
||||
selectinload(PollResponse.poll)
|
||||
.options(selectinload(Poll.questions).options(selectinload(PollQuestion.options))),
|
||||
selectinload(PollResponse.answers),
|
||||
selectinload(PollResponse.user),
|
||||
)
|
||||
.where(PollResponse.id == response_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def record_poll_answer(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
response_id: int,
|
||||
question_id: int,
|
||||
option_id: int,
|
||||
) -> PollAnswer:
|
||||
result = await db.execute(
|
||||
select(PollAnswer)
|
||||
.where(
|
||||
and_(
|
||||
PollAnswer.response_id == response_id,
|
||||
PollAnswer.question_id == question_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
answer = result.scalar_one_or_none()
|
||||
if answer:
|
||||
answer.option_id = option_id
|
||||
await db.commit()
|
||||
await db.refresh(answer)
|
||||
return answer
|
||||
|
||||
answer = PollAnswer(
|
||||
response_id=response_id,
|
||||
question_id=question_id,
|
||||
option_id=option_id,
|
||||
)
|
||||
db.add(answer)
|
||||
await db.commit()
|
||||
await db.refresh(answer)
|
||||
return answer
|
||||
|
||||
|
||||
async def reset_poll_answers(db: AsyncSession, response_id: int) -> None:
|
||||
await db.execute(
|
||||
delete(PollAnswer).where(PollAnswer.response_id == response_id)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def get_poll_statistics(db: AsyncSession, poll_id: int) -> dict:
|
||||
totals_result = await db.execute(
|
||||
select(
|
||||
func.count(PollResponse.id),
|
||||
func.count(PollResponse.completed_at),
|
||||
func.coalesce(func.sum(PollResponse.reward_amount_kopeks), 0),
|
||||
).where(PollResponse.poll_id == poll_id)
|
||||
)
|
||||
total_responses, completed_responses, reward_sum = totals_result.one()
|
||||
|
||||
option_counts_result = await db.execute(
|
||||
select(
|
||||
PollQuestion.id,
|
||||
PollQuestion.text,
|
||||
PollQuestion.order,
|
||||
PollOption.id,
|
||||
PollOption.text,
|
||||
PollOption.order,
|
||||
func.count(PollAnswer.id),
|
||||
)
|
||||
.join(PollOption, PollOption.question_id == PollQuestion.id)
|
||||
.outerjoin(
|
||||
PollAnswer,
|
||||
and_(
|
||||
PollAnswer.question_id == PollQuestion.id,
|
||||
PollAnswer.option_id == PollOption.id,
|
||||
),
|
||||
)
|
||||
.where(PollQuestion.poll_id == poll_id)
|
||||
.group_by(
|
||||
PollQuestion.id,
|
||||
PollQuestion.text,
|
||||
PollQuestion.order,
|
||||
PollOption.id,
|
||||
PollOption.text,
|
||||
PollOption.order,
|
||||
)
|
||||
.order_by(PollQuestion.order.asc(), PollOption.order.asc())
|
||||
)
|
||||
|
||||
questions_map: dict[int, dict] = {}
|
||||
for (
|
||||
question_id,
|
||||
question_text,
|
||||
question_order,
|
||||
option_id,
|
||||
option_text,
|
||||
option_order,
|
||||
answer_count,
|
||||
) in option_counts_result:
|
||||
question_entry = questions_map.setdefault(
|
||||
question_id,
|
||||
{
|
||||
"id": question_id,
|
||||
"text": question_text,
|
||||
"order": question_order,
|
||||
"options": [],
|
||||
},
|
||||
)
|
||||
question_entry["options"].append(
|
||||
{
|
||||
"id": option_id,
|
||||
"text": option_text,
|
||||
"count": answer_count,
|
||||
}
|
||||
)
|
||||
|
||||
questions = sorted(questions_map.values(), key=lambda item: item["order"])
|
||||
|
||||
return {
|
||||
"total_responses": total_responses,
|
||||
"completed_responses": completed_responses,
|
||||
"reward_sum_kopeks": reward_sum,
|
||||
"questions": questions,
|
||||
}
|
||||
|
||||
|
||||
async def get_poll_responses_with_answers(
|
||||
db: AsyncSession,
|
||||
poll_id: int,
|
||||
*,
|
||||
limit: int,
|
||||
offset: int,
|
||||
) -> tuple[list[PollResponse], int]:
|
||||
total_result = await db.execute(
|
||||
select(func.count()).select_from(PollResponse).where(PollResponse.poll_id == poll_id)
|
||||
)
|
||||
total = int(total_result.scalar_one() or 0)
|
||||
|
||||
if total == 0:
|
||||
return [], 0
|
||||
|
||||
result = await db.execute(
|
||||
select(PollResponse)
|
||||
.options(
|
||||
selectinload(PollResponse.user),
|
||||
selectinload(PollResponse.answers).selectinload(PollAnswer.question),
|
||||
selectinload(PollResponse.answers).selectinload(PollAnswer.option),
|
||||
)
|
||||
.where(PollResponse.poll_id == poll_id)
|
||||
.order_by(PollResponse.sent_at.asc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
|
||||
responses = result.scalars().unique().all()
|
||||
return responses, total
|
||||
@@ -0,0 +1,79 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import PrivacyPolicy
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def get_privacy_policy(db: AsyncSession, language: str) -> Optional[PrivacyPolicy]:
|
||||
result = await db.execute(
|
||||
select(PrivacyPolicy).where(PrivacyPolicy.language == language)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def upsert_privacy_policy(
|
||||
db: AsyncSession,
|
||||
language: str,
|
||||
content: str,
|
||||
*,
|
||||
enable_if_new: bool = True,
|
||||
) -> PrivacyPolicy:
|
||||
policy = await get_privacy_policy(db, language)
|
||||
|
||||
if policy:
|
||||
policy.content = content or ""
|
||||
policy.updated_at = datetime.utcnow()
|
||||
else:
|
||||
policy = PrivacyPolicy(
|
||||
language=language,
|
||||
content=content or "",
|
||||
is_enabled=True if enable_if_new else False,
|
||||
)
|
||||
db.add(policy)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(policy)
|
||||
|
||||
logger.info(
|
||||
"✅ Политика конфиденциальности для языка %s обновлена (ID: %s)",
|
||||
language,
|
||||
policy.id,
|
||||
)
|
||||
|
||||
return policy
|
||||
|
||||
|
||||
async def set_privacy_policy_enabled(
|
||||
db: AsyncSession,
|
||||
language: str,
|
||||
enabled: bool,
|
||||
) -> PrivacyPolicy:
|
||||
policy = await get_privacy_policy(db, language)
|
||||
|
||||
if policy:
|
||||
policy.is_enabled = bool(enabled)
|
||||
policy.updated_at = datetime.utcnow()
|
||||
else:
|
||||
policy = PrivacyPolicy(
|
||||
language=language,
|
||||
content="",
|
||||
is_enabled=bool(enabled),
|
||||
)
|
||||
db.add(policy)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(policy)
|
||||
|
||||
logger.info(
|
||||
"✅ Статус политики конфиденциальности для языка %s обновлен: %s",
|
||||
language,
|
||||
"enabled" if policy.is_enabled else "disabled",
|
||||
)
|
||||
|
||||
return policy
|
||||
@@ -0,0 +1,306 @@
|
||||
import logging
|
||||
from typing import Dict, List, Optional, Set, Tuple
|
||||
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import PromoGroup, User, UserPromoGroup
|
||||
|
||||
|
||||
def _normalize_period_discounts(period_discounts: Optional[Dict[int, int]]) -> Dict[int, int]:
|
||||
if not period_discounts:
|
||||
return {}
|
||||
|
||||
normalized: Dict[int, int] = {}
|
||||
|
||||
for key, value in period_discounts.items():
|
||||
try:
|
||||
period = int(key)
|
||||
percent = int(value)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
|
||||
normalized[period] = max(0, min(100, percent))
|
||||
|
||||
return normalized
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def get_promo_groups_with_counts(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
offset: int = 0,
|
||||
limit: Optional[int] = None,
|
||||
) -> List[Tuple[PromoGroup, int]]:
|
||||
query = (
|
||||
select(PromoGroup, func.count(User.id))
|
||||
.outerjoin(User, User.promo_group_id == PromoGroup.id)
|
||||
.group_by(PromoGroup.id)
|
||||
.order_by(PromoGroup.priority.desc(), PromoGroup.name)
|
||||
)
|
||||
|
||||
if offset:
|
||||
query = query.offset(offset)
|
||||
if limit is not None:
|
||||
query = query.limit(limit)
|
||||
|
||||
result = await db.execute(query)
|
||||
return result.all()
|
||||
|
||||
|
||||
async def get_auto_assign_promo_groups(db: AsyncSession) -> List[PromoGroup]:
|
||||
result = await db.execute(
|
||||
select(PromoGroup)
|
||||
.where(PromoGroup.auto_assign_total_spent_kopeks.is_not(None))
|
||||
.where(PromoGroup.auto_assign_total_spent_kopeks > 0)
|
||||
.order_by(PromoGroup.auto_assign_total_spent_kopeks, PromoGroup.id)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def has_auto_assign_promo_groups(db: AsyncSession) -> bool:
|
||||
result = await db.execute(
|
||||
select(func.count(PromoGroup.id))
|
||||
.where(PromoGroup.auto_assign_total_spent_kopeks.is_not(None))
|
||||
.where(PromoGroup.auto_assign_total_spent_kopeks > 0)
|
||||
)
|
||||
return bool(result.scalar_one())
|
||||
|
||||
|
||||
async def get_promo_group_by_id(db: AsyncSession, group_id: int) -> Optional[PromoGroup]:
|
||||
return await db.get(PromoGroup, group_id)
|
||||
|
||||
|
||||
async def count_promo_groups(db: AsyncSession) -> int:
|
||||
result = await db.execute(select(func.count(PromoGroup.id)))
|
||||
return int(result.scalar_one())
|
||||
|
||||
|
||||
async def get_default_promo_group(db: AsyncSession) -> Optional[PromoGroup]:
|
||||
result = await db.execute(
|
||||
select(PromoGroup).where(PromoGroup.is_default.is_(True))
|
||||
)
|
||||
return result.scalars().first()
|
||||
|
||||
|
||||
async def create_promo_group(
|
||||
db: AsyncSession,
|
||||
name: str,
|
||||
*,
|
||||
priority: int = 0,
|
||||
server_discount_percent: int,
|
||||
traffic_discount_percent: int,
|
||||
device_discount_percent: int,
|
||||
period_discounts: Optional[Dict[int, int]] = None,
|
||||
auto_assign_total_spent_kopeks: Optional[int] = None,
|
||||
apply_discounts_to_addons: bool = True,
|
||||
is_default: bool = False,
|
||||
) -> PromoGroup:
|
||||
normalized_period_discounts = _normalize_period_discounts(period_discounts)
|
||||
|
||||
auto_assign_total_spent_kopeks = (
|
||||
max(0, auto_assign_total_spent_kopeks)
|
||||
if auto_assign_total_spent_kopeks is not None
|
||||
else None
|
||||
)
|
||||
|
||||
existing_default = await get_default_promo_group(db)
|
||||
should_be_default = existing_default is None or is_default
|
||||
|
||||
promo_group = PromoGroup(
|
||||
name=name.strip(),
|
||||
priority=max(0, priority),
|
||||
server_discount_percent=max(0, min(100, server_discount_percent)),
|
||||
traffic_discount_percent=max(0, min(100, traffic_discount_percent)),
|
||||
device_discount_percent=max(0, min(100, device_discount_percent)),
|
||||
period_discounts=normalized_period_discounts or None,
|
||||
auto_assign_total_spent_kopeks=auto_assign_total_spent_kopeks,
|
||||
apply_discounts_to_addons=bool(apply_discounts_to_addons),
|
||||
is_default=should_be_default,
|
||||
)
|
||||
|
||||
db.add(promo_group)
|
||||
await db.flush()
|
||||
|
||||
if should_be_default and existing_default and existing_default.id != promo_group.id:
|
||||
await db.execute(
|
||||
update(PromoGroup)
|
||||
.where(PromoGroup.id != promo_group.id)
|
||||
.values(is_default=False)
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(promo_group)
|
||||
|
||||
logger.info(
|
||||
"Создана промогруппа '%s' (default=%s) с скидками (servers=%s%%, traffic=%s%%, devices=%s%%, periods=%s) и порогом автоприсвоения %s₽, скидки на доп. услуги: %s",
|
||||
promo_group.name,
|
||||
promo_group.is_default,
|
||||
promo_group.server_discount_percent,
|
||||
promo_group.traffic_discount_percent,
|
||||
promo_group.device_discount_percent,
|
||||
normalized_period_discounts,
|
||||
(auto_assign_total_spent_kopeks or 0) / 100,
|
||||
"on" if promo_group.apply_discounts_to_addons else "off",
|
||||
)
|
||||
|
||||
return promo_group
|
||||
|
||||
|
||||
async def update_promo_group(
|
||||
db: AsyncSession,
|
||||
group: PromoGroup,
|
||||
*,
|
||||
name: Optional[str] = None,
|
||||
priority: Optional[int] = 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,
|
||||
) -> PromoGroup:
|
||||
if name is not None:
|
||||
group.name = name.strip()
|
||||
if priority is not None:
|
||||
group.priority = max(0, priority)
|
||||
if server_discount_percent is not None:
|
||||
group.server_discount_percent = max(0, min(100, server_discount_percent))
|
||||
if traffic_discount_percent is not None:
|
||||
group.traffic_discount_percent = max(0, min(100, traffic_discount_percent))
|
||||
if device_discount_percent is not None:
|
||||
group.device_discount_percent = max(0, min(100, device_discount_percent))
|
||||
if period_discounts is not None:
|
||||
normalized_period_discounts = _normalize_period_discounts(period_discounts)
|
||||
group.period_discounts = normalized_period_discounts or None
|
||||
if auto_assign_total_spent_kopeks is not None:
|
||||
group.auto_assign_total_spent_kopeks = max(0, auto_assign_total_spent_kopeks)
|
||||
if apply_discounts_to_addons is not None:
|
||||
group.apply_discounts_to_addons = bool(apply_discounts_to_addons)
|
||||
|
||||
if is_default is not None:
|
||||
if is_default:
|
||||
group.is_default = True
|
||||
await db.flush()
|
||||
await db.execute(
|
||||
update(PromoGroup)
|
||||
.where(PromoGroup.id != group.id)
|
||||
.values(is_default=False)
|
||||
)
|
||||
else:
|
||||
if group.is_default:
|
||||
group.is_default = False
|
||||
await db.flush()
|
||||
replacement = await db.execute(
|
||||
select(PromoGroup)
|
||||
.where(PromoGroup.id != group.id)
|
||||
.order_by(PromoGroup.id)
|
||||
.limit(1)
|
||||
)
|
||||
new_default = replacement.scalars().first()
|
||||
if new_default:
|
||||
await db.execute(
|
||||
update(PromoGroup)
|
||||
.where(PromoGroup.id == new_default.id)
|
||||
.values(is_default=True)
|
||||
)
|
||||
else:
|
||||
# Не допускаем состояния без базовой промогруппы
|
||||
group.is_default = True
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(group)
|
||||
|
||||
logger.info(
|
||||
"Обновлена промогруппа '%s' (id=%s)",
|
||||
group.name,
|
||||
group.id,
|
||||
)
|
||||
return group
|
||||
|
||||
|
||||
async def delete_promo_group(db: AsyncSession, group: PromoGroup) -> bool:
|
||||
if group.is_default:
|
||||
logger.warning("Попытка удалить базовую промогруппу запрещена")
|
||||
return False
|
||||
|
||||
default_group = await get_default_promo_group(db)
|
||||
if not default_group:
|
||||
logger.error("Не найдена базовая промогруппа для reassignment")
|
||||
return False
|
||||
|
||||
|
||||
# Получаем список пользователей, связанных с удаляемой промогруппой
|
||||
affected_user_ids: Set[int] = set()
|
||||
|
||||
user_ids_result = await db.execute(
|
||||
select(User.id).where(User.promo_group_id == group.id)
|
||||
)
|
||||
affected_user_ids.update(user_ids_result.scalars().all())
|
||||
|
||||
promo_group_links_result = await db.execute(
|
||||
select(UserPromoGroup.user_id).where(UserPromoGroup.promo_group_id == group.id)
|
||||
)
|
||||
affected_user_ids.update(promo_group_links_result.scalars().all())
|
||||
|
||||
await db.execute(
|
||||
update(User)
|
||||
.where(User.promo_group_id == group.id)
|
||||
.values(promo_group_id=default_group.id)
|
||||
)
|
||||
|
||||
if affected_user_ids:
|
||||
existing_defaults_result = await db.execute(
|
||||
select(UserPromoGroup.user_id)
|
||||
.where(UserPromoGroup.promo_group_id == default_group.id)
|
||||
.where(UserPromoGroup.user_id.in_(affected_user_ids))
|
||||
)
|
||||
existing_default_user_ids = set(existing_defaults_result.scalars().all())
|
||||
|
||||
for user_id in affected_user_ids - existing_default_user_ids:
|
||||
db.add(
|
||||
UserPromoGroup(
|
||||
user_id=user_id,
|
||||
promo_group_id=default_group.id,
|
||||
assigned_by="system",
|
||||
)
|
||||
)
|
||||
|
||||
await db.delete(group)
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
"Промогруппа '%s' (id=%s) удалена, пользователи переведены в '%s'",
|
||||
group.name,
|
||||
group.id,
|
||||
default_group.name,
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
async def get_promo_group_members(
|
||||
db: AsyncSession,
|
||||
group_id: int,
|
||||
*,
|
||||
offset: int = 0,
|
||||
limit: int = 20,
|
||||
) -> List[User]:
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.options(selectinload(User.subscription))
|
||||
.where(User.promo_group_id == group_id)
|
||||
.order_by(User.created_at.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def count_promo_group_members(db: AsyncSession, group_id: int) -> int:
|
||||
result = await db.execute(
|
||||
select(func.count(User.id)).where(User.promo_group_id == group_id)
|
||||
)
|
||||
return result.scalar_one()
|
||||
@@ -0,0 +1,96 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import PromoOfferLog
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def log_promo_offer_action(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: Optional[int],
|
||||
offer_id: Optional[int],
|
||||
action: str,
|
||||
source: Optional[str] = None,
|
||||
percent: Optional[int] = None,
|
||||
effect_type: Optional[str] = None,
|
||||
details: Optional[Dict[str, object]] = None,
|
||||
commit: bool = True,
|
||||
) -> PromoOfferLog:
|
||||
"""Persist a promo offer log entry."""
|
||||
|
||||
entry = PromoOfferLog(
|
||||
user_id=user_id,
|
||||
offer_id=offer_id,
|
||||
action=action,
|
||||
source=source,
|
||||
percent=percent,
|
||||
effect_type=effect_type,
|
||||
details=(details or {}).copy(),
|
||||
)
|
||||
db.add(entry)
|
||||
|
||||
if commit:
|
||||
try:
|
||||
await db.commit()
|
||||
await db.refresh(entry)
|
||||
except Exception:
|
||||
logger.exception("Failed to commit promo offer log entry")
|
||||
raise
|
||||
|
||||
return entry
|
||||
|
||||
|
||||
async def list_promo_offer_logs(
|
||||
db: AsyncSession,
|
||||
offset: int = 0,
|
||||
limit: int = 20,
|
||||
*,
|
||||
user_id: Optional[int] = None,
|
||||
offer_id: Optional[int] = None,
|
||||
action: Optional[str] = None,
|
||||
source: Optional[str] = None,
|
||||
) -> Tuple[List[PromoOfferLog], int]:
|
||||
stmt = (
|
||||
select(PromoOfferLog)
|
||||
.options(
|
||||
selectinload(PromoOfferLog.user),
|
||||
selectinload(PromoOfferLog.offer),
|
||||
)
|
||||
.order_by(PromoOfferLog.created_at.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
|
||||
if user_id is not None:
|
||||
stmt = stmt.where(PromoOfferLog.user_id == user_id)
|
||||
if offer_id is not None:
|
||||
stmt = stmt.where(PromoOfferLog.offer_id == offer_id)
|
||||
if action:
|
||||
stmt = stmt.where(PromoOfferLog.action == action)
|
||||
if source:
|
||||
stmt = stmt.where(PromoOfferLog.source == source)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
logs = result.scalars().all()
|
||||
|
||||
count_stmt = select(func.count(PromoOfferLog.id))
|
||||
if user_id is not None:
|
||||
count_stmt = count_stmt.where(PromoOfferLog.user_id == user_id)
|
||||
if offer_id is not None:
|
||||
count_stmt = count_stmt.where(PromoOfferLog.offer_id == offer_id)
|
||||
if action:
|
||||
count_stmt = count_stmt.where(PromoOfferLog.action == action)
|
||||
if source:
|
||||
count_stmt = count_stmt.where(PromoOfferLog.source == source)
|
||||
|
||||
total = (await db.execute(count_stmt)).scalar() or 0
|
||||
|
||||
return logs, total
|
||||
@@ -0,0 +1,224 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Iterable, List, Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import PromoOfferTemplate
|
||||
|
||||
|
||||
UPDATED_TEMPLATE_MESSAGES = {
|
||||
"extend_discount": (
|
||||
"💎 Экономия {discount_percent}% при продлении\n\n"
|
||||
"Скидка суммируется с промогруппой и действует один раз.\n"
|
||||
"Срок действия предложения — {valid_hours} ч.\n"
|
||||
"После активации скидка действует {active_discount_hours} ч."
|
||||
),
|
||||
"purchase_discount": (
|
||||
"🎯 Вернитесь со скидкой {discount_percent}%\n\n"
|
||||
"Скидка суммируется с промогруппой и действует один раз.\n"
|
||||
"Предложение действует {valid_hours} ч.\n"
|
||||
"После активации скидка действует {active_discount_hours} ч."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
LEGACY_TEMPLATE_MESSAGES = {
|
||||
"extend_discount": (
|
||||
"💎 <b>Экономия {discount_percent}% при продлении</b>\n\n"
|
||||
"Активируйте предложение и получите дополнительную скидку на оплату продления. "
|
||||
"Она суммируется с вашими промогрупповыми скидками и действует один раз.\n"
|
||||
"Срок действия предложения — {valid_hours} ч.\n"
|
||||
"После активации скидка действует {active_discount_hours} ч."
|
||||
),
|
||||
"purchase_discount": (
|
||||
"🎯 <b>Вернитесь со скидкой {discount_percent}%</b>\n\n"
|
||||
"После активации мы применим дополнительную скидку к вашей следующей оплате подписки. "
|
||||
"Скидка суммируется с промогруппой и действует один раз.\n"
|
||||
"Предложение действует {valid_hours} ч.\n"
|
||||
"После активации скидка действует {active_discount_hours} ч."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
DEFAULT_TEMPLATES: tuple[dict, ...] = (
|
||||
{
|
||||
"offer_type": "test_access",
|
||||
"name": "Тестовые сервера",
|
||||
"message_text": (
|
||||
"🔥 <b>Испытайте новые сервера</b>\n\n"
|
||||
"Активируйте предложение и получите временный доступ к дополнительным сквадам на {test_duration_hours} ч.\n"
|
||||
"Предложение действительно {valid_hours} ч."
|
||||
),
|
||||
"button_text": "🚀 Попробовать серверы",
|
||||
"valid_hours": 24,
|
||||
"discount_percent": 0,
|
||||
"bonus_amount_kopeks": 0,
|
||||
"active_discount_hours": None,
|
||||
"test_duration_hours": 24,
|
||||
"test_squad_uuids": [],
|
||||
},
|
||||
{
|
||||
"offer_type": "extend_discount",
|
||||
"name": "Скидка на продление",
|
||||
"message_text": UPDATED_TEMPLATE_MESSAGES["extend_discount"],
|
||||
"button_text": "🎁 Получить скидку",
|
||||
"valid_hours": 24,
|
||||
"discount_percent": 20,
|
||||
"bonus_amount_kopeks": 0,
|
||||
"active_discount_hours": 24,
|
||||
"test_duration_hours": None,
|
||||
"test_squad_uuids": [],
|
||||
},
|
||||
{
|
||||
"offer_type": "purchase_discount",
|
||||
"name": "Скидка на покупку",
|
||||
"message_text": UPDATED_TEMPLATE_MESSAGES["purchase_discount"],
|
||||
"button_text": "🎁 Забрать скидку",
|
||||
"valid_hours": 48,
|
||||
"discount_percent": 25,
|
||||
"bonus_amount_kopeks": 0,
|
||||
"active_discount_hours": 48,
|
||||
"test_duration_hours": None,
|
||||
"test_squad_uuids": [],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _format_template_fields(payload: dict) -> dict:
|
||||
data = dict(payload)
|
||||
data.setdefault("valid_hours", 24)
|
||||
data.setdefault("discount_percent", 0)
|
||||
data.setdefault("bonus_amount_kopeks", 0)
|
||||
data.setdefault("active_discount_hours", None)
|
||||
data.setdefault("test_duration_hours", None)
|
||||
data.setdefault("test_squad_uuids", [])
|
||||
return data
|
||||
|
||||
|
||||
async def ensure_default_templates(db: AsyncSession, *, created_by: Optional[int] = None) -> List[PromoOfferTemplate]:
|
||||
templates: List[PromoOfferTemplate] = []
|
||||
|
||||
for template_data in DEFAULT_TEMPLATES:
|
||||
result = await db.execute(
|
||||
select(PromoOfferTemplate).where(PromoOfferTemplate.offer_type == template_data["offer_type"])
|
||||
)
|
||||
existing = result.scalars().first()
|
||||
if existing:
|
||||
new_message = UPDATED_TEMPLATE_MESSAGES.get(template_data["offer_type"])
|
||||
legacy_message = LEGACY_TEMPLATE_MESSAGES.get(template_data["offer_type"])
|
||||
should_update = False
|
||||
|
||||
if new_message and legacy_message and existing.message_text == legacy_message:
|
||||
should_update = True
|
||||
elif new_message and (
|
||||
"{bonus_amount" in existing.message_text or "Мы начислим" in existing.message_text
|
||||
):
|
||||
should_update = True
|
||||
|
||||
if should_update and new_message:
|
||||
existing.message_text = new_message
|
||||
existing.updated_at = datetime.utcnow()
|
||||
await db.flush()
|
||||
|
||||
target_active_hours = template_data.get("active_discount_hours")
|
||||
if (
|
||||
target_active_hours is not None
|
||||
and target_active_hours > 0
|
||||
and not existing.active_discount_hours
|
||||
):
|
||||
existing.active_discount_hours = target_active_hours
|
||||
existing.updated_at = datetime.utcnow()
|
||||
await db.flush()
|
||||
templates.append(existing)
|
||||
continue
|
||||
|
||||
payload = _format_template_fields(template_data)
|
||||
template = PromoOfferTemplate(
|
||||
name=payload["name"],
|
||||
offer_type=payload["offer_type"],
|
||||
message_text=payload["message_text"],
|
||||
button_text=payload["button_text"],
|
||||
valid_hours=payload["valid_hours"],
|
||||
discount_percent=payload["discount_percent"],
|
||||
bonus_amount_kopeks=payload["bonus_amount_kopeks"],
|
||||
active_discount_hours=payload["active_discount_hours"],
|
||||
test_duration_hours=payload["test_duration_hours"],
|
||||
test_squad_uuids=payload["test_squad_uuids"],
|
||||
is_active=True,
|
||||
created_by=created_by,
|
||||
)
|
||||
db.add(template)
|
||||
await db.flush()
|
||||
templates.append(template)
|
||||
|
||||
await db.commit()
|
||||
|
||||
return templates
|
||||
|
||||
|
||||
async def list_promo_offer_templates(db: AsyncSession) -> List[PromoOfferTemplate]:
|
||||
result = await db.execute(
|
||||
select(PromoOfferTemplate).order_by(PromoOfferTemplate.offer_type, PromoOfferTemplate.id)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def get_promo_offer_template_by_id(db: AsyncSession, template_id: int) -> Optional[PromoOfferTemplate]:
|
||||
result = await db.execute(
|
||||
select(PromoOfferTemplate).where(PromoOfferTemplate.id == template_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_promo_offer_template_by_type(db: AsyncSession, offer_type: str) -> Optional[PromoOfferTemplate]:
|
||||
result = await db.execute(
|
||||
select(PromoOfferTemplate).where(PromoOfferTemplate.offer_type == offer_type)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def update_promo_offer_template(
|
||||
db: AsyncSession,
|
||||
template: PromoOfferTemplate,
|
||||
*,
|
||||
name: Optional[str] = None,
|
||||
message_text: Optional[str] = None,
|
||||
button_text: Optional[str] = None,
|
||||
valid_hours: Optional[int] = None,
|
||||
discount_percent: Optional[int] = None,
|
||||
bonus_amount_kopeks: Optional[int] = None,
|
||||
active_discount_hours: Optional[int] = None,
|
||||
test_duration_hours: Optional[int] = None,
|
||||
test_squad_uuids: Optional[Iterable[str]] = None,
|
||||
is_active: Optional[bool] = None,
|
||||
) -> PromoOfferTemplate:
|
||||
if name is not None:
|
||||
template.name = name
|
||||
if message_text is not None:
|
||||
template.message_text = message_text
|
||||
if button_text is not None:
|
||||
template.button_text = button_text
|
||||
if valid_hours is not None:
|
||||
template.valid_hours = valid_hours
|
||||
if discount_percent is not None:
|
||||
template.discount_percent = discount_percent
|
||||
if bonus_amount_kopeks is not None:
|
||||
template.bonus_amount_kopeks = bonus_amount_kopeks
|
||||
if active_discount_hours is not None:
|
||||
template.active_discount_hours = active_discount_hours
|
||||
if test_duration_hours is not None:
|
||||
template.test_duration_hours = test_duration_hours
|
||||
if test_squad_uuids is not None:
|
||||
template.test_squad_uuids = list(test_squad_uuids)
|
||||
if is_active is not None:
|
||||
template.is_active = is_active
|
||||
|
||||
template.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(template)
|
||||
return template
|
||||
@@ -13,12 +13,50 @@ logger = logging.getLogger(__name__)
|
||||
async def get_promocode_by_code(db: AsyncSession, code: str) -> Optional[PromoCode]:
|
||||
result = await db.execute(
|
||||
select(PromoCode)
|
||||
.options(selectinload(PromoCode.uses))
|
||||
.options(
|
||||
selectinload(PromoCode.uses),
|
||||
selectinload(PromoCode.promo_group)
|
||||
)
|
||||
.where(PromoCode.code == code.upper())
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_promocode_by_id(db: AsyncSession, promo_id: int) -> Optional[PromoCode]:
|
||||
"""
|
||||
Получает промокод по ID с eager loading всех связанных данных.
|
||||
Используется для избежания lazy loading в async контексте.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(PromoCode)
|
||||
.options(
|
||||
selectinload(PromoCode.uses),
|
||||
selectinload(PromoCode.promo_group)
|
||||
)
|
||||
.where(PromoCode.id == promo_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def check_promocode_validity(db: AsyncSession, code: str) -> dict:
|
||||
"""
|
||||
Проверяет существование и валидность промокода без активации.
|
||||
Возвращает словарь с информацией о промокоде.
|
||||
"""
|
||||
promocode = await get_promocode_by_code(db, code)
|
||||
|
||||
if not promocode:
|
||||
return {"valid": False, "error": "not_found", "promocode": None}
|
||||
|
||||
if not promocode.is_valid:
|
||||
if promocode.current_uses >= promocode.max_uses:
|
||||
return {"valid": False, "error": "used", "promocode": None}
|
||||
else:
|
||||
return {"valid": False, "error": "expired", "promocode": None}
|
||||
|
||||
return {"valid": True, "error": None, "promocode": promocode}
|
||||
|
||||
|
||||
async def create_promocode(
|
||||
db: AsyncSession,
|
||||
code: str,
|
||||
@@ -27,7 +65,8 @@ async def create_promocode(
|
||||
subscription_days: int = 0,
|
||||
max_uses: int = 1,
|
||||
valid_until: Optional[datetime] = None,
|
||||
created_by: Optional[int] = None
|
||||
created_by: Optional[int] = None,
|
||||
promo_group_id: Optional[int] = None
|
||||
) -> PromoCode:
|
||||
|
||||
promocode = PromoCode(
|
||||
@@ -37,14 +76,18 @@ async def create_promocode(
|
||||
subscription_days=subscription_days,
|
||||
max_uses=max_uses,
|
||||
valid_until=valid_until,
|
||||
created_by=created_by
|
||||
created_by=created_by,
|
||||
promo_group_id=promo_group_id
|
||||
)
|
||||
|
||||
db.add(promocode)
|
||||
await db.commit()
|
||||
await db.refresh(promocode)
|
||||
|
||||
logger.info(f"✅ Создан промокод: {code}")
|
||||
|
||||
if promo_group_id:
|
||||
logger.info(f"✅ Создан промокод: {code} с промогруппой ID {promo_group_id}")
|
||||
else:
|
||||
logger.info(f"✅ Создан промокод: {code}")
|
||||
return promocode
|
||||
|
||||
|
||||
@@ -53,9 +96,9 @@ async def use_promocode(
|
||||
promocode_id: int,
|
||||
user_id: int
|
||||
) -> bool:
|
||||
|
||||
|
||||
try:
|
||||
promocode = await db.get(PromoCode, promocode_id)
|
||||
promocode = await get_promocode_by_id(db, promocode_id)
|
||||
if not promocode:
|
||||
return False
|
||||
|
||||
@@ -143,14 +186,17 @@ async def get_promocodes_list(
|
||||
limit: int = 50,
|
||||
is_active: Optional[bool] = None
|
||||
) -> List[PromoCode]:
|
||||
|
||||
query = select(PromoCode).options(selectinload(PromoCode.uses))
|
||||
|
||||
|
||||
query = select(PromoCode).options(
|
||||
selectinload(PromoCode.uses),
|
||||
selectinload(PromoCode.promo_group)
|
||||
)
|
||||
|
||||
if is_active is not None:
|
||||
query = query.where(PromoCode.is_active == is_active)
|
||||
|
||||
|
||||
query = query.order_by(PromoCode.created_at.desc()).offset(offset).limit(limit)
|
||||
|
||||
|
||||
result = await db.execute(query)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import PublicOffer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def get_public_offer(db: AsyncSession, language: str) -> Optional[PublicOffer]:
|
||||
result = await db.execute(
|
||||
select(PublicOffer).where(PublicOffer.language == language)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def upsert_public_offer(
|
||||
db: AsyncSession,
|
||||
language: str,
|
||||
content: str,
|
||||
*,
|
||||
enable_if_new: bool = True,
|
||||
) -> PublicOffer:
|
||||
offer = await get_public_offer(db, language)
|
||||
|
||||
if offer:
|
||||
offer.content = content or ""
|
||||
offer.updated_at = datetime.utcnow()
|
||||
else:
|
||||
offer = PublicOffer(
|
||||
language=language,
|
||||
content=content or "",
|
||||
is_enabled=True if enable_if_new else False,
|
||||
)
|
||||
db.add(offer)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(offer)
|
||||
|
||||
logger.info(
|
||||
"✅ Публичная оферта для языка %s обновлена (ID: %s)",
|
||||
language,
|
||||
offer.id,
|
||||
)
|
||||
|
||||
return offer
|
||||
|
||||
|
||||
async def set_public_offer_enabled(
|
||||
db: AsyncSession,
|
||||
language: str,
|
||||
enabled: bool,
|
||||
) -> PublicOffer:
|
||||
offer = await get_public_offer(db, language)
|
||||
|
||||
if offer:
|
||||
offer.is_enabled = bool(enabled)
|
||||
offer.updated_at = datetime.utcnow()
|
||||
else:
|
||||
offer = PublicOffer(
|
||||
language=language,
|
||||
content="",
|
||||
is_enabled=bool(enabled),
|
||||
)
|
||||
db.add(offer)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(offer)
|
||||
|
||||
logger.info(
|
||||
"✅ Статус публичной оферты для языка %s обновлен: %s",
|
||||
language,
|
||||
"enabled" if offer.is_enabled else "disabled",
|
||||
)
|
||||
|
||||
return offer
|
||||
+139
-3
@@ -1,6 +1,6 @@
|
||||
import logging
|
||||
from typing import Optional
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from datetime import datetime
|
||||
|
||||
@@ -53,10 +53,37 @@ async def create_or_update_rules(
|
||||
await db.commit()
|
||||
await db.refresh(new_rules)
|
||||
|
||||
logger.info(f"✅ Правила для языка {language} обновлены")
|
||||
logger.info(f"✅ Правила для языка {language} обновлены (ID: {new_rules.id})")
|
||||
return new_rules
|
||||
|
||||
|
||||
async def clear_all_rules(db: AsyncSession, language: str = "ru") -> bool:
|
||||
try:
|
||||
result = await db.execute(
|
||||
update(ServiceRule)
|
||||
.where(
|
||||
ServiceRule.language == language,
|
||||
ServiceRule.is_active == True
|
||||
)
|
||||
.values(
|
||||
is_active=False,
|
||||
updated_at=datetime.utcnow()
|
||||
)
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
rows_affected = result.rowcount
|
||||
logger.info(f"✅ Очищены правила для языка {language}. Деактивировано записей: {rows_affected}")
|
||||
|
||||
return rows_affected > 0
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка при очистке правил для языка {language}: {e}")
|
||||
await db.rollback()
|
||||
raise
|
||||
|
||||
|
||||
async def get_current_rules_content(db: AsyncSession, language: str = "ru") -> str:
|
||||
rules = await get_rules_by_language(db, language)
|
||||
|
||||
@@ -79,4 +106,113 @@ async def get_current_rules_content(db: AsyncSession, language: str = "ru") -> s
|
||||
6. При возникновении вопросов обращайтесь в техническую поддержку.
|
||||
|
||||
Используя сервис, вы соглашаетесь с данными правилами.
|
||||
"""
|
||||
"""
|
||||
|
||||
|
||||
async def get_all_rules_versions(
|
||||
db: AsyncSession,
|
||||
language: str = "ru",
|
||||
limit: int = 10
|
||||
) -> list[ServiceRule]:
|
||||
result = await db.execute(
|
||||
select(ServiceRule)
|
||||
.where(ServiceRule.language == language)
|
||||
.order_by(ServiceRule.created_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def restore_rules_version(
|
||||
db: AsyncSession,
|
||||
rule_id: int,
|
||||
language: str = "ru"
|
||||
) -> Optional[ServiceRule]:
|
||||
try:
|
||||
result = await db.execute(
|
||||
select(ServiceRule).where(
|
||||
ServiceRule.id == rule_id,
|
||||
ServiceRule.language == language
|
||||
)
|
||||
)
|
||||
rule_to_restore = result.scalar_one_or_none()
|
||||
|
||||
if not rule_to_restore:
|
||||
logger.warning(f"Правило с ID {rule_id} не найдено для языка {language}")
|
||||
return None
|
||||
|
||||
await db.execute(
|
||||
update(ServiceRule)
|
||||
.where(
|
||||
ServiceRule.language == language,
|
||||
ServiceRule.is_active == True
|
||||
)
|
||||
.values(
|
||||
is_active=False,
|
||||
updated_at=datetime.utcnow()
|
||||
)
|
||||
)
|
||||
|
||||
restored_rule = ServiceRule(
|
||||
title=rule_to_restore.title,
|
||||
content=rule_to_restore.content,
|
||||
language=language,
|
||||
is_active=True,
|
||||
order=0
|
||||
)
|
||||
|
||||
db.add(restored_rule)
|
||||
await db.commit()
|
||||
await db.refresh(restored_rule)
|
||||
|
||||
logger.info(f"✅ Восстановлена версия правил ID {rule_id} как новое правило ID {restored_rule.id}")
|
||||
return restored_rule
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка при восстановлении правил ID {rule_id}: {e}")
|
||||
await db.rollback()
|
||||
raise
|
||||
|
||||
|
||||
async def get_rules_statistics(db: AsyncSession) -> dict:
|
||||
try:
|
||||
active_result = await db.execute(
|
||||
select(ServiceRule).where(ServiceRule.is_active == True)
|
||||
)
|
||||
active_rules = active_result.scalars().all()
|
||||
|
||||
all_result = await db.execute(select(ServiceRule))
|
||||
all_rules = all_result.scalars().all()
|
||||
|
||||
languages_stats = {}
|
||||
for rule in active_rules:
|
||||
lang = rule.language
|
||||
if lang not in languages_stats:
|
||||
languages_stats[lang] = {
|
||||
'active_count': 0,
|
||||
'last_updated': None,
|
||||
'content_length': 0
|
||||
}
|
||||
|
||||
languages_stats[lang]['active_count'] += 1
|
||||
languages_stats[lang]['content_length'] = len(rule.content)
|
||||
|
||||
if not languages_stats[lang]['last_updated'] or rule.updated_at > languages_stats[lang]['last_updated']:
|
||||
languages_stats[lang]['last_updated'] = rule.updated_at
|
||||
|
||||
return {
|
||||
'total_active': len(active_rules),
|
||||
'total_all_time': len(all_rules),
|
||||
'languages': languages_stats,
|
||||
'total_languages': len(languages_stats)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка при получении статистики правил: {e}")
|
||||
return {
|
||||
'total_active': 0,
|
||||
'total_all_time': 0,
|
||||
'languages': {},
|
||||
'total_languages': 0,
|
||||
'error': str(e)
|
||||
}
|
||||
|
||||
@@ -1,14 +1,41 @@
|
||||
import logging
|
||||
from typing import List, Optional, Tuple
|
||||
from sqlalchemy import select, and_, func, update, delete
|
||||
import random
|
||||
from datetime import datetime
|
||||
from typing import Iterable, List, Optional, Sequence, Tuple
|
||||
|
||||
from sqlalchemy import (
|
||||
select,
|
||||
and_,
|
||||
func,
|
||||
update,
|
||||
delete,
|
||||
text,
|
||||
or_,
|
||||
cast,
|
||||
String,
|
||||
)
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import ServerSquad, SubscriptionServer
|
||||
from app.database.models import (
|
||||
PromoGroup,
|
||||
ServerSquad,
|
||||
SubscriptionServer,
|
||||
Subscription,
|
||||
SubscriptionStatus,
|
||||
User,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _get_default_promo_group_id(db: AsyncSession) -> Optional[int]:
|
||||
result = await db.execute(
|
||||
select(PromoGroup.id).where(PromoGroup.is_default.is_(True)).limit(1)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def create_server_squad(
|
||||
db: AsyncSession,
|
||||
squad_uuid: str,
|
||||
@@ -18,9 +45,32 @@ async def create_server_squad(
|
||||
price_kopeks: int = 0,
|
||||
description: str = None,
|
||||
max_users: int = None,
|
||||
is_available: bool = True
|
||||
is_available: bool = True,
|
||||
is_trial_eligible: bool = False,
|
||||
sort_order: int = 0,
|
||||
promo_group_ids: Optional[Iterable[int]] = None,
|
||||
) -> ServerSquad:
|
||||
|
||||
|
||||
normalized_group_ids: Sequence[int]
|
||||
if promo_group_ids is None:
|
||||
default_id = await _get_default_promo_group_id(db)
|
||||
normalized_group_ids = [default_id] if default_id is not None else []
|
||||
else:
|
||||
normalized_group_ids = [int(pg_id) for pg_id in set(promo_group_ids)]
|
||||
|
||||
if not normalized_group_ids:
|
||||
raise ValueError("Server squad must be linked to at least one promo group")
|
||||
|
||||
promo_groups_result = await db.execute(
|
||||
select(PromoGroup).where(PromoGroup.id.in_(normalized_group_ids))
|
||||
)
|
||||
promo_groups = promo_groups_result.scalars().all()
|
||||
|
||||
if len(promo_groups) != len(normalized_group_ids):
|
||||
logger.warning(
|
||||
"Не все промогруппы найдены при создании сервера %s", display_name
|
||||
)
|
||||
|
||||
server_squad = ServerSquad(
|
||||
squad_uuid=squad_uuid,
|
||||
display_name=display_name,
|
||||
@@ -29,9 +79,12 @@ async def create_server_squad(
|
||||
price_kopeks=price_kopeks,
|
||||
description=description,
|
||||
max_users=max_users,
|
||||
is_available=is_available
|
||||
is_available=is_available,
|
||||
is_trial_eligible=is_trial_eligible,
|
||||
sort_order=sort_order,
|
||||
allowed_promo_groups=promo_groups,
|
||||
)
|
||||
|
||||
|
||||
db.add(server_squad)
|
||||
await db.commit()
|
||||
await db.refresh(server_squad)
|
||||
@@ -46,9 +99,11 @@ async def get_server_squad_by_uuid(
|
||||
) -> Optional[ServerSquad]:
|
||||
|
||||
result = await db.execute(
|
||||
select(ServerSquad).where(ServerSquad.squad_uuid == squad_uuid)
|
||||
select(ServerSquad)
|
||||
.options(selectinload(ServerSquad.allowed_promo_groups))
|
||||
.where(ServerSquad.squad_uuid == squad_uuid)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
return result.scalars().unique().one_or_none()
|
||||
|
||||
|
||||
async def get_server_squad_by_id(
|
||||
@@ -57,9 +112,11 @@ async def get_server_squad_by_id(
|
||||
) -> Optional[ServerSquad]:
|
||||
|
||||
result = await db.execute(
|
||||
select(ServerSquad).where(ServerSquad.id == server_id)
|
||||
select(ServerSquad)
|
||||
.options(selectinload(ServerSquad.allowed_promo_groups))
|
||||
.where(ServerSquad.id == server_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
return result.scalars().unique().one_or_none()
|
||||
|
||||
|
||||
async def get_all_server_squads(
|
||||
@@ -91,14 +148,111 @@ async def get_all_server_squads(
|
||||
return servers, total_count
|
||||
|
||||
|
||||
async def get_available_server_squads(db: AsyncSession) -> List[ServerSquad]:
|
||||
|
||||
result = await db.execute(
|
||||
async def get_available_server_squads(
|
||||
db: AsyncSession,
|
||||
promo_group_id: Optional[int] = None,
|
||||
) -> List[ServerSquad]:
|
||||
|
||||
query = (
|
||||
select(ServerSquad)
|
||||
.where(ServerSquad.is_available == True)
|
||||
.options(selectinload(ServerSquad.allowed_promo_groups))
|
||||
.where(ServerSquad.is_available.is_(True))
|
||||
.order_by(ServerSquad.sort_order, ServerSquad.display_name)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
if promo_group_id is not None:
|
||||
query = query.join(ServerSquad.allowed_promo_groups).where(
|
||||
PromoGroup.id == promo_group_id
|
||||
)
|
||||
|
||||
result = await db.execute(query)
|
||||
return result.scalars().unique().all()
|
||||
|
||||
|
||||
async def get_active_server_squads(db: AsyncSession) -> List[ServerSquad]:
|
||||
"""Возвращает список активных серверов, доступных для подключения."""
|
||||
|
||||
squads = await get_available_server_squads(db)
|
||||
|
||||
if not squads:
|
||||
return []
|
||||
|
||||
eligible: List[ServerSquad] = []
|
||||
|
||||
for squad in squads:
|
||||
max_users = squad.max_users
|
||||
current_users = squad.current_users or 0
|
||||
|
||||
if max_users is not None and current_users >= max_users:
|
||||
continue
|
||||
|
||||
eligible.append(squad)
|
||||
|
||||
if eligible:
|
||||
return eligible
|
||||
|
||||
return squads
|
||||
|
||||
|
||||
async def choose_random_active_server_squad(
|
||||
db: AsyncSession,
|
||||
) -> Optional[ServerSquad]:
|
||||
"""Возвращает случайный активный сервер."""
|
||||
|
||||
squads = await get_active_server_squads(db)
|
||||
|
||||
if not squads:
|
||||
return None
|
||||
|
||||
return random.choice(squads)
|
||||
|
||||
|
||||
async def get_random_active_squad_uuid(
|
||||
db: AsyncSession,
|
||||
fallback_uuid: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
"""Возвращает UUID случайного активного сервера или запасной UUID."""
|
||||
|
||||
squad = await choose_random_active_server_squad(db)
|
||||
|
||||
if squad:
|
||||
return squad.squad_uuid
|
||||
|
||||
return fallback_uuid
|
||||
|
||||
|
||||
async def update_server_squad_promo_groups(
|
||||
db: AsyncSession, server_id: int, promo_group_ids: Iterable[int]
|
||||
) -> Optional[ServerSquad]:
|
||||
unique_ids = [int(pg_id) for pg_id in set(promo_group_ids)]
|
||||
|
||||
if not unique_ids:
|
||||
raise ValueError("Нужно выбрать хотя бы одну промогруппу")
|
||||
|
||||
server = await get_server_squad_by_id(db, server_id)
|
||||
if not server:
|
||||
return None
|
||||
|
||||
result = await db.execute(
|
||||
select(PromoGroup).where(PromoGroup.id.in_(unique_ids))
|
||||
)
|
||||
promo_groups = result.scalars().all()
|
||||
|
||||
if not promo_groups:
|
||||
raise ValueError("Не найдены промогруппы для обновления сервера")
|
||||
|
||||
server.allowed_promo_groups = promo_groups
|
||||
await db.commit()
|
||||
await db.refresh(server)
|
||||
|
||||
logger.info(
|
||||
"Обновлены промогруппы сервера %s (ID: %s): %s",
|
||||
server.display_name,
|
||||
server.id,
|
||||
", ".join(pg.name for pg in promo_groups),
|
||||
)
|
||||
|
||||
return server
|
||||
|
||||
|
||||
async def update_server_squad(
|
||||
@@ -108,8 +262,15 @@ async def update_server_squad(
|
||||
) -> Optional[ServerSquad]:
|
||||
|
||||
valid_fields = {
|
||||
'display_name', 'country_code', 'price_kopeks', 'description',
|
||||
'max_users', 'is_available', 'sort_order'
|
||||
"display_name",
|
||||
"original_name",
|
||||
"country_code",
|
||||
"price_kopeks",
|
||||
"description",
|
||||
"max_users",
|
||||
"is_available",
|
||||
"sort_order",
|
||||
"is_trial_eligible",
|
||||
}
|
||||
|
||||
filtered_updates = {k: v for k, v in updates.items() if k in valid_fields}
|
||||
@@ -137,7 +298,7 @@ async def delete_server_squad(db: AsyncSession, server_id: int) -> bool:
|
||||
connections_count = connections_result.scalar()
|
||||
|
||||
if connections_count > 0:
|
||||
logger.warning(f"❌ Нельзя удалить сервер {server_id}: есть активные подключения ({connections_count})")
|
||||
logger.warning(f"⚠ Нельзя удалить сервер {server_id}: есть активные подключения ({connections_count})")
|
||||
return False
|
||||
|
||||
await db.execute(
|
||||
@@ -153,10 +314,10 @@ async def sync_with_remnawave(
|
||||
db: AsyncSession,
|
||||
remnawave_squads: List[dict]
|
||||
) -> Tuple[int, int, int]:
|
||||
|
||||
|
||||
created = 0
|
||||
updated = 0
|
||||
disabled = 0
|
||||
removed = 0
|
||||
|
||||
existing_servers = {}
|
||||
result = await db.execute(select(ServerSquad))
|
||||
@@ -186,19 +347,186 @@ async def sync_with_remnawave(
|
||||
)
|
||||
created += 1
|
||||
|
||||
for uuid, server in existing_servers.items():
|
||||
if uuid not in remnawave_uuids and server.is_available:
|
||||
server.is_available = False
|
||||
disabled += 1
|
||||
|
||||
removed_servers = [
|
||||
server for uuid, server in existing_servers.items()
|
||||
if uuid not in remnawave_uuids
|
||||
]
|
||||
|
||||
if removed_servers:
|
||||
removed_ids = [server.id for server in removed_servers]
|
||||
removed_uuids = {server.squad_uuid for server in removed_servers}
|
||||
|
||||
subscription_ids_result = await db.execute(
|
||||
select(SubscriptionServer.subscription_id)
|
||||
.where(SubscriptionServer.server_squad_id.in_(removed_ids))
|
||||
)
|
||||
subscription_ids = {row[0] for row in subscription_ids_result.fetchall()}
|
||||
|
||||
for server in removed_servers:
|
||||
logger.info(
|
||||
"🗑️ Удаляется сервер %s (UUID: %s)",
|
||||
server.display_name,
|
||||
server.squad_uuid,
|
||||
)
|
||||
|
||||
await db.execute(
|
||||
delete(SubscriptionServer).where(SubscriptionServer.server_squad_id.in_(removed_ids))
|
||||
)
|
||||
|
||||
subscriptions_to_update: dict[int, Subscription] = {}
|
||||
|
||||
if subscription_ids:
|
||||
subscriptions_result = await db.execute(
|
||||
select(Subscription).where(Subscription.id.in_(subscription_ids))
|
||||
)
|
||||
for subscription in subscriptions_result.scalars().unique().all():
|
||||
subscriptions_to_update[subscription.id] = subscription
|
||||
|
||||
for squad_uuid in removed_uuids:
|
||||
if not squad_uuid:
|
||||
continue
|
||||
|
||||
extra_result = await db.execute(
|
||||
select(Subscription).where(
|
||||
text("connected_squads::text LIKE :uuid_pattern")
|
||||
),
|
||||
{"uuid_pattern": f'%"{squad_uuid}"%'}
|
||||
)
|
||||
|
||||
for subscription in extra_result.scalars().unique().all():
|
||||
subscriptions_to_update[subscription.id] = subscription
|
||||
|
||||
cleaned_subscriptions = 0
|
||||
|
||||
for subscription in subscriptions_to_update.values():
|
||||
current_squads = list(subscription.connected_squads or [])
|
||||
if not current_squads:
|
||||
continue
|
||||
|
||||
filtered_squads = [
|
||||
squad_uuid for squad_uuid in current_squads if squad_uuid not in removed_uuids
|
||||
]
|
||||
|
||||
if len(filtered_squads) != len(current_squads):
|
||||
subscription.connected_squads = filtered_squads
|
||||
subscription.updated_at = datetime.utcnow()
|
||||
cleaned_subscriptions += 1
|
||||
|
||||
await db.execute(delete(ServerSquad).where(ServerSquad.id.in_(removed_ids)))
|
||||
removed = len(removed_servers)
|
||||
|
||||
if cleaned_subscriptions:
|
||||
logger.info(
|
||||
"🧹 Обновлены подписки после удаления серверов: %s",
|
||||
cleaned_subscriptions,
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"🔄 Синхронизация завершена: +{created} ~{updated} -{disabled}")
|
||||
return created, updated, disabled
|
||||
|
||||
logger.info(f"🔄 Синхронизация завершена: +{created} ~{updated} -{removed}")
|
||||
return created, updated, removed
|
||||
|
||||
|
||||
async def get_server_connected_users(
|
||||
db: AsyncSession,
|
||||
server_id: int
|
||||
) -> List[User]:
|
||||
|
||||
server_uuid_result = await db.execute(
|
||||
select(ServerSquad.squad_uuid).where(ServerSquad.id == server_id)
|
||||
)
|
||||
server_uuid = server_uuid_result.scalar_one_or_none()
|
||||
|
||||
connection_filters = [SubscriptionServer.id.isnot(None)]
|
||||
|
||||
if server_uuid:
|
||||
connection_filters.append(
|
||||
cast(Subscription.connected_squads, String).like(
|
||||
f'%"{server_uuid}"%'
|
||||
)
|
||||
)
|
||||
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.join(Subscription, Subscription.user_id == User.id)
|
||||
.outerjoin(
|
||||
SubscriptionServer,
|
||||
and_(
|
||||
SubscriptionServer.subscription_id == Subscription.id,
|
||||
SubscriptionServer.server_squad_id == server_id,
|
||||
),
|
||||
)
|
||||
.where(or_(*connection_filters))
|
||||
.options(selectinload(User.subscription))
|
||||
.order_by(User.id)
|
||||
)
|
||||
|
||||
return result.scalars().unique().all()
|
||||
|
||||
|
||||
async def get_trial_eligible_server_squads(
|
||||
db: AsyncSession,
|
||||
include_unavailable: bool = False,
|
||||
) -> List[ServerSquad]:
|
||||
|
||||
query = select(ServerSquad).where(ServerSquad.is_trial_eligible.is_(True))
|
||||
|
||||
result = await db.execute(query)
|
||||
squads = result.scalars().unique().all()
|
||||
|
||||
if include_unavailable:
|
||||
return squads
|
||||
|
||||
preferred_squads: List[ServerSquad] = []
|
||||
fallback_squads: List[ServerSquad] = []
|
||||
|
||||
for squad in squads:
|
||||
current_users = squad.current_users or 0
|
||||
is_full = squad.max_users is not None and current_users >= squad.max_users
|
||||
|
||||
if is_full:
|
||||
continue
|
||||
|
||||
if squad.is_available:
|
||||
preferred_squads.append(squad)
|
||||
else:
|
||||
fallback_squads.append(squad)
|
||||
|
||||
if preferred_squads:
|
||||
return preferred_squads
|
||||
|
||||
if fallback_squads:
|
||||
return fallback_squads
|
||||
|
||||
return squads
|
||||
|
||||
|
||||
async def choose_random_trial_server_squad(
|
||||
db: AsyncSession,
|
||||
) -> Optional[ServerSquad]:
|
||||
|
||||
squads = await get_trial_eligible_server_squads(db)
|
||||
|
||||
if not squads:
|
||||
return None
|
||||
|
||||
return random.choice(squads)
|
||||
|
||||
|
||||
async def get_random_trial_squad_uuid(
|
||||
db: AsyncSession,
|
||||
) -> Optional[str]:
|
||||
|
||||
squad = await choose_random_trial_server_squad(db)
|
||||
|
||||
if squad:
|
||||
return squad.squad_uuid
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _generate_display_name(original_name: str) -> str:
|
||||
|
||||
|
||||
country_names = {
|
||||
'NL': '🇳🇱 Нидерланды',
|
||||
'DE': '🇩🇪 Германия',
|
||||
@@ -234,20 +562,33 @@ def _extract_country_code(original_name: str) -> Optional[str]:
|
||||
|
||||
|
||||
async def get_server_statistics(db: AsyncSession) -> dict:
|
||||
|
||||
|
||||
total_result = await db.execute(select(func.count(ServerSquad.id)))
|
||||
total_servers = total_result.scalar()
|
||||
|
||||
|
||||
available_result = await db.execute(
|
||||
select(func.count(ServerSquad.id))
|
||||
.where(ServerSquad.is_available == True)
|
||||
)
|
||||
available_servers = available_result.scalar()
|
||||
|
||||
with_connections_result = await db.execute(
|
||||
select(func.count(func.distinct(SubscriptionServer.server_squad_id)))
|
||||
)
|
||||
servers_with_connections = with_connections_result.scalar()
|
||||
servers_with_connections = 0
|
||||
all_servers_result = await db.execute(select(ServerSquad.squad_uuid))
|
||||
all_server_uuids = [row[0] for row in all_servers_result.fetchall()]
|
||||
|
||||
for squad_uuid in all_server_uuids:
|
||||
count_result = await db.execute(
|
||||
text("""
|
||||
SELECT COUNT(s.id)
|
||||
FROM subscriptions s
|
||||
WHERE s.status IN ('active', 'trial')
|
||||
AND s.connected_squads::text LIKE :uuid_pattern
|
||||
"""),
|
||||
{"uuid_pattern": f'%"{squad_uuid}"%'}
|
||||
)
|
||||
user_count = count_result.scalar() or 0
|
||||
if user_count > 0:
|
||||
servers_with_connections += 1
|
||||
|
||||
revenue_result = await db.execute(
|
||||
select(func.coalesce(func.sum(SubscriptionServer.paid_price_kopeks), 0))
|
||||
@@ -263,6 +604,25 @@ async def get_server_statistics(db: AsyncSession) -> dict:
|
||||
'total_revenue_rubles': total_revenue_kopeks / 100
|
||||
}
|
||||
|
||||
|
||||
async def count_active_users_for_squad(db: AsyncSession, squad_uuid: str) -> int:
|
||||
"""Возвращает количество активных подписок, подключенных к указанному скваду."""
|
||||
|
||||
result = await db.execute(
|
||||
select(func.count(Subscription.id)).where(
|
||||
Subscription.status.in_(
|
||||
[
|
||||
SubscriptionStatus.ACTIVE.value,
|
||||
SubscriptionStatus.TRIAL.value,
|
||||
]
|
||||
),
|
||||
cast(Subscription.connected_squads, String).like(f'%"{squad_uuid}"%'),
|
||||
)
|
||||
)
|
||||
|
||||
return result.scalar() or 0
|
||||
|
||||
|
||||
async def add_user_to_servers(
|
||||
db: AsyncSession,
|
||||
server_squad_ids: List[int]
|
||||
@@ -324,20 +684,26 @@ async def get_server_ids_by_uuids(
|
||||
async def sync_server_user_counts(db: AsyncSession) -> int:
|
||||
|
||||
try:
|
||||
result = await db.execute(
|
||||
select(
|
||||
ServerSquad.id,
|
||||
ServerSquad.squad_uuid,
|
||||
func.count(SubscriptionServer.id).label('actual_users')
|
||||
)
|
||||
.outerjoin(SubscriptionServer, ServerSquad.id == SubscriptionServer.server_squad_id)
|
||||
.join(Subscription, SubscriptionServer.subscription_id == Subscription.id)
|
||||
.where(Subscription.status == 'active')
|
||||
.group_by(ServerSquad.id, ServerSquad.squad_uuid)
|
||||
)
|
||||
all_servers_result = await db.execute(select(ServerSquad.id, ServerSquad.squad_uuid))
|
||||
all_servers = all_servers_result.fetchall()
|
||||
|
||||
logger.info(f"🔍 Найдено серверов для синхронизации: {len(all_servers)}")
|
||||
|
||||
updated_count = 0
|
||||
for server_id, squad_uuid, actual_users in result.fetchall():
|
||||
for server_id, squad_uuid in all_servers:
|
||||
count_result = await db.execute(
|
||||
text("""
|
||||
SELECT COUNT(s.id)
|
||||
FROM subscriptions s
|
||||
WHERE s.status IN ('active', 'trial')
|
||||
AND s.connected_squads::text LIKE :uuid_pattern
|
||||
"""),
|
||||
{"uuid_pattern": f'%"{squad_uuid}"%'}
|
||||
)
|
||||
actual_users = count_result.scalar() or 0
|
||||
|
||||
logger.info(f"📊 Сервер {server_id} ({squad_uuid[:8]}): {actual_users} пользователей")
|
||||
|
||||
await db.execute(
|
||||
update(ServerSquad)
|
||||
.where(ServerSquad.id == server_id)
|
||||
@@ -352,4 +718,4 @@ async def sync_server_user_counts(db: AsyncSession) -> int:
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка синхронизации счетчиков пользователей: {e}")
|
||||
await db.rollback()
|
||||
return 0
|
||||
return 0
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, List, Tuple
|
||||
from typing import Iterable, Optional, List, Tuple
|
||||
from sqlalchemy import select, and_, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import (
|
||||
Subscription, SubscriptionStatus, User,
|
||||
SubscriptionServer
|
||||
Subscription,
|
||||
SubscriptionStatus,
|
||||
User,
|
||||
SubscriptionServer,
|
||||
PromoGroup,
|
||||
UserPromoGroup,
|
||||
)
|
||||
from app.database.crud.notification import clear_notifications
|
||||
from app.utils.pricing_utils import calculate_months_from_days, get_remaining_months
|
||||
from app.config import settings
|
||||
from app.utils.timezone import format_local_datetime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -26,6 +32,7 @@ async def get_subscription_by_user_id(db: AsyncSession, user_id: int) -> Optiona
|
||||
subscription = result.scalar_one_or_none()
|
||||
|
||||
if subscription:
|
||||
logger.info(f"🔍 Загружена подписка {subscription.id} для пользователя {user_id}, статус: {subscription.status}")
|
||||
subscription = await check_and_update_subscription_status(db, subscription)
|
||||
|
||||
return subscription
|
||||
@@ -36,17 +43,35 @@ async def create_trial_subscription(
|
||||
user_id: int,
|
||||
duration_days: int = None,
|
||||
traffic_limit_gb: int = None,
|
||||
device_limit: int = None,
|
||||
device_limit: Optional[int] = None,
|
||||
squad_uuid: str = None
|
||||
) -> Subscription:
|
||||
|
||||
duration_days = duration_days or settings.TRIAL_DURATION_DAYS
|
||||
traffic_limit_gb = traffic_limit_gb or settings.TRIAL_TRAFFIC_LIMIT_GB
|
||||
device_limit = device_limit or settings.TRIAL_DEVICE_LIMIT
|
||||
squad_uuid = squad_uuid or settings.TRIAL_SQUAD_UUID
|
||||
|
||||
if device_limit is None:
|
||||
device_limit = settings.TRIAL_DEVICE_LIMIT
|
||||
if not squad_uuid:
|
||||
try:
|
||||
from app.database.crud.server_squad import get_random_trial_squad_uuid
|
||||
|
||||
squad_uuid = await get_random_trial_squad_uuid(db)
|
||||
|
||||
if squad_uuid:
|
||||
logger.debug(
|
||||
"Выбран сквад %s для триальной подписки пользователя %s",
|
||||
squad_uuid,
|
||||
user_id,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Не удалось получить сквад для триальной подписки пользователя %s: %s",
|
||||
user_id,
|
||||
error,
|
||||
)
|
||||
|
||||
end_date = datetime.utcnow() + timedelta(days=duration_days)
|
||||
|
||||
|
||||
subscription = Subscription(
|
||||
user_id=user_id,
|
||||
status=SubscriptionStatus.ACTIVE.value,
|
||||
@@ -55,14 +80,43 @@ async def create_trial_subscription(
|
||||
end_date=end_date,
|
||||
traffic_limit_gb=traffic_limit_gb,
|
||||
device_limit=device_limit,
|
||||
connected_squads=[squad_uuid] if squad_uuid else []
|
||||
connected_squads=[squad_uuid] if squad_uuid else [],
|
||||
autopay_enabled=settings.is_autopay_enabled_by_default(),
|
||||
autopay_days_before=settings.DEFAULT_AUTOPAY_DAYS_BEFORE,
|
||||
)
|
||||
|
||||
db.add(subscription)
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
|
||||
logger.info(f"🎁 Создана триальная подписка для пользователя {user_id}")
|
||||
|
||||
if squad_uuid:
|
||||
try:
|
||||
from app.database.crud.server_squad import (
|
||||
get_server_ids_by_uuids,
|
||||
add_user_to_servers,
|
||||
)
|
||||
|
||||
server_ids = await get_server_ids_by_uuids(db, [squad_uuid])
|
||||
if server_ids:
|
||||
await add_user_to_servers(db, server_ids)
|
||||
logger.info(
|
||||
"📈 Обновлен счетчик пользователей для триального сквада %s",
|
||||
squad_uuid,
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"⚠️ Не удалось найти серверы для обновления счетчика (сквад %s)",
|
||||
squad_uuid,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"⚠️ Ошибка обновления счетчика пользователей для триального сквада %s: %s",
|
||||
squad_uuid,
|
||||
error,
|
||||
)
|
||||
|
||||
return subscription
|
||||
|
||||
|
||||
@@ -70,13 +124,17 @@ async def create_paid_subscription(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
duration_days: int,
|
||||
traffic_limit_gb: int = 0,
|
||||
device_limit: int = 1,
|
||||
connected_squads: List[str] = None
|
||||
traffic_limit_gb: int = 0,
|
||||
device_limit: Optional[int] = None,
|
||||
connected_squads: List[str] = None,
|
||||
update_server_counters: bool = False,
|
||||
) -> Subscription:
|
||||
|
||||
end_date = datetime.utcnow() + timedelta(days=duration_days)
|
||||
|
||||
if device_limit is None:
|
||||
device_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
|
||||
subscription = Subscription(
|
||||
user_id=user_id,
|
||||
status=SubscriptionStatus.ACTIVE.value,
|
||||
@@ -85,14 +143,46 @@ async def create_paid_subscription(
|
||||
end_date=end_date,
|
||||
traffic_limit_gb=traffic_limit_gb,
|
||||
device_limit=device_limit,
|
||||
connected_squads=connected_squads or []
|
||||
connected_squads=connected_squads or [],
|
||||
autopay_enabled=settings.is_autopay_enabled_by_default(),
|
||||
autopay_days_before=settings.DEFAULT_AUTOPAY_DAYS_BEFORE,
|
||||
)
|
||||
|
||||
db.add(subscription)
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
logger.info(f"💎 Создана платная подписка для пользователя {user_id}")
|
||||
logger.info(f"💎 Создана платная подписка для пользователя {user_id}, ID: {subscription.id}, статус: {subscription.status}")
|
||||
|
||||
squad_uuids = list(connected_squads or [])
|
||||
if update_server_counters and squad_uuids:
|
||||
try:
|
||||
from app.database.crud.server_squad import (
|
||||
get_server_ids_by_uuids,
|
||||
add_user_to_servers,
|
||||
)
|
||||
|
||||
server_ids = await get_server_ids_by_uuids(db, squad_uuids)
|
||||
if server_ids:
|
||||
await add_user_to_servers(db, server_ids)
|
||||
logger.info(
|
||||
"📈 Обновлен счетчик пользователей для платной подписки пользователя %s (сквады: %s)",
|
||||
user_id,
|
||||
squad_uuids,
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"⚠️ Не удалось найти серверы для обновления счетчика платной подписки пользователя %s (сквады: %s)",
|
||||
user_id,
|
||||
squad_uuids,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"⚠️ Ошибка обновления счетчика пользователей серверов для платной подписки пользователя %s: %s",
|
||||
user_id,
|
||||
error,
|
||||
)
|
||||
|
||||
return subscription
|
||||
|
||||
|
||||
@@ -102,29 +192,79 @@ async def extend_subscription(
|
||||
days: int
|
||||
) -> Subscription:
|
||||
current_time = datetime.utcnow()
|
||||
|
||||
|
||||
logger.info(f"🔄 Продление подписки {subscription.id} на {days} дней")
|
||||
logger.info(f"📊 Текущие параметры: статус={subscription.status}, окончание={subscription.end_date}")
|
||||
|
||||
if subscription.end_date > current_time:
|
||||
|
||||
# НОВОЕ: Вычисляем бонусные дни от триала ДО изменения end_date
|
||||
bonus_days = 0
|
||||
if subscription.is_trial and settings.TRIAL_ADD_REMAINING_DAYS_TO_PAID:
|
||||
# Вычисляем остаток триала
|
||||
if subscription.end_date and subscription.end_date > current_time:
|
||||
remaining = subscription.end_date - current_time
|
||||
if remaining.total_seconds() > 0:
|
||||
bonus_days = max(0, remaining.days)
|
||||
logger.info(
|
||||
"🎁 Обнаружен остаток триала: %s дней для подписки %s",
|
||||
bonus_days,
|
||||
subscription.id,
|
||||
)
|
||||
|
||||
# Применяем продление с учетом бонусных дней
|
||||
total_days = days + bonus_days
|
||||
|
||||
if days < 0:
|
||||
subscription.end_date = subscription.end_date + timedelta(days=days)
|
||||
logger.info(f"📅 Подписка активна, добавляем {days} дней к текущей дате окончания")
|
||||
logger.info(
|
||||
"📅 Срок подписки уменьшен на %s дней, новая дата окончания: %s",
|
||||
abs(days),
|
||||
subscription.end_date,
|
||||
)
|
||||
elif subscription.end_date > current_time:
|
||||
subscription.end_date = subscription.end_date + timedelta(days=total_days)
|
||||
logger.info(f"📅 Подписка активна, добавляем {total_days} дней ({days} + {bonus_days} бонус) к текущей дате окончания")
|
||||
else:
|
||||
subscription.end_date = current_time + timedelta(days=days)
|
||||
logger.info(f"📅 Подписка истекла, устанавливаем новую дату окончания")
|
||||
subscription.end_date = current_time + timedelta(days=total_days)
|
||||
logger.info(f"📅 Подписка истекла, устанавливаем новую дату окончания на {total_days} дней ({days} + {bonus_days} бонус)")
|
||||
|
||||
# УДАЛЕНО: Автоматическая конвертация триала по длительности
|
||||
# Теперь триал конвертируется ТОЛЬКО после успешного коммита продления
|
||||
# и ТОЛЬКО вызывающей функцией (например, _auto_extend_subscription)
|
||||
|
||||
# Логируем статус подписки перед проверкой
|
||||
logger.info(f"🔄 Продление подписки {subscription.id}, текущий статус: {subscription.status}, дни: {days}")
|
||||
|
||||
if subscription.status == SubscriptionStatus.EXPIRED.value:
|
||||
if days > 0 and subscription.status in (
|
||||
SubscriptionStatus.EXPIRED.value,
|
||||
SubscriptionStatus.DISABLED.value,
|
||||
):
|
||||
previous_status = subscription.status
|
||||
subscription.status = SubscriptionStatus.ACTIVE.value
|
||||
logger.info(f"🔄 Статус изменён с EXPIRED на ACTIVE")
|
||||
|
||||
logger.info(
|
||||
"🔄 Статус подписки %s изменён с %s на ACTIVE",
|
||||
subscription.id,
|
||||
previous_status,
|
||||
)
|
||||
elif days > 0 and subscription.status == SubscriptionStatus.PENDING.value:
|
||||
logger.warning(
|
||||
"⚠️ Попытка продлить PENDING подписку %s, дни: %s",
|
||||
subscription.id,
|
||||
days
|
||||
)
|
||||
|
||||
if settings.RESET_TRAFFIC_ON_PAYMENT:
|
||||
subscription.traffic_used_gb = 0.0
|
||||
logger.info("🔄 Сбрасываем использованный трафик согласно настройке RESET_TRAFFIC_ON_PAYMENT")
|
||||
|
||||
subscription.updated_at = current_time
|
||||
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
await clear_notifications(db, subscription.id)
|
||||
|
||||
logger.info(f"✅ Подписка продлена до: {subscription.end_date}")
|
||||
logger.info(f"📊 Новые параметры: статус={subscription.status}, окончание={subscription.end_date}")
|
||||
|
||||
|
||||
return subscription
|
||||
|
||||
|
||||
@@ -198,6 +338,64 @@ async def remove_subscription_squad(
|
||||
return subscription
|
||||
|
||||
|
||||
async def decrement_subscription_server_counts(
|
||||
db: AsyncSession,
|
||||
subscription: Optional[Subscription],
|
||||
*,
|
||||
subscription_servers: Optional[Iterable[SubscriptionServer]] = None,
|
||||
) -> None:
|
||||
"""Decrease server counters linked to the provided subscription."""
|
||||
|
||||
if not subscription:
|
||||
return
|
||||
|
||||
server_ids: set[int] = set()
|
||||
|
||||
if subscription_servers is not None:
|
||||
for sub_server in subscription_servers:
|
||||
if sub_server and sub_server.server_squad_id is not None:
|
||||
server_ids.add(sub_server.server_squad_id)
|
||||
else:
|
||||
try:
|
||||
ids_from_links = await get_subscription_server_ids(db, subscription.id)
|
||||
server_ids.update(ids_from_links)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"⚠️ Не удалось получить серверы подписки %s для уменьшения счетчика: %s",
|
||||
subscription.id,
|
||||
error,
|
||||
)
|
||||
|
||||
connected_squads = list(subscription.connected_squads or [])
|
||||
if connected_squads:
|
||||
try:
|
||||
from app.database.crud.server_squad import get_server_ids_by_uuids
|
||||
|
||||
squad_server_ids = await get_server_ids_by_uuids(db, connected_squads)
|
||||
server_ids.update(squad_server_ids)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"⚠️ Не удалось сопоставить сквады подписки %s с серверами: %s",
|
||||
subscription.id,
|
||||
error,
|
||||
)
|
||||
|
||||
if not server_ids:
|
||||
return
|
||||
|
||||
try:
|
||||
from app.database.crud.server_squad import remove_user_from_servers
|
||||
|
||||
await remove_user_from_servers(db, sorted(server_ids))
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"⚠️ Ошибка уменьшения счетчика пользователей серверов %s для подписки %s: %s",
|
||||
list(server_ids),
|
||||
subscription.id,
|
||||
error,
|
||||
)
|
||||
|
||||
|
||||
async def update_subscription_autopay(
|
||||
db: AsyncSession,
|
||||
subscription: Subscription,
|
||||
@@ -440,6 +638,8 @@ async def add_subscription_servers(
|
||||
server_squad_ids: List[int],
|
||||
paid_prices: List[int] = None
|
||||
) -> Subscription:
|
||||
await db.refresh(subscription)
|
||||
|
||||
if paid_prices is None:
|
||||
months_remaining = get_remaining_months(subscription.end_date)
|
||||
paid_prices = []
|
||||
@@ -456,7 +656,7 @@ async def add_subscription_servers(
|
||||
|
||||
for i, server_id in enumerate(server_squad_ids):
|
||||
subscription_server = SubscriptionServer(
|
||||
subscription_id=subscription.id,
|
||||
subscription_id=subscription.id,
|
||||
server_squad_id=server_id,
|
||||
paid_price_kopeks=paid_prices[i] if i < len(paid_prices) else 0
|
||||
)
|
||||
@@ -465,7 +665,7 @@ async def add_subscription_servers(
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
logger.info(f"🌍 К подписке {subscription.id} добавлено {len(server_squad_ids)} серверов с ценами: {paid_prices}")
|
||||
logger.info(f"🌐 К подписке {subscription.id} добавлено {len(server_squad_ids)} серверов с ценами: {paid_prices}")
|
||||
return subscription
|
||||
|
||||
async def get_server_monthly_price(
|
||||
@@ -491,49 +691,144 @@ async def get_servers_monthly_prices(
|
||||
prices.append(price)
|
||||
return prices
|
||||
|
||||
def _get_discount_percent(
|
||||
user: Optional[User],
|
||||
promo_group: Optional[PromoGroup],
|
||||
category: str,
|
||||
*,
|
||||
period_days: Optional[int] = None,
|
||||
) -> int:
|
||||
if user is not None:
|
||||
try:
|
||||
return user.get_promo_discount(category, period_days)
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
if promo_group is not None:
|
||||
return promo_group.get_discount_percent(category, period_days)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
async def calculate_subscription_total_cost(
|
||||
db: AsyncSession,
|
||||
period_days: int,
|
||||
traffic_gb: int,
|
||||
server_squad_ids: List[int],
|
||||
devices: int
|
||||
devices: int,
|
||||
*,
|
||||
user: Optional[User] = None,
|
||||
promo_group: Optional[PromoGroup] = None,
|
||||
) -> Tuple[int, dict]:
|
||||
from app.config import PERIOD_PRICES
|
||||
|
||||
months_in_period = calculate_months_from_days(period_days)
|
||||
|
||||
base_price = PERIOD_PRICES.get(period_days, 0)
|
||||
base_price_original = PERIOD_PRICES.get(period_days, 0)
|
||||
period_discount_percent = _get_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"period",
|
||||
period_days=period_days,
|
||||
)
|
||||
base_discount_total = base_price_original * period_discount_percent // 100
|
||||
base_price = base_price_original - base_discount_total
|
||||
|
||||
promo_group = promo_group or (user.promo_group if user else None)
|
||||
|
||||
traffic_price_per_month = settings.get_traffic_price(traffic_gb)
|
||||
total_traffic_price = traffic_price_per_month * months_in_period
|
||||
|
||||
traffic_discount_percent = _get_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"traffic",
|
||||
period_days=period_days,
|
||||
)
|
||||
traffic_discount_per_month = traffic_price_per_month * traffic_discount_percent // 100
|
||||
discounted_traffic_per_month = traffic_price_per_month - traffic_discount_per_month
|
||||
total_traffic_price = discounted_traffic_per_month * months_in_period
|
||||
total_traffic_discount = traffic_discount_per_month * months_in_period
|
||||
|
||||
servers_prices = await get_servers_monthly_prices(db, server_squad_ids)
|
||||
servers_price_per_month = sum(servers_prices)
|
||||
total_servers_price = servers_price_per_month * months_in_period
|
||||
|
||||
servers_discount_percent = _get_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"servers",
|
||||
period_days=period_days,
|
||||
)
|
||||
servers_discount_per_month = servers_price_per_month * servers_discount_percent // 100
|
||||
discounted_servers_per_month = servers_price_per_month - servers_discount_per_month
|
||||
total_servers_price = discounted_servers_per_month * months_in_period
|
||||
total_servers_discount = servers_discount_per_month * months_in_period
|
||||
|
||||
additional_devices = max(0, devices - settings.DEFAULT_DEVICE_LIMIT)
|
||||
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
|
||||
total_devices_price = devices_price_per_month * months_in_period
|
||||
|
||||
devices_discount_percent = _get_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"devices",
|
||||
period_days=period_days,
|
||||
)
|
||||
devices_discount_per_month = devices_price_per_month * devices_discount_percent // 100
|
||||
discounted_devices_per_month = devices_price_per_month - devices_discount_per_month
|
||||
total_devices_price = discounted_devices_per_month * months_in_period
|
||||
total_devices_discount = devices_discount_per_month * months_in_period
|
||||
|
||||
total_cost = base_price + total_traffic_price + total_servers_price + total_devices_price
|
||||
|
||||
|
||||
details = {
|
||||
'base_price': base_price,
|
||||
'base_price_original': base_price_original,
|
||||
'base_discount_percent': period_discount_percent,
|
||||
'base_discount_total': base_discount_total,
|
||||
'traffic_price_per_month': traffic_price_per_month,
|
||||
'traffic_discount_percent': traffic_discount_percent,
|
||||
'traffic_discount_total': total_traffic_discount,
|
||||
'total_traffic_price': total_traffic_price,
|
||||
'servers_price_per_month': servers_price_per_month,
|
||||
'servers_discount_percent': servers_discount_percent,
|
||||
'servers_discount_total': total_servers_discount,
|
||||
'total_servers_price': total_servers_price,
|
||||
'devices_price_per_month': devices_price_per_month,
|
||||
'devices_discount_percent': devices_discount_percent,
|
||||
'devices_discount_total': total_devices_discount,
|
||||
'total_devices_price': total_devices_price,
|
||||
'months_in_period': months_in_period,
|
||||
'servers_individual_prices': [price * months_in_period for price in servers_prices]
|
||||
'servers_individual_prices': [
|
||||
(price - (price * servers_discount_percent // 100)) * months_in_period
|
||||
for price in servers_prices
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
logger.info(f"📊 Расчет стоимости подписки на {period_days} дней ({months_in_period} мес):")
|
||||
logger.info(f" Базовый период: {base_price/100}₽")
|
||||
logger.info(f" Трафик: {traffic_price_per_month/100}₽/мес × {months_in_period} = {total_traffic_price/100}₽")
|
||||
logger.info(f" Серверы: {servers_price_per_month/100}₽/мес × {months_in_period} = {total_servers_price/100}₽")
|
||||
logger.info(f" Устройства: {devices_price_per_month/100}₽/мес × {months_in_period} = {total_devices_price/100}₽")
|
||||
if total_traffic_price > 0:
|
||||
message = (
|
||||
f" Трафик: {traffic_price_per_month/100}₽/мес × {months_in_period} = {total_traffic_price/100}₽"
|
||||
)
|
||||
if total_traffic_discount > 0:
|
||||
message += (
|
||||
f" (скидка {traffic_discount_percent}%: -{total_traffic_discount/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
if total_servers_price > 0:
|
||||
message = (
|
||||
f" Серверы: {servers_price_per_month/100}₽/мес × {months_in_period} = {total_servers_price/100}₽"
|
||||
)
|
||||
if total_servers_discount > 0:
|
||||
message += (
|
||||
f" (скидка {servers_discount_percent}%: -{total_servers_discount/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
if total_devices_price > 0:
|
||||
message = (
|
||||
f" Устройства: {devices_price_per_month/100}₽/мес × {months_in_period} = {total_devices_price/100}₽"
|
||||
)
|
||||
if total_devices_discount > 0:
|
||||
message += (
|
||||
f" (скидка {devices_discount_percent}%: -{total_devices_discount/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
logger.info(f" ИТОГО: {total_cost/100}₽")
|
||||
|
||||
return total_cost, details
|
||||
@@ -607,19 +902,33 @@ async def remove_subscription_servers(
|
||||
async def get_subscription_renewal_cost(
|
||||
db: AsyncSession,
|
||||
subscription_id: int,
|
||||
period_days: int
|
||||
period_days: int,
|
||||
*,
|
||||
user: Optional[User] = None,
|
||||
promo_group: Optional[PromoGroup] = None,
|
||||
) -> int:
|
||||
try:
|
||||
from app.config import PERIOD_PRICES
|
||||
|
||||
|
||||
months_in_period = calculate_months_from_days(period_days)
|
||||
|
||||
|
||||
base_price = PERIOD_PRICES.get(period_days, 0)
|
||||
|
||||
subscription = await db.get(Subscription, subscription_id)
|
||||
|
||||
result = await db.execute(
|
||||
select(Subscription)
|
||||
.options(
|
||||
selectinload(Subscription.user).selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
|
||||
)
|
||||
.where(Subscription.id == subscription_id)
|
||||
)
|
||||
subscription = result.scalar_one_or_none()
|
||||
if not subscription:
|
||||
return base_price
|
||||
|
||||
|
||||
if user is None:
|
||||
user = subscription.user
|
||||
promo_group = promo_group or (user.promo_group if user else None)
|
||||
|
||||
servers_info = await get_subscription_servers(db, subscription_id)
|
||||
servers_price_per_month = 0
|
||||
for server_info in servers_info:
|
||||
@@ -630,23 +939,74 @@ async def get_subscription_renewal_cost(
|
||||
)
|
||||
current_server_price = result.scalar() or 0
|
||||
servers_price_per_month += current_server_price
|
||||
|
||||
total_servers_cost = servers_price_per_month * months_in_period
|
||||
|
||||
|
||||
servers_discount_percent = _get_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"servers",
|
||||
period_days=period_days,
|
||||
)
|
||||
servers_discount_per_month = servers_price_per_month * servers_discount_percent // 100
|
||||
discounted_servers_per_month = servers_price_per_month - servers_discount_per_month
|
||||
total_servers_cost = discounted_servers_per_month * months_in_period
|
||||
total_servers_discount = servers_discount_per_month * months_in_period
|
||||
|
||||
traffic_price_per_month = settings.get_traffic_price(subscription.traffic_limit_gb)
|
||||
total_traffic_cost = traffic_price_per_month * months_in_period
|
||||
|
||||
traffic_discount_percent = _get_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"traffic",
|
||||
period_days=period_days,
|
||||
)
|
||||
traffic_discount_per_month = traffic_price_per_month * traffic_discount_percent // 100
|
||||
discounted_traffic_per_month = traffic_price_per_month - traffic_discount_per_month
|
||||
total_traffic_cost = discounted_traffic_per_month * months_in_period
|
||||
total_traffic_discount = traffic_discount_per_month * months_in_period
|
||||
|
||||
additional_devices = max(0, subscription.device_limit - settings.DEFAULT_DEVICE_LIMIT)
|
||||
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
|
||||
total_devices_cost = devices_price_per_month * months_in_period
|
||||
|
||||
devices_discount_percent = _get_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"devices",
|
||||
period_days=period_days,
|
||||
)
|
||||
devices_discount_per_month = devices_price_per_month * devices_discount_percent // 100
|
||||
discounted_devices_per_month = devices_price_per_month - devices_discount_per_month
|
||||
total_devices_cost = discounted_devices_per_month * months_in_period
|
||||
total_devices_discount = devices_discount_per_month * months_in_period
|
||||
|
||||
total_cost = base_price + total_servers_cost + total_traffic_cost + total_devices_cost
|
||||
|
||||
|
||||
logger.info(f"💰 Расчет продления подписки {subscription_id} на {period_days} дней ({months_in_period} мес):")
|
||||
logger.info(f" 📅 Период: {base_price/100}₽")
|
||||
logger.info(f" 🌍 Серверы: {servers_price_per_month/100}₽/мес × {months_in_period} = {total_servers_cost/100}₽")
|
||||
logger.info(f" 📊 Трафик: {traffic_price_per_month/100}₽/мес × {months_in_period} = {total_traffic_cost/100}₽")
|
||||
logger.info(f" 📱 Устройства: {devices_price_per_month/100}₽/мес × {months_in_period} = {total_devices_cost/100}₽")
|
||||
if total_servers_cost > 0:
|
||||
message = (
|
||||
f" 🌍 Серверы: {servers_price_per_month/100}₽/мес × {months_in_period} = {total_servers_cost/100}₽"
|
||||
)
|
||||
if total_servers_discount > 0:
|
||||
message += (
|
||||
f" (скидка {servers_discount_percent}%: -{total_servers_discount/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
if total_traffic_cost > 0:
|
||||
message = (
|
||||
f" 📊 Трафик: {traffic_price_per_month/100}₽/мес × {months_in_period} = {total_traffic_cost/100}₽"
|
||||
)
|
||||
if total_traffic_discount > 0:
|
||||
message += (
|
||||
f" (скидка {traffic_discount_percent}%: -{total_traffic_discount/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
if total_devices_cost > 0:
|
||||
message = (
|
||||
f" 📱 Устройства: {devices_price_per_month/100}₽/мес × {months_in_period} = {total_devices_cost/100}₽"
|
||||
)
|
||||
if total_devices_discount > 0:
|
||||
message += (
|
||||
f" (скидка {devices_discount_percent}%: -{total_devices_discount/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
logger.info(f" 💎 ИТОГО: {total_cost/100}₽")
|
||||
|
||||
return total_cost
|
||||
@@ -661,27 +1021,65 @@ async def calculate_addon_cost_for_remaining_period(
|
||||
subscription: Subscription,
|
||||
additional_traffic_gb: int = 0,
|
||||
additional_devices: int = 0,
|
||||
additional_server_ids: List[int] = None
|
||||
additional_server_ids: List[int] = None,
|
||||
*,
|
||||
user: Optional[User] = None,
|
||||
promo_group: Optional[PromoGroup] = None,
|
||||
) -> int:
|
||||
if additional_server_ids is None:
|
||||
additional_server_ids = []
|
||||
|
||||
|
||||
months_to_pay = get_remaining_months(subscription.end_date)
|
||||
|
||||
period_hint_days = months_to_pay * 30 if months_to_pay > 0 else None
|
||||
|
||||
total_cost = 0
|
||||
|
||||
|
||||
if user is None:
|
||||
user = getattr(subscription, "user", None)
|
||||
promo_group = promo_group or (user.promo_group if user else None)
|
||||
|
||||
if additional_traffic_gb > 0:
|
||||
traffic_price_per_month = settings.get_traffic_price(additional_traffic_gb)
|
||||
traffic_total_cost = traffic_price_per_month * months_to_pay
|
||||
traffic_discount_percent = _get_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"traffic",
|
||||
period_days=period_hint_days,
|
||||
)
|
||||
traffic_discount_per_month = traffic_price_per_month * traffic_discount_percent // 100
|
||||
discounted_traffic_per_month = traffic_price_per_month - traffic_discount_per_month
|
||||
traffic_total_cost = discounted_traffic_per_month * months_to_pay
|
||||
total_cost += traffic_total_cost
|
||||
logger.info(f"Трафик +{additional_traffic_gb}ГБ: {traffic_price_per_month/100}₽/мес × {months_to_pay} = {traffic_total_cost/100}₽")
|
||||
|
||||
message = (
|
||||
f"Трафик +{additional_traffic_gb}ГБ: {traffic_price_per_month/100}₽/мес × {months_to_pay} = {traffic_total_cost/100}₽"
|
||||
)
|
||||
if traffic_discount_per_month > 0:
|
||||
message += (
|
||||
f" (скидка {traffic_discount_percent}%: -{traffic_discount_per_month * months_to_pay/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
|
||||
if additional_devices > 0:
|
||||
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
|
||||
devices_total_cost = devices_price_per_month * months_to_pay
|
||||
devices_discount_percent = _get_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"devices",
|
||||
period_days=period_hint_days,
|
||||
)
|
||||
devices_discount_per_month = devices_price_per_month * devices_discount_percent // 100
|
||||
discounted_devices_per_month = devices_price_per_month - devices_discount_per_month
|
||||
devices_total_cost = discounted_devices_per_month * months_to_pay
|
||||
total_cost += devices_total_cost
|
||||
logger.info(f"Устройства +{additional_devices}: {devices_price_per_month/100}₽/мес × {months_to_pay} = {devices_total_cost/100}₽")
|
||||
|
||||
message = (
|
||||
f"Устройства +{additional_devices}: {devices_price_per_month/100}₽/мес × {months_to_pay} = {devices_total_cost/100}₽"
|
||||
)
|
||||
if devices_discount_per_month > 0:
|
||||
message += (
|
||||
f" (скидка {devices_discount_percent}%: -{devices_discount_per_month * months_to_pay/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
|
||||
if additional_server_ids:
|
||||
from app.database.models import ServerSquad
|
||||
for server_id in additional_server_ids:
|
||||
@@ -692,9 +1090,24 @@ async def calculate_addon_cost_for_remaining_period(
|
||||
server_data = result.first()
|
||||
if server_data:
|
||||
server_price_per_month, server_name = server_data
|
||||
server_total_cost = server_price_per_month * months_to_pay
|
||||
servers_discount_percent = _get_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"servers",
|
||||
period_days=period_hint_days,
|
||||
)
|
||||
server_discount_per_month = server_price_per_month * servers_discount_percent // 100
|
||||
discounted_server_per_month = server_price_per_month - server_discount_per_month
|
||||
server_total_cost = discounted_server_per_month * months_to_pay
|
||||
total_cost += server_total_cost
|
||||
logger.info(f"Сервер {server_name}: {server_price_per_month/100}₽/мес × {months_to_pay} = {server_total_cost/100}₽")
|
||||
message = (
|
||||
f"Сервер {server_name}: {server_price_per_month/100}₽/мес × {months_to_pay} = {server_total_cost/100}₽"
|
||||
)
|
||||
if server_discount_per_month > 0:
|
||||
message += (
|
||||
f" (скидка {servers_discount_percent}%: -{server_discount_per_month * months_to_pay/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
|
||||
logger.info(f"💰 Итого доплата за {months_to_pay} мес: {total_cost/100}₽")
|
||||
return total_cost
|
||||
@@ -721,6 +1134,14 @@ async def check_and_update_subscription_status(
|
||||
|
||||
current_time = datetime.utcnow()
|
||||
|
||||
logger.info(
|
||||
"🔍 Проверка статуса подписки %s, текущий статус: %s, дата окончания: %s, текущее время: %s",
|
||||
subscription.id,
|
||||
subscription.status,
|
||||
format_local_datetime(subscription.end_date),
|
||||
format_local_datetime(current_time),
|
||||
)
|
||||
|
||||
if (subscription.status == SubscriptionStatus.ACTIVE.value and
|
||||
subscription.end_date <= current_time):
|
||||
|
||||
@@ -731,9 +1152,68 @@ async def check_and_update_subscription_status(
|
||||
await db.refresh(subscription)
|
||||
|
||||
logger.info(f"⏰ Статус подписки пользователя {subscription.user_id} изменен на 'expired'")
|
||||
elif subscription.status == SubscriptionStatus.PENDING.value:
|
||||
logger.info(f"ℹ️ Проверка PENDING подписки {subscription.id}, статус остается без изменений")
|
||||
|
||||
return subscription
|
||||
|
||||
async def create_subscription_no_commit(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
status: str = "trial",
|
||||
is_trial: bool = True,
|
||||
end_date: datetime = None,
|
||||
traffic_limit_gb: int = 10,
|
||||
traffic_used_gb: float = 0.0,
|
||||
device_limit: int = 1,
|
||||
connected_squads: list = None,
|
||||
remnawave_short_uuid: str = None,
|
||||
subscription_url: str = "",
|
||||
subscription_crypto_link: str = "",
|
||||
autopay_enabled: Optional[bool] = None,
|
||||
autopay_days_before: Optional[int] = None,
|
||||
) -> Subscription:
|
||||
"""
|
||||
Создает подписку без немедленного коммита для пакетной обработки
|
||||
"""
|
||||
|
||||
if end_date is None:
|
||||
end_date = datetime.utcnow() + timedelta(days=3)
|
||||
|
||||
if connected_squads is None:
|
||||
connected_squads = []
|
||||
|
||||
subscription = Subscription(
|
||||
user_id=user_id,
|
||||
status=status,
|
||||
is_trial=is_trial,
|
||||
end_date=end_date,
|
||||
traffic_limit_gb=traffic_limit_gb,
|
||||
traffic_used_gb=traffic_used_gb,
|
||||
device_limit=device_limit,
|
||||
connected_squads=connected_squads,
|
||||
remnawave_short_uuid=remnawave_short_uuid,
|
||||
subscription_url=subscription_url,
|
||||
subscription_crypto_link=subscription_crypto_link,
|
||||
autopay_enabled=(
|
||||
settings.is_autopay_enabled_by_default()
|
||||
if autopay_enabled is None
|
||||
else autopay_enabled
|
||||
),
|
||||
autopay_days_before=(
|
||||
settings.DEFAULT_AUTOPAY_DAYS_BEFORE
|
||||
if autopay_days_before is None
|
||||
else autopay_days_before
|
||||
),
|
||||
)
|
||||
|
||||
db.add(subscription)
|
||||
# Не коммитим сразу, оставляем для пакетной обработки
|
||||
|
||||
logger.info(f"✅ Подготовлена подписка для пользователя {user_id} (ожидает коммита)")
|
||||
return subscription
|
||||
|
||||
|
||||
async def create_subscription(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
@@ -745,7 +1225,10 @@ async def create_subscription(
|
||||
device_limit: int = 1,
|
||||
connected_squads: list = None,
|
||||
remnawave_short_uuid: str = None,
|
||||
subscription_url: str = ""
|
||||
subscription_url: str = "",
|
||||
subscription_crypto_link: str = "",
|
||||
autopay_enabled: Optional[bool] = None,
|
||||
autopay_days_before: Optional[int] = None,
|
||||
) -> Subscription:
|
||||
|
||||
if end_date is None:
|
||||
@@ -764,7 +1247,18 @@ async def create_subscription(
|
||||
device_limit=device_limit,
|
||||
connected_squads=connected_squads,
|
||||
remnawave_short_uuid=remnawave_short_uuid,
|
||||
subscription_url=subscription_url
|
||||
subscription_url=subscription_url,
|
||||
subscription_crypto_link=subscription_crypto_link,
|
||||
autopay_enabled=(
|
||||
settings.is_autopay_enabled_by_default()
|
||||
if autopay_enabled is None
|
||||
else autopay_enabled
|
||||
),
|
||||
autopay_days_before=(
|
||||
settings.DEFAULT_AUTOPAY_DAYS_BEFORE
|
||||
if autopay_days_before is None
|
||||
else autopay_days_before
|
||||
),
|
||||
)
|
||||
|
||||
db.add(subscription)
|
||||
@@ -773,3 +1267,130 @@ async def create_subscription(
|
||||
|
||||
logger.info(f"✅ Создана подписка для пользователя {user_id}")
|
||||
return subscription
|
||||
|
||||
|
||||
async def create_pending_subscription(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
duration_days: int,
|
||||
traffic_limit_gb: int = 0,
|
||||
device_limit: int = 1,
|
||||
connected_squads: List[str] = None,
|
||||
payment_method: str = "pending",
|
||||
total_price_kopeks: int = 0
|
||||
) -> Subscription:
|
||||
"""Creates a pending subscription that will be activated after payment."""
|
||||
|
||||
current_time = datetime.utcnow()
|
||||
end_date = current_time + timedelta(days=duration_days)
|
||||
|
||||
existing_subscription = await get_subscription_by_user_id(db, user_id)
|
||||
|
||||
if existing_subscription:
|
||||
if (
|
||||
existing_subscription.status == SubscriptionStatus.ACTIVE.value
|
||||
and existing_subscription.end_date > current_time
|
||||
):
|
||||
logger.warning(
|
||||
"⚠️ Попытка создать pending подписку для активного пользователя %s. Возвращаем существующую запись.",
|
||||
user_id,
|
||||
)
|
||||
return existing_subscription
|
||||
|
||||
existing_subscription.status = SubscriptionStatus.PENDING.value
|
||||
existing_subscription.is_trial = False
|
||||
existing_subscription.start_date = current_time
|
||||
existing_subscription.end_date = end_date
|
||||
existing_subscription.traffic_limit_gb = traffic_limit_gb
|
||||
existing_subscription.device_limit = device_limit
|
||||
existing_subscription.connected_squads = connected_squads or []
|
||||
existing_subscription.traffic_used_gb = 0.0
|
||||
existing_subscription.updated_at = current_time
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(existing_subscription)
|
||||
|
||||
logger.info(
|
||||
"♻️ Обновлена ожидающая подписка пользователя %s, ID: %s, метод оплаты: %s",
|
||||
user_id,
|
||||
existing_subscription.id,
|
||||
payment_method,
|
||||
)
|
||||
return existing_subscription
|
||||
|
||||
subscription = Subscription(
|
||||
user_id=user_id,
|
||||
status=SubscriptionStatus.PENDING.value,
|
||||
is_trial=False,
|
||||
start_date=current_time,
|
||||
end_date=end_date,
|
||||
traffic_limit_gb=traffic_limit_gb,
|
||||
device_limit=device_limit,
|
||||
connected_squads=connected_squads or [],
|
||||
autopay_enabled=settings.is_autopay_enabled_by_default(),
|
||||
autopay_days_before=settings.DEFAULT_AUTOPAY_DAYS_BEFORE,
|
||||
)
|
||||
|
||||
db.add(subscription)
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
logger.info(
|
||||
"💳 Создана ожидающая подписка для пользователя %s, ID: %s, метод оплаты: %s",
|
||||
user_id,
|
||||
subscription.id,
|
||||
payment_method,
|
||||
)
|
||||
|
||||
return subscription
|
||||
|
||||
|
||||
async def activate_pending_subscription(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
period_days: int = None
|
||||
) -> Optional[Subscription]:
|
||||
"""Активирует pending подписку пользователя, меняя её статус на ACTIVE."""
|
||||
from sqlalchemy import and_
|
||||
|
||||
logger.info(f"Активация pending подписки: пользователь {user_id}, период {period_days} дней")
|
||||
|
||||
# Находим pending подписку пользователя
|
||||
result = await db.execute(
|
||||
select(Subscription)
|
||||
.where(
|
||||
and_(
|
||||
Subscription.user_id == user_id,
|
||||
Subscription.status == SubscriptionStatus.PENDING.value
|
||||
)
|
||||
)
|
||||
)
|
||||
pending_subscription = result.scalar_one_or_none()
|
||||
|
||||
if not pending_subscription:
|
||||
logger.warning(f"Не найдена pending подписка для пользователя {user_id}")
|
||||
return None
|
||||
|
||||
logger.info(f"Найдена pending подписка {pending_subscription.id} для пользователя {user_id}, статус: {pending_subscription.status}")
|
||||
|
||||
# Обновляем статус подписки на ACTIVE
|
||||
current_time = datetime.utcnow()
|
||||
pending_subscription.status = SubscriptionStatus.ACTIVE.value
|
||||
|
||||
# Если указан период, обновляем дату окончания
|
||||
if period_days is not None:
|
||||
effective_start = pending_subscription.start_date or current_time
|
||||
if effective_start < current_time:
|
||||
effective_start = current_time
|
||||
pending_subscription.end_date = effective_start + timedelta(days=period_days)
|
||||
|
||||
# Обновляем дату начала, если она не установлена или в прошлом
|
||||
if not pending_subscription.start_date or pending_subscription.start_date < current_time:
|
||||
pending_subscription.start_date = current_time
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(pending_subscription)
|
||||
|
||||
logger.info(f"Подписка пользователя {user_id} активирована, ID: {pending_subscription.id}")
|
||||
|
||||
return pending_subscription
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import SystemSetting
|
||||
|
||||
|
||||
async def upsert_system_setting(
|
||||
db: AsyncSession,
|
||||
key: str,
|
||||
value: Optional[str],
|
||||
description: Optional[str] = None,
|
||||
) -> SystemSetting:
|
||||
result = await db.execute(
|
||||
select(SystemSetting).where(SystemSetting.key == key)
|
||||
)
|
||||
setting = result.scalar_one_or_none()
|
||||
|
||||
if setting is None:
|
||||
setting = SystemSetting(key=key, value=value, description=description)
|
||||
db.add(setting)
|
||||
else:
|
||||
setting.value = value
|
||||
if description is not None:
|
||||
setting.description = description
|
||||
|
||||
await db.flush()
|
||||
return setting
|
||||
|
||||
|
||||
async def delete_system_setting(db: AsyncSession, key: str) -> None:
|
||||
result = await db.execute(
|
||||
select(SystemSetting).where(SystemSetting.key == key)
|
||||
)
|
||||
setting = result.scalar_one_or_none()
|
||||
if setting is not None:
|
||||
await db.delete(setting)
|
||||
await db.flush()
|
||||
|
||||
@@ -0,0 +1,472 @@
|
||||
from typing import List, Optional
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, desc, and_, or_, update, func
|
||||
from sqlalchemy.orm import selectinload
|
||||
from datetime import datetime
|
||||
|
||||
from app.database.models import Ticket, TicketMessage, TicketStatus, User, SupportAuditLog
|
||||
|
||||
|
||||
class TicketCRUD:
|
||||
"""CRUD операции для работы с тикетами"""
|
||||
|
||||
@staticmethod
|
||||
async def create_ticket(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
title: str,
|
||||
message_text: str,
|
||||
priority: str = "normal",
|
||||
*,
|
||||
media_type: Optional[str] = None,
|
||||
media_file_id: Optional[str] = None,
|
||||
media_caption: Optional[str] = None,
|
||||
) -> Ticket:
|
||||
"""Создать новый тикет с первым сообщением"""
|
||||
ticket = Ticket(
|
||||
user_id=user_id,
|
||||
title=title,
|
||||
status=TicketStatus.OPEN.value,
|
||||
priority=priority
|
||||
)
|
||||
db.add(ticket)
|
||||
await db.flush() # Получаем ID тикета
|
||||
|
||||
# Создаем первое сообщение
|
||||
message = TicketMessage(
|
||||
ticket_id=ticket.id,
|
||||
user_id=user_id,
|
||||
message_text=message_text,
|
||||
is_from_admin=False,
|
||||
has_media=bool(media_type and media_file_id),
|
||||
media_type=media_type,
|
||||
media_file_id=media_file_id,
|
||||
media_caption=media_caption,
|
||||
)
|
||||
db.add(message)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(ticket)
|
||||
return ticket
|
||||
|
||||
@staticmethod
|
||||
async def get_ticket_by_id(
|
||||
db: AsyncSession,
|
||||
ticket_id: int,
|
||||
load_messages: bool = True,
|
||||
load_user: bool = False
|
||||
) -> Optional[Ticket]:
|
||||
"""Получить тикет по ID"""
|
||||
query = select(Ticket).where(Ticket.id == ticket_id)
|
||||
|
||||
if load_user:
|
||||
query = query.options(selectinload(Ticket.user))
|
||||
|
||||
if load_messages:
|
||||
query = query.options(selectinload(Ticket.messages))
|
||||
|
||||
result = await db.execute(query)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
@staticmethod
|
||||
async def get_user_tickets(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
status: Optional[str] = None,
|
||||
limit: int = 20,
|
||||
offset: int = 0
|
||||
) -> List[Ticket]:
|
||||
"""Получить тикеты пользователя"""
|
||||
query = select(Ticket).where(Ticket.user_id == user_id)
|
||||
|
||||
if status:
|
||||
query = query.where(Ticket.status == status)
|
||||
|
||||
query = query.order_by(desc(Ticket.updated_at)).offset(offset).limit(limit)
|
||||
|
||||
result = await db.execute(query)
|
||||
return result.scalars().all()
|
||||
|
||||
@staticmethod
|
||||
async def count_user_tickets_by_statuses(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
statuses: List[str]
|
||||
) -> int:
|
||||
"""Подсчитать количество тикетов пользователя по списку статусов"""
|
||||
query = select(func.count()).select_from(Ticket).where(Ticket.user_id == user_id)
|
||||
if statuses:
|
||||
query = query.where(Ticket.status.in_(statuses))
|
||||
result = await db.execute(query)
|
||||
return int(result.scalar() or 0)
|
||||
|
||||
@staticmethod
|
||||
async def get_user_tickets_by_statuses(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
statuses: List[str],
|
||||
limit: int = 20,
|
||||
offset: int = 0
|
||||
) -> List[Ticket]:
|
||||
"""Получить тикеты пользователя по списку статусов с пагинацией"""
|
||||
query = (
|
||||
select(Ticket)
|
||||
.where(Ticket.user_id == user_id)
|
||||
.order_by(desc(Ticket.updated_at))
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
if statuses:
|
||||
query = query.where(Ticket.status.in_(statuses))
|
||||
result = await db.execute(query)
|
||||
return result.scalars().all()
|
||||
|
||||
@staticmethod
|
||||
async def user_has_active_ticket(
|
||||
db: AsyncSession,
|
||||
user_id: int
|
||||
) -> bool:
|
||||
"""Проверить, есть ли у пользователя активный (не закрытый) тикет"""
|
||||
query = (
|
||||
select(Ticket.id)
|
||||
.where(
|
||||
Ticket.user_id == user_id,
|
||||
Ticket.status.in_([TicketStatus.OPEN.value, TicketStatus.ANSWERED.value])
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
result = await db.execute(query)
|
||||
return result.scalar_one_or_none() is not None
|
||||
|
||||
@staticmethod
|
||||
async def is_user_globally_blocked(
|
||||
db: AsyncSession,
|
||||
user_id: int
|
||||
) -> Optional[datetime]:
|
||||
"""Проверить, заблокирован ли пользователь для создания/ответов по любому тикету.
|
||||
Возвращает дату окончания блокировки, если активна, или None.
|
||||
"""
|
||||
query = select(Ticket).where(
|
||||
Ticket.user_id == user_id,
|
||||
or_(Ticket.user_reply_block_permanent == True, Ticket.user_reply_block_until.isnot(None))
|
||||
).order_by(desc(Ticket.updated_at)).limit(10)
|
||||
result = await db.execute(query)
|
||||
tickets = result.scalars().all()
|
||||
if not tickets:
|
||||
return None
|
||||
from datetime import datetime
|
||||
# Если есть вечная блокировка в любом тикете — блок активен без срока
|
||||
for t in tickets:
|
||||
if t.user_reply_block_permanent:
|
||||
return datetime.max
|
||||
# Иначе ищем максимальный срок блокировки, если он в будущем
|
||||
future_until = [t.user_reply_block_until for t in tickets if t.user_reply_block_until]
|
||||
if not future_until:
|
||||
return None
|
||||
max_until = max(future_until)
|
||||
return max_until if max_until > datetime.utcnow() else None
|
||||
|
||||
@staticmethod
|
||||
async def get_all_tickets(
|
||||
db: AsyncSession,
|
||||
status: Optional[str] = None,
|
||||
priority: Optional[str] = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0
|
||||
) -> List[Ticket]:
|
||||
"""Получить все тикеты (для админов)"""
|
||||
query = select(Ticket).options(selectinload(Ticket.user))
|
||||
|
||||
conditions = []
|
||||
if status:
|
||||
conditions.append(Ticket.status == status)
|
||||
if priority:
|
||||
conditions.append(Ticket.priority == priority)
|
||||
|
||||
if conditions:
|
||||
query = query.where(and_(*conditions))
|
||||
|
||||
query = query.order_by(desc(Ticket.updated_at)).offset(offset).limit(limit)
|
||||
|
||||
result = await db.execute(query)
|
||||
return result.scalars().all()
|
||||
|
||||
@staticmethod
|
||||
async def get_tickets_by_statuses(
|
||||
db: AsyncSession,
|
||||
statuses: List[str],
|
||||
limit: int = 50,
|
||||
offset: int = 0
|
||||
) -> List[Ticket]:
|
||||
query = select(Ticket).options(selectinload(Ticket.user))
|
||||
if statuses:
|
||||
query = query.where(Ticket.status.in_(statuses))
|
||||
query = query.order_by(desc(Ticket.updated_at)).offset(offset).limit(limit)
|
||||
result = await db.execute(query)
|
||||
return result.scalars().all()
|
||||
|
||||
@staticmethod
|
||||
async def count_tickets(
|
||||
db: AsyncSession,
|
||||
status: Optional[str] = None
|
||||
) -> int:
|
||||
query = select(func.count()).select_from(Ticket)
|
||||
if status:
|
||||
query = query.where(Ticket.status == status)
|
||||
result = await db.execute(query)
|
||||
return int(result.scalar() or 0)
|
||||
|
||||
@staticmethod
|
||||
async def count_tickets_by_statuses(
|
||||
db: AsyncSession,
|
||||
statuses: List[str]
|
||||
) -> int:
|
||||
query = select(func.count()).select_from(Ticket)
|
||||
if statuses:
|
||||
query = query.where(Ticket.status.in_(statuses))
|
||||
result = await db.execute(query)
|
||||
return int(result.scalar() or 0)
|
||||
|
||||
@staticmethod
|
||||
async def update_ticket_status(
|
||||
db: AsyncSession,
|
||||
ticket_id: int,
|
||||
status: str,
|
||||
closed_at: Optional[datetime] = None
|
||||
) -> bool:
|
||||
"""Обновить статус тикета"""
|
||||
ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=False)
|
||||
if not ticket:
|
||||
return False
|
||||
|
||||
ticket.status = status
|
||||
ticket.updated_at = datetime.utcnow()
|
||||
|
||||
if status == TicketStatus.CLOSED.value and closed_at:
|
||||
ticket.closed_at = closed_at
|
||||
|
||||
await db.commit()
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
async def set_user_reply_block(
|
||||
db: AsyncSession,
|
||||
ticket_id: int,
|
||||
permanent: bool,
|
||||
until: Optional[datetime]
|
||||
) -> bool:
|
||||
ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=False)
|
||||
if not ticket:
|
||||
return False
|
||||
ticket.user_reply_block_permanent = bool(permanent)
|
||||
ticket.user_reply_block_until = until
|
||||
ticket.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
async def close_ticket(
|
||||
db: AsyncSession,
|
||||
ticket_id: int
|
||||
) -> bool:
|
||||
"""Закрыть тикет"""
|
||||
return await TicketCRUD.update_ticket_status(
|
||||
db, ticket_id, TicketStatus.CLOSED.value, datetime.utcnow()
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def close_all_open_tickets(
|
||||
db: AsyncSession,
|
||||
) -> List[int]:
|
||||
"""Закрыть все открытые тикеты. Возвращает список идентификаторов закрытых тикетов."""
|
||||
open_statuses = [TicketStatus.OPEN.value, TicketStatus.ANSWERED.value]
|
||||
result = await db.execute(
|
||||
select(Ticket.id).where(Ticket.status.in_(open_statuses))
|
||||
)
|
||||
ticket_ids = result.scalars().all()
|
||||
|
||||
if not ticket_ids:
|
||||
return []
|
||||
|
||||
now = datetime.utcnow()
|
||||
await db.execute(
|
||||
update(Ticket)
|
||||
.where(Ticket.id.in_(ticket_ids))
|
||||
.values(status=TicketStatus.CLOSED.value, closed_at=now, updated_at=now)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
return ticket_ids
|
||||
|
||||
@staticmethod
|
||||
async def add_support_audit(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
actor_user_id: Optional[int],
|
||||
actor_telegram_id: int,
|
||||
is_moderator: bool,
|
||||
action: str,
|
||||
ticket_id: Optional[int] = None,
|
||||
target_user_id: Optional[int] = None,
|
||||
details: Optional[dict] = None,
|
||||
) -> None:
|
||||
try:
|
||||
log = SupportAuditLog(
|
||||
actor_user_id=actor_user_id,
|
||||
actor_telegram_id=actor_telegram_id,
|
||||
is_moderator=bool(is_moderator),
|
||||
action=action,
|
||||
ticket_id=ticket_id,
|
||||
target_user_id=target_user_id,
|
||||
details=details or {},
|
||||
)
|
||||
db.add(log)
|
||||
await db.commit()
|
||||
except Exception:
|
||||
await db.rollback()
|
||||
# не мешаем основной логике
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
async def list_support_audit(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
action: Optional[str] = None,
|
||||
) -> List[SupportAuditLog]:
|
||||
from sqlalchemy import select, desc
|
||||
|
||||
query = select(SupportAuditLog).order_by(desc(SupportAuditLog.created_at))
|
||||
|
||||
if action:
|
||||
query = query.where(SupportAuditLog.action == action)
|
||||
|
||||
result = await db.execute(query.offset(offset).limit(limit))
|
||||
return result.scalars().all()
|
||||
|
||||
@staticmethod
|
||||
async def count_support_audit(db: AsyncSession, action: Optional[str] = None) -> int:
|
||||
from sqlalchemy import select, func
|
||||
|
||||
query = select(func.count()).select_from(SupportAuditLog)
|
||||
|
||||
if action:
|
||||
query = query.where(SupportAuditLog.action == action)
|
||||
|
||||
result = await db.execute(query)
|
||||
return int(result.scalar() or 0)
|
||||
|
||||
@staticmethod
|
||||
async def list_support_audit_actions(db: AsyncSession) -> List[str]:
|
||||
from sqlalchemy import select
|
||||
|
||||
result = await db.execute(
|
||||
select(SupportAuditLog.action)
|
||||
.where(SupportAuditLog.action.isnot(None))
|
||||
.distinct()
|
||||
.order_by(SupportAuditLog.action)
|
||||
)
|
||||
|
||||
return [row[0] for row in result.fetchall()]
|
||||
|
||||
@staticmethod
|
||||
async def get_open_tickets_count(db: AsyncSession) -> int:
|
||||
"""Получить количество открытых тикетов"""
|
||||
query = select(Ticket).where(Ticket.status.in_([
|
||||
TicketStatus.OPEN.value,
|
||||
TicketStatus.ANSWERED.value
|
||||
]))
|
||||
result = await db.execute(query)
|
||||
return len(result.scalars().all())
|
||||
|
||||
|
||||
class TicketMessageCRUD:
|
||||
"""CRUD операции для работы с сообщениями тикетов"""
|
||||
|
||||
@staticmethod
|
||||
async def add_message(
|
||||
db: AsyncSession,
|
||||
ticket_id: int,
|
||||
user_id: int,
|
||||
message_text: str,
|
||||
is_from_admin: bool = False,
|
||||
media_type: Optional[str] = None,
|
||||
media_file_id: Optional[str] = None,
|
||||
media_caption: Optional[str] = None
|
||||
) -> TicketMessage:
|
||||
"""Добавить сообщение в тикет"""
|
||||
message = TicketMessage(
|
||||
ticket_id=ticket_id,
|
||||
user_id=user_id,
|
||||
message_text=message_text,
|
||||
is_from_admin=is_from_admin,
|
||||
has_media=bool(media_type and media_file_id),
|
||||
media_type=media_type,
|
||||
media_file_id=media_file_id,
|
||||
media_caption=media_caption
|
||||
)
|
||||
|
||||
db.add(message)
|
||||
|
||||
# Обновляем статус тикета
|
||||
ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=False)
|
||||
if ticket:
|
||||
# Если тикет закрыт, запрещаем изменение статуса при сообщении пользователя
|
||||
if not is_from_admin and ticket.status == TicketStatus.CLOSED.value:
|
||||
return message
|
||||
if is_from_admin:
|
||||
# Админ ответил - тикет отвечен
|
||||
ticket.status = TicketStatus.ANSWERED.value
|
||||
else:
|
||||
# Пользователь ответил - тикет открыт
|
||||
ticket.status = TicketStatus.OPEN.value
|
||||
# Сбросить отметку последнего SLA-напоминания, чтобы снова напоминать от времени нового сообщения
|
||||
try:
|
||||
from sqlalchemy import inspect as sa_inspect
|
||||
# если колонка существует в модели
|
||||
if hasattr(ticket, 'last_sla_reminder_at'):
|
||||
ticket.last_sla_reminder_at = None
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
ticket.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(message)
|
||||
return message
|
||||
|
||||
@staticmethod
|
||||
async def get_ticket_messages(
|
||||
db: AsyncSession,
|
||||
ticket_id: int,
|
||||
limit: int = 50,
|
||||
offset: int = 0
|
||||
) -> List[TicketMessage]:
|
||||
"""Получить сообщения тикета"""
|
||||
query = (
|
||||
select(TicketMessage)
|
||||
.where(TicketMessage.ticket_id == ticket_id)
|
||||
.order_by(TicketMessage.created_at)
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
|
||||
result = await db.execute(query)
|
||||
return result.scalars().all()
|
||||
|
||||
@staticmethod
|
||||
async def get_last_message(
|
||||
db: AsyncSession,
|
||||
ticket_id: int
|
||||
) -> Optional[TicketMessage]:
|
||||
"""Получить последнее сообщение в тикете"""
|
||||
query = (
|
||||
select(TicketMessage)
|
||||
.where(TicketMessage.ticket_id == ticket_id)
|
||||
.order_by(desc(TicketMessage.created_at))
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
result = await db.execute(query)
|
||||
return result.scalar_one_or_none()
|
||||
@@ -37,6 +37,20 @@ async def create_transaction(
|
||||
await db.refresh(transaction)
|
||||
|
||||
logger.info(f"💳 Создана транзакция: {type.value} на {amount_kopeks/100}₽ для пользователя {user_id}")
|
||||
|
||||
try:
|
||||
from app.services.promo_group_assignment import (
|
||||
maybe_assign_promo_group_by_total_spent,
|
||||
)
|
||||
|
||||
await maybe_assign_promo_group_by_total_spent(db, user_id)
|
||||
except Exception as exc:
|
||||
logger.debug(
|
||||
"Не удалось проверить автовыдачу промогруппы для пользователя %s: %s",
|
||||
user_id,
|
||||
exc,
|
||||
)
|
||||
|
||||
return transaction
|
||||
|
||||
|
||||
@@ -98,15 +112,42 @@ async def get_user_transactions_count(
|
||||
return result.scalar()
|
||||
|
||||
|
||||
async def get_user_total_spent_kopeks(db: AsyncSession, user_id: int) -> int:
|
||||
result = await db.execute(
|
||||
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
|
||||
and_(
|
||||
Transaction.user_id == user_id,
|
||||
Transaction.is_completed.is_(True),
|
||||
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
|
||||
)
|
||||
)
|
||||
)
|
||||
return int(result.scalar_one())
|
||||
|
||||
|
||||
async def complete_transaction(db: AsyncSession, transaction: Transaction) -> Transaction:
|
||||
|
||||
|
||||
transaction.is_completed = True
|
||||
transaction.completed_at = datetime.utcnow()
|
||||
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(transaction)
|
||||
|
||||
|
||||
logger.info(f"✅ Транзакция {transaction.id} завершена")
|
||||
|
||||
try:
|
||||
from app.services.promo_group_assignment import (
|
||||
maybe_assign_promo_group_by_total_spent,
|
||||
)
|
||||
|
||||
await maybe_assign_promo_group_by_total_spent(db, transaction.user_id)
|
||||
except Exception as exc:
|
||||
logger.debug(
|
||||
"Не удалось проверить автовыдачу промогруппы для пользователя %s: %s",
|
||||
transaction.user_id,
|
||||
exc,
|
||||
)
|
||||
|
||||
return transaction
|
||||
|
||||
|
||||
@@ -277,12 +318,12 @@ async def get_revenue_by_period(
|
||||
|
||||
return [{"date": row.date, "amount_kopeks": row.amount} for row in result]
|
||||
|
||||
|
||||
async def find_tribute_transactions_by_payment_id(
|
||||
db: AsyncSession,
|
||||
payment_id: str,
|
||||
user_telegram_id: Optional[int] = None
|
||||
) -> List[Transaction]:
|
||||
"""Найти все Tribute транзакции по payment_id"""
|
||||
|
||||
query = select(Transaction).options(selectinload(Transaction.user))
|
||||
|
||||
@@ -313,18 +354,27 @@ async def check_tribute_payment_duplicate(
|
||||
amount_kopeks: int,
|
||||
user_telegram_id: int
|
||||
) -> Optional[Transaction]:
|
||||
cutoff_time = datetime.utcnow() - timedelta(hours=24)
|
||||
|
||||
transactions = await find_tribute_transactions_by_payment_id(
|
||||
db, payment_id, user_telegram_id
|
||||
)
|
||||
exact_external_id = f"donation_{payment_id}"
|
||||
|
||||
for transaction in transactions:
|
||||
if (transaction.amount_kopeks == amount_kopeks and
|
||||
transaction.is_completed and
|
||||
transaction.user.telegram_id == user_telegram_id):
|
||||
return transaction
|
||||
query = select(Transaction).options(selectinload(Transaction.user)).where(
|
||||
and_(
|
||||
Transaction.payment_method == PaymentMethod.TRIBUTE.value,
|
||||
Transaction.external_id == exact_external_id,
|
||||
Transaction.amount_kopeks == amount_kopeks,
|
||||
Transaction.is_completed == True,
|
||||
Transaction.created_at >= cutoff_time
|
||||
)
|
||||
).join(User).where(User.telegram_id == user_telegram_id)
|
||||
|
||||
return None
|
||||
result = await db.execute(query)
|
||||
transaction = result.scalar_one_or_none()
|
||||
|
||||
if transaction:
|
||||
logger.info(f"🔍 Найден дубликат платежа в течение 24ч: {transaction.id}")
|
||||
|
||||
return transaction
|
||||
|
||||
|
||||
async def create_unique_tribute_transaction(
|
||||
@@ -334,7 +384,6 @@ async def create_unique_tribute_transaction(
|
||||
amount_kopeks: int,
|
||||
description: str
|
||||
) -> Transaction:
|
||||
"""Создать уникальную Tribute транзакцию с защитой от дубликатов"""
|
||||
|
||||
external_id = f"donation_{payment_id}"
|
||||
|
||||
@@ -356,16 +405,3 @@ async def create_unique_tribute_transaction(
|
||||
external_id=external_id,
|
||||
is_completed=True
|
||||
)
|
||||
|
||||
async def get_transaction_by_id(
|
||||
db: AsyncSession,
|
||||
transaction_id: int
|
||||
) -> Optional[Transaction]:
|
||||
try:
|
||||
result = await db.execute(
|
||||
select(Transaction).where(Transaction.id == transaction_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения транзакции {transaction_id}: {e}")
|
||||
return None
|
||||
|
||||
+672
-74
@@ -2,13 +2,28 @@ import logging
|
||||
import secrets
|
||||
import string
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, List
|
||||
from sqlalchemy import select, and_, or_, func
|
||||
from typing import Optional, List, Dict
|
||||
from sqlalchemy import select, and_, or_, func, case, nullslast, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
from sqlalchemy.orm import selectinload, joinedload
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from app.database.models import User, UserStatus, Subscription, Transaction
|
||||
from app.database.models import (
|
||||
User,
|
||||
UserStatus,
|
||||
Subscription,
|
||||
SubscriptionStatus,
|
||||
Transaction,
|
||||
PromoGroup,
|
||||
UserPromoGroup,
|
||||
PaymentMethod,
|
||||
TransactionType,
|
||||
)
|
||||
from app.config import settings
|
||||
from app.database.crud.promo_group import get_default_promo_group
|
||||
from app.database.crud.discount_offer import get_latest_claimed_offer_for_user
|
||||
from app.database.crud.promo_offer_log import log_promo_offer_action
|
||||
from app.utils.validators import sanitize_telegram_name
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -22,12 +37,18 @@ def generate_referral_code() -> str:
|
||||
async def get_user_by_id(db: AsyncSession, user_id: int) -> Optional[User]:
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.options(selectinload(User.subscription))
|
||||
.options(
|
||||
selectinload(User.subscription),
|
||||
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
|
||||
selectinload(User.referrer),
|
||||
selectinload(User.promo_group),
|
||||
)
|
||||
.where(User.id == user_id)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if user and user.subscription:
|
||||
# Загружаем дополнительные зависимости для subscription
|
||||
_ = user.subscription.is_active
|
||||
|
||||
return user
|
||||
@@ -36,23 +57,66 @@ async def get_user_by_id(db: AsyncSession, user_id: int) -> Optional[User]:
|
||||
async def get_user_by_telegram_id(db: AsyncSession, telegram_id: int) -> Optional[User]:
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.options(selectinload(User.subscription))
|
||||
.options(
|
||||
selectinload(User.subscription),
|
||||
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
|
||||
selectinload(User.referrer),
|
||||
selectinload(User.promo_group),
|
||||
)
|
||||
.where(User.telegram_id == telegram_id)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
|
||||
if user and user.subscription:
|
||||
# Загружаем дополнительные зависимости для subscription
|
||||
_ = user.subscription.is_active
|
||||
|
||||
|
||||
return user
|
||||
|
||||
|
||||
async def get_user_by_username(db: AsyncSession, username: str) -> Optional[User]:
|
||||
if not username:
|
||||
return None
|
||||
|
||||
normalized = username.lower()
|
||||
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.options(
|
||||
selectinload(User.subscription),
|
||||
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
|
||||
selectinload(User.referrer),
|
||||
selectinload(User.promo_group),
|
||||
)
|
||||
.where(func.lower(User.username) == normalized)
|
||||
)
|
||||
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if user and user.subscription:
|
||||
# Загружаем дополнительные зависимости для subscription
|
||||
_ = user.subscription.is_active
|
||||
|
||||
return user
|
||||
|
||||
|
||||
async def get_user_by_referral_code(db: AsyncSession, referral_code: str) -> Optional[User]:
|
||||
|
||||
result = await db.execute(
|
||||
select(User).where(User.referral_code == referral_code)
|
||||
select(User)
|
||||
.options(
|
||||
selectinload(User.subscription),
|
||||
selectinload(User.promo_group),
|
||||
selectinload(User.referrer),
|
||||
)
|
||||
.where(User.referral_code == referral_code)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if user and user.subscription:
|
||||
# Загружаем дополнительные зависимости для subscription
|
||||
_ = user.subscription.is_active
|
||||
|
||||
return user
|
||||
|
||||
|
||||
async def create_unique_referral_code(db: AsyncSession) -> str:
|
||||
@@ -68,6 +132,88 @@ async def create_unique_referral_code(db: AsyncSession) -> str:
|
||||
return f"ref{timestamp}"
|
||||
|
||||
|
||||
async def _sync_users_sequence(db: AsyncSession) -> None:
|
||||
"""Ensure the users.id sequence matches the current max ID."""
|
||||
await db.execute(
|
||||
text(
|
||||
"SELECT setval('users_id_seq', "
|
||||
"COALESCE((SELECT MAX(id) FROM users), 0) + 1, false)"
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
logger.warning(
|
||||
"🔄 Последовательность users_id_seq была синхронизирована с текущим максимумом id"
|
||||
)
|
||||
|
||||
|
||||
async def _get_or_create_default_promo_group(db: AsyncSession) -> PromoGroup:
|
||||
default_group = await get_default_promo_group(db)
|
||||
if default_group:
|
||||
return default_group
|
||||
|
||||
default_group = PromoGroup(
|
||||
name="Базовый юзер",
|
||||
server_discount_percent=0,
|
||||
traffic_discount_percent=0,
|
||||
device_discount_percent=0,
|
||||
is_default=True,
|
||||
)
|
||||
db.add(default_group)
|
||||
await db.flush()
|
||||
return default_group
|
||||
|
||||
|
||||
async def create_user_no_commit(
|
||||
db: AsyncSession,
|
||||
telegram_id: int,
|
||||
username: str = None,
|
||||
first_name: str = None,
|
||||
last_name: str = None,
|
||||
language: str = "ru",
|
||||
referred_by_id: int = None,
|
||||
referral_code: str = None
|
||||
) -> User:
|
||||
"""
|
||||
Создает пользователя без немедленного коммита для пакетной обработки
|
||||
"""
|
||||
|
||||
if not referral_code:
|
||||
referral_code = await create_unique_referral_code(db)
|
||||
|
||||
default_group = await _get_or_create_default_promo_group(db)
|
||||
promo_group_id = default_group.id
|
||||
|
||||
safe_first = sanitize_telegram_name(first_name)
|
||||
safe_last = sanitize_telegram_name(last_name)
|
||||
user = User(
|
||||
telegram_id=telegram_id,
|
||||
username=username,
|
||||
first_name=safe_first,
|
||||
last_name=safe_last,
|
||||
language=language,
|
||||
referred_by_id=referred_by_id,
|
||||
referral_code=referral_code,
|
||||
balance_kopeks=0,
|
||||
has_had_paid_subscription=False,
|
||||
has_made_first_topup=False,
|
||||
promo_group_id=promo_group_id,
|
||||
)
|
||||
|
||||
db.add(user)
|
||||
|
||||
# Обязательно выполняем flush, чтобы получить присвоенный первичный ключ
|
||||
await db.flush()
|
||||
|
||||
# Сохраняем ссылку на группу, чтобы дальнейшие операции могли её использовать
|
||||
user.promo_group = default_group
|
||||
|
||||
# Не коммитим сразу, оставляем для пакетной обработки
|
||||
logger.info(
|
||||
f"✅ Подготовлен пользователь {telegram_id} с реферальным кодом {referral_code} (ожидает коммита)"
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
async def create_user(
|
||||
db: AsyncSession,
|
||||
telegram_id: int,
|
||||
@@ -80,28 +226,63 @@ async def create_user(
|
||||
) -> User:
|
||||
|
||||
if not referral_code:
|
||||
from app.utils.user_utils import generate_unique_referral_code
|
||||
referral_code = await generate_unique_referral_code(db, telegram_id)
|
||||
referral_code = await create_unique_referral_code(db)
|
||||
|
||||
user = User(
|
||||
telegram_id=telegram_id,
|
||||
username=username,
|
||||
first_name=first_name,
|
||||
last_name=last_name,
|
||||
language=language,
|
||||
referred_by_id=referred_by_id,
|
||||
referral_code=referral_code,
|
||||
balance_kopeks=0,
|
||||
has_had_paid_subscription=False
|
||||
)
|
||||
|
||||
db.add(user)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
logger.info(f"✅ Создан пользователь {telegram_id} с реферальным кодом {referral_code}")
|
||||
|
||||
return user
|
||||
attempts = 3
|
||||
|
||||
for attempt in range(1, attempts + 1):
|
||||
default_group = await _get_or_create_default_promo_group(db)
|
||||
promo_group_id = default_group.id
|
||||
|
||||
safe_first = sanitize_telegram_name(first_name)
|
||||
safe_last = sanitize_telegram_name(last_name)
|
||||
user = User(
|
||||
telegram_id=telegram_id,
|
||||
username=username,
|
||||
first_name=safe_first,
|
||||
last_name=safe_last,
|
||||
language=language,
|
||||
referred_by_id=referred_by_id,
|
||||
referral_code=referral_code,
|
||||
balance_kopeks=0,
|
||||
has_had_paid_subscription=False,
|
||||
has_made_first_topup=False,
|
||||
promo_group_id=promo_group_id,
|
||||
)
|
||||
|
||||
db.add(user)
|
||||
|
||||
try:
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
user.promo_group = default_group
|
||||
logger.info(
|
||||
f"✅ Создан пользователь {telegram_id} с реферальным кодом {referral_code}"
|
||||
)
|
||||
return user
|
||||
|
||||
except IntegrityError as exc:
|
||||
await db.rollback()
|
||||
|
||||
if (
|
||||
isinstance(getattr(exc, "orig", None), Exception)
|
||||
and "users_pkey" in str(exc.orig)
|
||||
and attempt < attempts
|
||||
):
|
||||
logger.warning(
|
||||
"⚠️ Обнаружено несоответствие последовательности users_id_seq при создании пользователя %s. "
|
||||
"Выполняем повторную синхронизацию (попытка %s/%s)",
|
||||
telegram_id,
|
||||
attempt,
|
||||
attempts,
|
||||
)
|
||||
await _sync_users_sequence(db)
|
||||
continue
|
||||
|
||||
raise
|
||||
|
||||
raise RuntimeError("Не удалось создать пользователя после синхронизации последовательности")
|
||||
|
||||
|
||||
async def update_user(
|
||||
@@ -110,7 +291,10 @@ async def update_user(
|
||||
**kwargs
|
||||
) -> User:
|
||||
|
||||
from app.utils.validators import sanitize_telegram_name
|
||||
for field, value in kwargs.items():
|
||||
if field in ("first_name", "last_name"):
|
||||
value = sanitize_telegram_name(value)
|
||||
if hasattr(user, field):
|
||||
setattr(user, field, value)
|
||||
|
||||
@@ -127,7 +311,8 @@ async def add_user_balance(
|
||||
amount_kopeks: int,
|
||||
description: str = "Пополнение баланса",
|
||||
create_transaction: bool = True,
|
||||
bot = None
|
||||
transaction_type: TransactionType = TransactionType.DEPOSIT,
|
||||
bot = None
|
||||
) -> bool:
|
||||
try:
|
||||
old_balance = user.balance_kopeks
|
||||
@@ -136,12 +321,11 @@ async def add_user_balance(
|
||||
|
||||
if create_transaction:
|
||||
from app.database.crud.transaction import create_transaction as create_trans
|
||||
from app.database.models import TransactionType
|
||||
|
||||
|
||||
await create_trans(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
type=TransactionType.DEPOSIT,
|
||||
type=transaction_type,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=description
|
||||
)
|
||||
@@ -149,18 +333,6 @@ async def add_user_balance(
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
topup_keywords = ["пополнение", "stars", "yookassa", "topup"]
|
||||
exclude_keywords = ["комиссия", "бонус", "реферальн", "выплата", "вознаграждение"]
|
||||
|
||||
has_topup_keywords = any(word in description.lower() for word in topup_keywords)
|
||||
has_exclude_keywords = any(word in description.lower() for word in exclude_keywords)
|
||||
|
||||
if has_topup_keywords and not has_exclude_keywords:
|
||||
try:
|
||||
from app.services.referral_service import process_referral_topup
|
||||
await process_referral_topup(db, user.id, amount_kopeks, bot)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обработки реферального пополнения: {e}")
|
||||
|
||||
logger.info(f"💰 Баланс пользователя {user.telegram_id} изменен: {old_balance} → {user.balance_kopeks} (изменение: +{amount_kopeks})")
|
||||
return True
|
||||
@@ -169,12 +341,14 @@ async def add_user_balance(
|
||||
logger.error(f"Ошибка изменения баланса пользователя {user.id}: {e}")
|
||||
await db.rollback()
|
||||
return False
|
||||
|
||||
|
||||
|
||||
async def add_user_balance_by_id(
|
||||
db: AsyncSession,
|
||||
telegram_id: int,
|
||||
telegram_id: int,
|
||||
amount_kopeks: int,
|
||||
description: str = "Пополнение баланса"
|
||||
description: str = "Пополнение баланса",
|
||||
transaction_type: TransactionType = TransactionType.DEPOSIT,
|
||||
) -> bool:
|
||||
try:
|
||||
user = await get_user_by_telegram_id(db, telegram_id)
|
||||
@@ -182,22 +356,28 @@ async def add_user_balance_by_id(
|
||||
logger.error(f"Пользователь с telegram_id {telegram_id} не найден")
|
||||
return False
|
||||
|
||||
return await add_user_balance(db, user, amount_kopeks, description)
|
||||
return await add_user_balance(
|
||||
db,
|
||||
user,
|
||||
amount_kopeks,
|
||||
description,
|
||||
transaction_type=transaction_type,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка пополнения баланса пользователя {telegram_id}: {e}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка пополнения баланса пользователя {user_id}: {e}")
|
||||
await db.rollback()
|
||||
return False
|
||||
|
||||
|
||||
async def subtract_user_balance(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
amount_kopeks: int,
|
||||
description: str
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
amount_kopeks: int,
|
||||
description: str,
|
||||
create_transaction: bool = False,
|
||||
payment_method: Optional[PaymentMethod] = None,
|
||||
*,
|
||||
consume_promo_offer: bool = False,
|
||||
) -> bool:
|
||||
logger.error(f"💸 ОТЛАДКА subtract_user_balance:")
|
||||
logger.error(f" 👤 User ID: {user.id} (TG: {user.telegram_id})")
|
||||
@@ -205,18 +385,100 @@ async def subtract_user_balance(
|
||||
logger.error(f" 💸 Сумма к списанию: {amount_kopeks} копеек")
|
||||
logger.error(f" 📝 Описание: {description}")
|
||||
|
||||
log_context: Optional[Dict[str, object]] = None
|
||||
if consume_promo_offer:
|
||||
try:
|
||||
current_percent = int(getattr(user, "promo_offer_discount_percent", 0) or 0)
|
||||
except (TypeError, ValueError):
|
||||
current_percent = 0
|
||||
|
||||
if current_percent > 0:
|
||||
source = getattr(user, "promo_offer_discount_source", None)
|
||||
log_context = {
|
||||
"offer_id": None,
|
||||
"percent": current_percent,
|
||||
"source": source,
|
||||
"effect_type": None,
|
||||
"details": {
|
||||
"reason": "manual_charge",
|
||||
"description": description,
|
||||
"amount_kopeks": amount_kopeks,
|
||||
},
|
||||
}
|
||||
try:
|
||||
offer = await get_latest_claimed_offer_for_user(db, user.id, source)
|
||||
except Exception as lookup_error: # pragma: no cover - defensive logging
|
||||
logger.warning(
|
||||
"Failed to fetch latest claimed promo offer for user %s: %s",
|
||||
user.id,
|
||||
lookup_error,
|
||||
)
|
||||
offer = None
|
||||
|
||||
if offer:
|
||||
log_context["offer_id"] = offer.id
|
||||
log_context["effect_type"] = offer.effect_type
|
||||
if not log_context["percent"] and offer.discount_percent:
|
||||
log_context["percent"] = offer.discount_percent
|
||||
|
||||
if user.balance_kopeks < amount_kopeks:
|
||||
logger.error(f" ❌ НЕДОСТАТОЧНО СРЕДСТВ!")
|
||||
return False
|
||||
|
||||
|
||||
try:
|
||||
old_balance = user.balance_kopeks
|
||||
user.balance_kopeks -= amount_kopeks
|
||||
|
||||
if consume_promo_offer and getattr(user, "promo_offer_discount_percent", 0):
|
||||
user.promo_offer_discount_percent = 0
|
||||
user.promo_offer_discount_source = None
|
||||
user.promo_offer_discount_expires_at = None
|
||||
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
|
||||
if create_transaction:
|
||||
from app.database.crud.transaction import (
|
||||
create_transaction as create_trans,
|
||||
)
|
||||
|
||||
await create_trans(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
type=TransactionType.WITHDRAWAL,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=description,
|
||||
payment_method=payment_method,
|
||||
)
|
||||
|
||||
if consume_promo_offer and log_context:
|
||||
try:
|
||||
await log_promo_offer_action(
|
||||
db,
|
||||
user_id=user.id,
|
||||
offer_id=log_context.get("offer_id"),
|
||||
action="consumed",
|
||||
source=log_context.get("source"),
|
||||
percent=log_context.get("percent"),
|
||||
effect_type=log_context.get("effect_type"),
|
||||
details=log_context.get("details"),
|
||||
)
|
||||
except Exception as log_error: # pragma: no cover - defensive logging
|
||||
logger.warning(
|
||||
"Failed to record promo offer consumption log for user %s: %s",
|
||||
user.id,
|
||||
log_error,
|
||||
)
|
||||
try:
|
||||
await db.rollback()
|
||||
except Exception as rollback_error: # pragma: no cover - defensive logging
|
||||
logger.warning(
|
||||
"Failed to rollback session after promo offer consumption log failure: %s",
|
||||
rollback_error,
|
||||
)
|
||||
|
||||
logger.error(f" ✅ Средства списаны: {old_balance} → {user.balance_kopeks}")
|
||||
return True
|
||||
|
||||
@@ -226,15 +488,115 @@ async def subtract_user_balance(
|
||||
return False
|
||||
|
||||
|
||||
async def cleanup_expired_promo_offer_discounts(db: AsyncSession) -> int:
|
||||
now = datetime.utcnow()
|
||||
result = await db.execute(
|
||||
select(User).where(
|
||||
User.promo_offer_discount_percent > 0,
|
||||
User.promo_offer_discount_expires_at.isnot(None),
|
||||
User.promo_offer_discount_expires_at <= now,
|
||||
)
|
||||
)
|
||||
users = result.scalars().all()
|
||||
if not users:
|
||||
return 0
|
||||
|
||||
log_payloads: List[Dict[str, object]] = []
|
||||
|
||||
for user in users:
|
||||
try:
|
||||
percent = int(getattr(user, "promo_offer_discount_percent", 0) or 0)
|
||||
except (TypeError, ValueError):
|
||||
percent = 0
|
||||
|
||||
source = getattr(user, "promo_offer_discount_source", None)
|
||||
offer_id = None
|
||||
effect_type = None
|
||||
|
||||
if source:
|
||||
try:
|
||||
offer = await get_latest_claimed_offer_for_user(db, user.id, source)
|
||||
except Exception as lookup_error: # pragma: no cover - defensive logging
|
||||
logger.warning(
|
||||
"Failed to fetch latest claimed promo offer for user %s during expiration cleanup: %s",
|
||||
user.id,
|
||||
lookup_error,
|
||||
)
|
||||
offer = None
|
||||
|
||||
if offer:
|
||||
offer_id = offer.id
|
||||
effect_type = offer.effect_type
|
||||
if not percent and offer.discount_percent:
|
||||
percent = offer.discount_percent
|
||||
|
||||
log_payloads.append(
|
||||
{
|
||||
"user_id": user.id,
|
||||
"offer_id": offer_id,
|
||||
"source": source,
|
||||
"percent": percent,
|
||||
"effect_type": effect_type,
|
||||
}
|
||||
)
|
||||
|
||||
user.promo_offer_discount_percent = 0
|
||||
user.promo_offer_discount_source = None
|
||||
user.promo_offer_discount_expires_at = None
|
||||
user.updated_at = now
|
||||
|
||||
await db.commit()
|
||||
|
||||
for payload in log_payloads:
|
||||
user_id = payload.get("user_id")
|
||||
if not user_id:
|
||||
continue
|
||||
try:
|
||||
await log_promo_offer_action(
|
||||
db,
|
||||
user_id=user_id,
|
||||
offer_id=payload.get("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 log_error: # pragma: no cover - defensive logging
|
||||
logger.warning(
|
||||
"Failed to log promo offer expiration for user %s: %s",
|
||||
user_id,
|
||||
log_error,
|
||||
)
|
||||
try:
|
||||
await db.rollback()
|
||||
except Exception as rollback_error: # pragma: no cover - defensive logging
|
||||
logger.warning(
|
||||
"Failed to rollback session after promo offer expiration log failure: %s",
|
||||
rollback_error,
|
||||
)
|
||||
|
||||
return len(users)
|
||||
|
||||
|
||||
async def get_users_list(
|
||||
db: AsyncSession,
|
||||
offset: int = 0,
|
||||
limit: int = 50,
|
||||
search: Optional[str] = None,
|
||||
status: Optional[UserStatus] = None
|
||||
status: Optional[UserStatus] = None,
|
||||
order_by_balance: bool = False,
|
||||
order_by_traffic: bool = False,
|
||||
order_by_last_activity: bool = False,
|
||||
order_by_total_spent: bool = False,
|
||||
order_by_purchase_count: bool = False
|
||||
) -> List[User]:
|
||||
|
||||
query = select(User).options(selectinload(User.subscription))
|
||||
query = select(User).options(
|
||||
selectinload(User.subscription),
|
||||
selectinload(User.promo_group),
|
||||
selectinload(User.referrer),
|
||||
)
|
||||
|
||||
if status:
|
||||
query = query.where(User.status == status.value)
|
||||
@@ -248,14 +610,96 @@ async def get_users_list(
|
||||
]
|
||||
|
||||
if search.isdigit():
|
||||
conditions.append(User.telegram_id == int(search))
|
||||
try:
|
||||
search_int = int(search)
|
||||
# Добавляем условие поиска по telegram_id, который является BigInteger
|
||||
# и может содержать большие значения, в отличие от User.id (INTEGER)
|
||||
conditions.append(User.telegram_id == search_int)
|
||||
except ValueError:
|
||||
# Если не удалось преобразовать в int, просто ищем по текстовым полям
|
||||
pass
|
||||
|
||||
query = query.where(or_(*conditions))
|
||||
|
||||
sort_flags = [
|
||||
order_by_balance,
|
||||
order_by_traffic,
|
||||
order_by_last_activity,
|
||||
order_by_total_spent,
|
||||
order_by_purchase_count,
|
||||
]
|
||||
if sum(int(flag) for flag in sort_flags) > 1:
|
||||
logger.debug(
|
||||
"Выбрано несколько сортировок пользователей — применяется приоритет: трафик > траты > покупки > баланс > активность"
|
||||
)
|
||||
|
||||
transactions_stats = None
|
||||
if order_by_total_spent or order_by_purchase_count:
|
||||
from app.database.models import Transaction
|
||||
|
||||
transactions_stats = (
|
||||
select(
|
||||
Transaction.user_id.label("user_id"),
|
||||
func.coalesce(
|
||||
func.sum(
|
||||
case(
|
||||
(
|
||||
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
|
||||
Transaction.amount_kopeks,
|
||||
),
|
||||
else_=0,
|
||||
)
|
||||
),
|
||||
0,
|
||||
).label("total_spent"),
|
||||
func.coalesce(
|
||||
func.sum(
|
||||
case(
|
||||
(
|
||||
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
|
||||
1,
|
||||
),
|
||||
else_=0,
|
||||
)
|
||||
),
|
||||
0,
|
||||
).label("purchase_count"),
|
||||
)
|
||||
.where(Transaction.is_completed.is_(True))
|
||||
.group_by(Transaction.user_id)
|
||||
.subquery()
|
||||
)
|
||||
query = query.outerjoin(transactions_stats, transactions_stats.c.user_id == User.id)
|
||||
|
||||
if order_by_traffic:
|
||||
traffic_sort = func.coalesce(Subscription.traffic_used_gb, 0.0)
|
||||
query = query.outerjoin(Subscription, Subscription.user_id == User.id)
|
||||
query = query.order_by(traffic_sort.desc(), User.created_at.desc())
|
||||
elif order_by_total_spent:
|
||||
order_column = func.coalesce(transactions_stats.c.total_spent, 0)
|
||||
query = query.order_by(order_column.desc(), User.created_at.desc())
|
||||
elif order_by_purchase_count:
|
||||
order_column = func.coalesce(transactions_stats.c.purchase_count, 0)
|
||||
query = query.order_by(order_column.desc(), User.created_at.desc())
|
||||
elif order_by_balance:
|
||||
query = query.order_by(User.balance_kopeks.desc(), User.created_at.desc())
|
||||
elif order_by_last_activity:
|
||||
query = query.order_by(nullslast(User.last_activity.desc()), User.created_at.desc())
|
||||
else:
|
||||
query = query.order_by(User.created_at.desc())
|
||||
|
||||
query = query.order_by(User.created_at.desc()).offset(offset).limit(limit)
|
||||
query = query.offset(offset).limit(limit)
|
||||
|
||||
result = await db.execute(query)
|
||||
return result.scalars().all()
|
||||
users = result.scalars().all()
|
||||
|
||||
# Загружаем дополнительные зависимости для всех пользователей
|
||||
for user in users:
|
||||
if user and user.subscription:
|
||||
# Загружаем дополнительные зависимости для subscription
|
||||
_ = user.subscription.is_active
|
||||
|
||||
return users
|
||||
|
||||
|
||||
async def get_users_count(
|
||||
@@ -278,7 +722,14 @@ async def get_users_count(
|
||||
]
|
||||
|
||||
if search.isdigit():
|
||||
conditions.append(User.telegram_id == int(search))
|
||||
try:
|
||||
search_int = int(search)
|
||||
# Добавляем условие поиска по telegram_id, который является BigInteger
|
||||
# и может содержать большие значения, в отличие от User.id (INTEGER)
|
||||
conditions.append(User.telegram_id == search_int)
|
||||
except ValueError:
|
||||
# Если не удалось преобразовать в int, просто ищем по текстовым полям
|
||||
pass
|
||||
|
||||
query = query.where(or_(*conditions))
|
||||
|
||||
@@ -286,14 +737,148 @@ async def get_users_count(
|
||||
return result.scalar()
|
||||
|
||||
|
||||
async def get_users_spending_stats(
|
||||
db: AsyncSession,
|
||||
user_ids: List[int]
|
||||
) -> Dict[int, Dict[str, int]]:
|
||||
if not user_ids:
|
||||
return {}
|
||||
|
||||
from app.database.models import Transaction
|
||||
|
||||
stats_query = (
|
||||
select(
|
||||
Transaction.user_id,
|
||||
func.coalesce(
|
||||
func.sum(
|
||||
case(
|
||||
(
|
||||
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
|
||||
Transaction.amount_kopeks,
|
||||
),
|
||||
else_=0,
|
||||
)
|
||||
),
|
||||
0,
|
||||
).label("total_spent"),
|
||||
func.coalesce(
|
||||
func.sum(
|
||||
case(
|
||||
(
|
||||
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
|
||||
1,
|
||||
),
|
||||
else_=0,
|
||||
)
|
||||
),
|
||||
0,
|
||||
).label("purchase_count"),
|
||||
)
|
||||
.where(
|
||||
Transaction.user_id.in_(user_ids),
|
||||
Transaction.is_completed.is_(True),
|
||||
)
|
||||
.group_by(Transaction.user_id)
|
||||
)
|
||||
|
||||
result = await db.execute(stats_query)
|
||||
rows = result.all()
|
||||
|
||||
return {
|
||||
row.user_id: {
|
||||
"total_spent": int(row.total_spent or 0),
|
||||
"purchase_count": int(row.purchase_count or 0),
|
||||
}
|
||||
for row in rows
|
||||
}
|
||||
|
||||
|
||||
async def get_referrals(db: AsyncSession, user_id: int) -> List[User]:
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.options(selectinload(User.subscription))
|
||||
.options(
|
||||
selectinload(User.subscription),
|
||||
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
|
||||
selectinload(User.referrer),
|
||||
selectinload(User.promo_group),
|
||||
)
|
||||
.where(User.referred_by_id == user_id)
|
||||
.order_by(User.created_at.desc())
|
||||
)
|
||||
return result.scalars().all()
|
||||
users = result.scalars().all()
|
||||
|
||||
# Загружаем дополнительные зависимости для всех пользователей
|
||||
for user in users:
|
||||
if user and user.subscription:
|
||||
# Загружаем дополнительные зависимости для subscription
|
||||
_ = user.subscription.is_active
|
||||
|
||||
return users
|
||||
|
||||
|
||||
async def get_users_for_promo_segment(db: AsyncSession, segment: str) -> List[User]:
|
||||
now = datetime.utcnow()
|
||||
|
||||
base_query = (
|
||||
select(User)
|
||||
.options(
|
||||
selectinload(User.subscription),
|
||||
selectinload(User.promo_group),
|
||||
selectinload(User.referrer),
|
||||
)
|
||||
.where(User.status == UserStatus.ACTIVE.value)
|
||||
)
|
||||
|
||||
if segment == "no_subscription":
|
||||
query = (
|
||||
base_query.outerjoin(Subscription, Subscription.user_id == User.id)
|
||||
.where(Subscription.id.is_(None))
|
||||
)
|
||||
else:
|
||||
query = base_query.join(Subscription)
|
||||
|
||||
if segment == "paid_active":
|
||||
query = query.where(
|
||||
Subscription.is_trial == False, # noqa: E712
|
||||
Subscription.status == SubscriptionStatus.ACTIVE.value,
|
||||
Subscription.end_date > now,
|
||||
)
|
||||
elif segment == "paid_expired":
|
||||
query = query.where(
|
||||
Subscription.is_trial == False, # noqa: E712
|
||||
or_(
|
||||
Subscription.status == SubscriptionStatus.EXPIRED.value,
|
||||
Subscription.end_date <= now,
|
||||
),
|
||||
)
|
||||
elif segment == "trial_active":
|
||||
query = query.where(
|
||||
Subscription.is_trial == True, # noqa: E712
|
||||
Subscription.status == SubscriptionStatus.ACTIVE.value,
|
||||
Subscription.end_date > now,
|
||||
)
|
||||
elif segment == "trial_expired":
|
||||
query = query.where(
|
||||
Subscription.is_trial == True, # noqa: E712
|
||||
or_(
|
||||
Subscription.status == SubscriptionStatus.EXPIRED.value,
|
||||
Subscription.end_date <= now,
|
||||
),
|
||||
)
|
||||
else:
|
||||
logger.warning("Неизвестный сегмент для промо: %s", segment)
|
||||
return []
|
||||
|
||||
result = await db.execute(query.order_by(User.id))
|
||||
users = result.scalars().unique().all()
|
||||
|
||||
# Загружаем дополнительные зависимости для всех пользователей
|
||||
for user in users:
|
||||
if user and user.subscription:
|
||||
# Загружаем дополнительные зависимости для subscription
|
||||
_ = user.subscription.is_active
|
||||
|
||||
return users
|
||||
|
||||
|
||||
async def get_inactive_users(db: AsyncSession, months: int = 3) -> List[User]:
|
||||
@@ -301,7 +886,12 @@ async def get_inactive_users(db: AsyncSession, months: int = 3) -> List[User]:
|
||||
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.options(selectinload(User.subscription))
|
||||
.options(
|
||||
selectinload(User.subscription),
|
||||
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
|
||||
selectinload(User.referrer),
|
||||
selectinload(User.promo_group),
|
||||
)
|
||||
.where(
|
||||
and_(
|
||||
User.last_activity < threshold_date,
|
||||
@@ -309,7 +899,15 @@ async def get_inactive_users(db: AsyncSession, months: int = 3) -> List[User]:
|
||||
)
|
||||
)
|
||||
)
|
||||
return result.scalars().all()
|
||||
users = result.scalars().all()
|
||||
|
||||
# Загружаем дополнительные зависимости для всех пользователей
|
||||
for user in users:
|
||||
if user and user.subscription:
|
||||
# Загружаем дополнительные зависимости для subscription
|
||||
_ = user.subscription.is_active
|
||||
|
||||
return users
|
||||
|
||||
|
||||
async def delete_user(db: AsyncSession, user: User) -> bool:
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import logging
|
||||
import random
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
from sqlalchemy import select, func, and_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import UserMessage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def create_user_message(
|
||||
db: AsyncSession,
|
||||
message_text: str,
|
||||
created_by: int,
|
||||
is_active: bool = True,
|
||||
sort_order: int = 0
|
||||
) -> UserMessage:
|
||||
message = UserMessage(
|
||||
message_text=message_text,
|
||||
is_active=is_active,
|
||||
sort_order=sort_order,
|
||||
created_by=created_by
|
||||
)
|
||||
|
||||
db.add(message)
|
||||
await db.commit()
|
||||
await db.refresh(message)
|
||||
|
||||
logger.info(f"✅ Создано сообщение ID {message.id} пользователем {created_by}")
|
||||
return message
|
||||
|
||||
|
||||
async def get_user_message_by_id(db: AsyncSession, message_id: int) -> Optional[UserMessage]:
|
||||
result = await db.execute(
|
||||
select(UserMessage).where(UserMessage.id == message_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_active_user_messages(db: AsyncSession) -> List[UserMessage]:
|
||||
result = await db.execute(
|
||||
select(UserMessage)
|
||||
.where(UserMessage.is_active == True)
|
||||
.order_by(UserMessage.sort_order.asc(), UserMessage.created_at.desc())
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def get_random_active_message(db: AsyncSession) -> Optional[str]:
|
||||
active_messages = await get_active_user_messages(db)
|
||||
|
||||
if not active_messages:
|
||||
return None
|
||||
|
||||
random_message = random.choice(active_messages)
|
||||
return random_message.message_text
|
||||
|
||||
|
||||
async def get_all_user_messages(
|
||||
db: AsyncSession,
|
||||
offset: int = 0,
|
||||
limit: int = 50
|
||||
) -> List[UserMessage]:
|
||||
result = await db.execute(
|
||||
select(UserMessage)
|
||||
.order_by(UserMessage.created_at.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def get_user_messages_count(db: AsyncSession) -> int:
|
||||
result = await db.execute(select(func.count(UserMessage.id)))
|
||||
return result.scalar()
|
||||
|
||||
|
||||
async def update_user_message(
|
||||
db: AsyncSession,
|
||||
message_id: int,
|
||||
message_text: Optional[str] = None,
|
||||
is_active: Optional[bool] = None,
|
||||
sort_order: Optional[int] = None
|
||||
) -> Optional[UserMessage]:
|
||||
message = await get_user_message_by_id(db, message_id)
|
||||
|
||||
if not message:
|
||||
return None
|
||||
|
||||
if message_text is not None:
|
||||
message.message_text = message_text
|
||||
|
||||
if is_active is not None:
|
||||
message.is_active = is_active
|
||||
|
||||
if sort_order is not None:
|
||||
message.sort_order = sort_order
|
||||
|
||||
message.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(message)
|
||||
|
||||
logger.info(f"📝 Обновлено сообщение ID {message_id}")
|
||||
return message
|
||||
|
||||
|
||||
async def toggle_user_message_status(
|
||||
db: AsyncSession,
|
||||
message_id: int
|
||||
) -> Optional[UserMessage]:
|
||||
message = await get_user_message_by_id(db, message_id)
|
||||
|
||||
if not message:
|
||||
return None
|
||||
|
||||
message.is_active = not message.is_active
|
||||
message.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(message)
|
||||
|
||||
status_text = "активировано" if message.is_active else "деактивировано"
|
||||
logger.info(f"🔄 Сообщение ID {message_id} {status_text}")
|
||||
|
||||
return message
|
||||
|
||||
|
||||
async def delete_user_message(db: AsyncSession, message_id: int) -> bool:
|
||||
message = await get_user_message_by_id(db, message_id)
|
||||
|
||||
if not message:
|
||||
return False
|
||||
|
||||
await db.delete(message)
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"🗑️ Удалено сообщение ID {message_id}")
|
||||
return True
|
||||
|
||||
|
||||
async def get_user_messages_stats(db: AsyncSession) -> dict:
|
||||
total_result = await db.execute(select(func.count(UserMessage.id)))
|
||||
total_messages = total_result.scalar()
|
||||
|
||||
active_result = await db.execute(
|
||||
select(func.count(UserMessage.id)).where(UserMessage.is_active == True)
|
||||
)
|
||||
active_messages = active_result.scalar()
|
||||
|
||||
return {
|
||||
"total_messages": total_messages,
|
||||
"active_messages": active_messages,
|
||||
"inactive_messages": total_messages - active_messages
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
"""CRUD операции для связи пользователей с промогруппами (Many-to-Many)."""
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from sqlalchemy import select, and_, desc
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import UserPromoGroup, PromoGroup, User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _sync_user_primary_promo_group(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
) -> None:
|
||||
"""Синхронизирует колонку users.promo_group_id с primary промогруппой."""
|
||||
|
||||
try:
|
||||
result = await db.execute(
|
||||
select(UserPromoGroup.promo_group_id)
|
||||
.join(PromoGroup, UserPromoGroup.promo_group_id == PromoGroup.id)
|
||||
.where(UserPromoGroup.user_id == user_id)
|
||||
.order_by(desc(PromoGroup.priority), PromoGroup.id)
|
||||
)
|
||||
|
||||
first = result.first()
|
||||
new_primary_id = first[0] if first else None
|
||||
|
||||
user = await db.get(User, user_id)
|
||||
if not user:
|
||||
return
|
||||
|
||||
if user.promo_group_id != new_primary_id:
|
||||
user.promo_group_id = new_primary_id
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Ошибка синхронизации primary промогруппы пользователя %s: %s",
|
||||
user_id,
|
||||
error,
|
||||
)
|
||||
|
||||
|
||||
async def sync_user_primary_promo_group(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
) -> None:
|
||||
"""Публичная обертка для синхронизации primary промогруппы пользователя."""
|
||||
|
||||
await _sync_user_primary_promo_group(db, user_id)
|
||||
|
||||
|
||||
async def add_user_to_promo_group(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
promo_group_id: int,
|
||||
assigned_by: str = "admin"
|
||||
) -> Optional[UserPromoGroup]:
|
||||
"""
|
||||
Добавляет пользователю промогруппу.
|
||||
|
||||
Args:
|
||||
db: Сессия БД
|
||||
user_id: ID пользователя
|
||||
promo_group_id: ID промогруппы
|
||||
assigned_by: Кто назначил ('admin', 'system', 'auto', 'promocode')
|
||||
|
||||
Returns:
|
||||
UserPromoGroup или None если уже существует
|
||||
"""
|
||||
try:
|
||||
# Проверяем существование связи
|
||||
existing = await has_user_promo_group(db, user_id, promo_group_id)
|
||||
if existing:
|
||||
logger.info(f"Пользователь {user_id} уже имеет промогруппу {promo_group_id}")
|
||||
return None
|
||||
|
||||
# Создаем новую связь
|
||||
user_promo_group = UserPromoGroup(
|
||||
user_id=user_id,
|
||||
promo_group_id=promo_group_id,
|
||||
assigned_by=assigned_by,
|
||||
)
|
||||
db.add(user_promo_group)
|
||||
await db.flush()
|
||||
|
||||
await _sync_user_primary_promo_group(db, user_id)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user_promo_group)
|
||||
|
||||
logger.info(f"Пользователю {user_id} добавлена промогруппа {promo_group_id} ({assigned_by})")
|
||||
return user_promo_group
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка добавления промогруппы пользователю: {error}")
|
||||
await db.rollback()
|
||||
return None
|
||||
|
||||
|
||||
async def remove_user_from_promo_group(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
promo_group_id: int
|
||||
) -> bool:
|
||||
"""
|
||||
Удаляет промогруппу у пользователя.
|
||||
|
||||
Args:
|
||||
db: Сессия БД
|
||||
user_id: ID пользователя
|
||||
promo_group_id: ID промогруппы
|
||||
|
||||
Returns:
|
||||
True если удалено, False если связи не было
|
||||
"""
|
||||
try:
|
||||
result = await db.execute(
|
||||
select(UserPromoGroup).where(
|
||||
and_(
|
||||
UserPromoGroup.user_id == user_id,
|
||||
UserPromoGroup.promo_group_id == promo_group_id
|
||||
)
|
||||
)
|
||||
)
|
||||
user_promo_group = result.scalar_one_or_none()
|
||||
|
||||
if not user_promo_group:
|
||||
logger.warning(f"Связь пользователя {user_id} с промогруппой {promo_group_id} не найдена")
|
||||
return False
|
||||
|
||||
await db.delete(user_promo_group)
|
||||
await db.flush()
|
||||
|
||||
await _sync_user_primary_promo_group(db, user_id)
|
||||
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"У пользователя {user_id} удалена промогруппа {promo_group_id}")
|
||||
return True
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка удаления промогруппы у пользователя: {error}")
|
||||
await db.rollback()
|
||||
return False
|
||||
|
||||
|
||||
async def get_user_promo_groups(
|
||||
db: AsyncSession,
|
||||
user_id: int
|
||||
) -> List[UserPromoGroup]:
|
||||
"""
|
||||
Получает все промогруппы пользователя, отсортированные по приоритету.
|
||||
|
||||
Args:
|
||||
db: Сессия БД
|
||||
user_id: ID пользователя
|
||||
|
||||
Returns:
|
||||
Список UserPromoGroup с загруженными PromoGroup, отсортированный по приоритету DESC
|
||||
"""
|
||||
try:
|
||||
result = await db.execute(
|
||||
select(UserPromoGroup)
|
||||
.options(selectinload(UserPromoGroup.promo_group))
|
||||
.where(UserPromoGroup.user_id == user_id)
|
||||
.join(PromoGroup, UserPromoGroup.promo_group_id == PromoGroup.id)
|
||||
.order_by(desc(PromoGroup.priority), PromoGroup.id)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка получения промогрупп пользователя {user_id}: {error}")
|
||||
return []
|
||||
|
||||
|
||||
async def get_primary_user_promo_group(
|
||||
db: AsyncSession,
|
||||
user_id: int
|
||||
) -> Optional[PromoGroup]:
|
||||
"""
|
||||
Получает промогруппу пользователя с максимальным приоритетом.
|
||||
|
||||
Args:
|
||||
db: Сессия БД
|
||||
user_id: ID пользователя
|
||||
|
||||
Returns:
|
||||
PromoGroup с максимальным приоритетом или None
|
||||
"""
|
||||
try:
|
||||
user_promo_groups = await get_user_promo_groups(db, user_id)
|
||||
|
||||
if not user_promo_groups:
|
||||
return None
|
||||
|
||||
# Первая в списке имеет максимальный приоритет (список уже отсортирован)
|
||||
return user_promo_groups[0].promo_group if user_promo_groups[0].promo_group else None
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка получения primary промогруппы пользователя {user_id}: {error}")
|
||||
return None
|
||||
|
||||
|
||||
async def has_user_promo_group(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
promo_group_id: int
|
||||
) -> bool:
|
||||
"""
|
||||
Проверяет наличие промогруппы у пользователя.
|
||||
|
||||
Args:
|
||||
db: Сессия БД
|
||||
user_id: ID пользователя
|
||||
promo_group_id: ID промогруппы
|
||||
|
||||
Returns:
|
||||
True если пользователь уже имеет эту промогруппу
|
||||
"""
|
||||
try:
|
||||
result = await db.execute(
|
||||
select(UserPromoGroup).where(
|
||||
and_(
|
||||
UserPromoGroup.user_id == user_id,
|
||||
UserPromoGroup.promo_group_id == promo_group_id
|
||||
)
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none() is not None
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка проверки промогруппы пользователя: {error}")
|
||||
return False
|
||||
|
||||
|
||||
async def count_user_promo_groups(
|
||||
db: AsyncSession,
|
||||
user_id: int
|
||||
) -> int:
|
||||
"""
|
||||
Подсчитывает количество промогрупп у пользователя.
|
||||
|
||||
Args:
|
||||
db: Сессия БД
|
||||
user_id: ID пользователя
|
||||
|
||||
Returns:
|
||||
Количество промогрупп
|
||||
"""
|
||||
try:
|
||||
result = await db.execute(
|
||||
select(UserPromoGroup).where(UserPromoGroup.user_id == user_id)
|
||||
)
|
||||
return len(list(result.scalars().all()))
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка подсчета промогрупп пользователя: {error}")
|
||||
return 0
|
||||
|
||||
|
||||
async def replace_user_promo_groups(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
promo_group_ids: List[int],
|
||||
assigned_by: str = "admin"
|
||||
) -> bool:
|
||||
"""
|
||||
Заменяет все промогруппы пользователя на новый список.
|
||||
|
||||
Args:
|
||||
db: Сессия БД
|
||||
user_id: ID пользователя
|
||||
promo_group_ids: Список ID промогрупп
|
||||
assigned_by: Кто назначил
|
||||
|
||||
Returns:
|
||||
True если успешно
|
||||
"""
|
||||
try:
|
||||
# Удаляем все текущие промогруппы
|
||||
await db.execute(
|
||||
select(UserPromoGroup).where(UserPromoGroup.user_id == user_id)
|
||||
)
|
||||
result = await db.execute(
|
||||
select(UserPromoGroup).where(UserPromoGroup.user_id == user_id)
|
||||
)
|
||||
for upg in result.scalars().all():
|
||||
await db.delete(upg)
|
||||
|
||||
# Добавляем новые
|
||||
for promo_group_id in promo_group_ids:
|
||||
user_promo_group = UserPromoGroup(
|
||||
user_id=user_id,
|
||||
promo_group_id=promo_group_id,
|
||||
assigned_by=assigned_by
|
||||
)
|
||||
db.add(user_promo_group)
|
||||
|
||||
await db.commit()
|
||||
logger.info(f"Промогруппы пользователя {user_id} заменены на {promo_group_ids}")
|
||||
return True
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка замены промогрупп пользователя: {error}")
|
||||
await db.rollback()
|
||||
return False
|
||||
@@ -0,0 +1,168 @@
|
||||
"""CRUD helpers for WATA payment records."""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import WataPayment
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def create_wata_payment(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int,
|
||||
payment_link_id: str,
|
||||
amount_kopeks: int,
|
||||
currency: str,
|
||||
description: Optional[str],
|
||||
status: str,
|
||||
type_: Optional[str],
|
||||
url: Optional[str],
|
||||
order_id: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
expires_at: Optional[datetime] = None,
|
||||
terminal_public_id: Optional[str] = None,
|
||||
success_redirect_url: Optional[str] = None,
|
||||
fail_redirect_url: Optional[str] = None,
|
||||
) -> WataPayment:
|
||||
payment = WataPayment(
|
||||
user_id=user_id,
|
||||
payment_link_id=payment_link_id,
|
||||
order_id=order_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
currency=currency,
|
||||
description=description,
|
||||
status=status,
|
||||
type=type_,
|
||||
url=url,
|
||||
metadata_json=metadata or {},
|
||||
expires_at=expires_at,
|
||||
terminal_public_id=terminal_public_id,
|
||||
success_redirect_url=success_redirect_url,
|
||||
fail_redirect_url=fail_redirect_url,
|
||||
)
|
||||
|
||||
db.add(payment)
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
|
||||
logger.info(
|
||||
"Создан Wata платеж #%s для пользователя %s: %s копеек (статус %s)",
|
||||
payment.id,
|
||||
user_id,
|
||||
amount_kopeks,
|
||||
status,
|
||||
)
|
||||
|
||||
return payment
|
||||
|
||||
|
||||
async def get_wata_payment_by_id(
|
||||
db: AsyncSession,
|
||||
payment_id: int,
|
||||
) -> Optional[WataPayment]:
|
||||
result = await db.execute(
|
||||
select(WataPayment).where(WataPayment.id == payment_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_wata_payment_by_link_id(
|
||||
db: AsyncSession,
|
||||
payment_link_id: str,
|
||||
) -> Optional[WataPayment]:
|
||||
result = await db.execute(
|
||||
select(WataPayment).where(WataPayment.payment_link_id == payment_link_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_wata_payment_by_order_id(
|
||||
db: AsyncSession,
|
||||
order_id: str,
|
||||
) -> Optional[WataPayment]:
|
||||
result = await db.execute(
|
||||
select(WataPayment).where(WataPayment.order_id == order_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def update_wata_payment_status(
|
||||
db: AsyncSession,
|
||||
payment: WataPayment,
|
||||
*,
|
||||
status: Optional[str] = None,
|
||||
is_paid: Optional[bool] = None,
|
||||
paid_at: Optional[datetime] = None,
|
||||
last_status: Optional[str] = None,
|
||||
url: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
callback_payload: Optional[Dict[str, Any]] = None,
|
||||
terminal_public_id: Optional[str] = None,
|
||||
) -> WataPayment:
|
||||
update_values: Dict[str, Any] = {}
|
||||
|
||||
if status is not None:
|
||||
update_values["status"] = status
|
||||
if is_paid is not None:
|
||||
update_values["is_paid"] = is_paid
|
||||
if paid_at is not None:
|
||||
update_values["paid_at"] = paid_at
|
||||
if last_status is not None:
|
||||
update_values["last_status"] = last_status
|
||||
if url is not None:
|
||||
update_values["url"] = url
|
||||
if metadata is not None:
|
||||
update_values["metadata_json"] = metadata
|
||||
if callback_payload is not None:
|
||||
update_values["callback_payload"] = callback_payload
|
||||
if terminal_public_id is not None:
|
||||
update_values["terminal_public_id"] = terminal_public_id
|
||||
|
||||
if not update_values:
|
||||
return payment
|
||||
|
||||
await db.execute(
|
||||
update(WataPayment)
|
||||
.where(WataPayment.id == payment.id)
|
||||
.values(**update_values)
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
|
||||
logger.info(
|
||||
"Обновлен Wata платеж %s: статус=%s, is_paid=%s",
|
||||
payment.payment_link_id,
|
||||
payment.status,
|
||||
payment.is_paid,
|
||||
)
|
||||
|
||||
return payment
|
||||
|
||||
|
||||
async def link_wata_payment_to_transaction(
|
||||
db: AsyncSession,
|
||||
payment: WataPayment,
|
||||
transaction_id: int,
|
||||
) -> WataPayment:
|
||||
await db.execute(
|
||||
update(WataPayment)
|
||||
.where(WataPayment.id == payment.id)
|
||||
.values(transaction_id=transaction_id)
|
||||
)
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
|
||||
logger.info(
|
||||
"Wata платеж %s привязан к транзакции %s",
|
||||
payment.payment_link_id,
|
||||
transaction_id,
|
||||
)
|
||||
|
||||
return payment
|
||||
@@ -0,0 +1,106 @@
|
||||
"""CRUD операции для токенов административного веб-API."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Iterable, List, Optional
|
||||
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import WebApiToken
|
||||
|
||||
|
||||
async def list_tokens(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
include_inactive: bool = False,
|
||||
) -> List[WebApiToken]:
|
||||
query = select(WebApiToken)
|
||||
|
||||
if not include_inactive:
|
||||
query = query.where(WebApiToken.is_active.is_(True))
|
||||
|
||||
query = query.order_by(WebApiToken.created_at.desc())
|
||||
|
||||
result = await db.execute(query)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_token_by_id(db: AsyncSession, token_id: int) -> Optional[WebApiToken]:
|
||||
return await db.get(WebApiToken, token_id)
|
||||
|
||||
|
||||
async def get_token_by_hash(db: AsyncSession, token_hash: str) -> Optional[WebApiToken]:
|
||||
query = select(WebApiToken).where(
|
||||
WebApiToken.token_hash == token_hash
|
||||
)
|
||||
result = await db.execute(query)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def create_token(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
name: str,
|
||||
token_hash: str,
|
||||
token_prefix: str,
|
||||
description: Optional[str] = None,
|
||||
expires_at: Optional[datetime] = None,
|
||||
created_by: Optional[str] = None,
|
||||
) -> WebApiToken:
|
||||
token = WebApiToken(
|
||||
name=name,
|
||||
token_hash=token_hash,
|
||||
token_prefix=token_prefix,
|
||||
description=description,
|
||||
expires_at=expires_at,
|
||||
created_by=created_by,
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
db.add(token)
|
||||
await db.flush()
|
||||
await db.refresh(token)
|
||||
return token
|
||||
|
||||
|
||||
async def update_token(
|
||||
db: AsyncSession,
|
||||
token: WebApiToken,
|
||||
**kwargs,
|
||||
) -> WebApiToken:
|
||||
for key, value in kwargs.items():
|
||||
if hasattr(token, key):
|
||||
setattr(token, key, value)
|
||||
token.updated_at = datetime.utcnow()
|
||||
await db.flush()
|
||||
await db.refresh(token)
|
||||
return token
|
||||
|
||||
|
||||
async def set_tokens_active_status(
|
||||
db: AsyncSession,
|
||||
token_ids: Iterable[int],
|
||||
*,
|
||||
is_active: bool,
|
||||
) -> None:
|
||||
await db.execute(
|
||||
update(WebApiToken)
|
||||
.where(WebApiToken.id.in_(list(token_ids)))
|
||||
.values(is_active=is_active, updated_at=datetime.utcnow())
|
||||
)
|
||||
|
||||
|
||||
async def delete_token(db: AsyncSession, token: WebApiToken) -> None:
|
||||
await db.delete(token)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"list_tokens",
|
||||
"get_token_by_id",
|
||||
"get_token_by_hash",
|
||||
"create_token",
|
||||
"update_token",
|
||||
"set_tokens_active_status",
|
||||
"delete_token",
|
||||
]
|
||||
@@ -0,0 +1,174 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import WelcomeText
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
WELCOME_TEXT_KEY = "welcome_text"
|
||||
|
||||
async def get_active_welcome_text(db: AsyncSession) -> Optional[str]:
|
||||
result = await db.execute(
|
||||
select(WelcomeText)
|
||||
.where(WelcomeText.is_active == True)
|
||||
.where(WelcomeText.is_enabled == True)
|
||||
.order_by(WelcomeText.updated_at.desc())
|
||||
)
|
||||
welcome_text = result.scalar_one_or_none()
|
||||
|
||||
if welcome_text:
|
||||
return welcome_text.text_content
|
||||
|
||||
return None
|
||||
|
||||
async def get_current_welcome_text_settings(db: AsyncSession) -> dict:
|
||||
result = await db.execute(
|
||||
select(WelcomeText)
|
||||
.where(WelcomeText.is_active == True)
|
||||
.order_by(WelcomeText.updated_at.desc())
|
||||
)
|
||||
welcome_text = result.scalar_one_or_none()
|
||||
|
||||
if welcome_text:
|
||||
return {
|
||||
'text': welcome_text.text_content,
|
||||
'is_enabled': welcome_text.is_enabled,
|
||||
'id': welcome_text.id
|
||||
}
|
||||
|
||||
return {
|
||||
'text': await get_current_welcome_text_or_default(),
|
||||
'is_enabled': True,
|
||||
'id': None
|
||||
}
|
||||
|
||||
async def toggle_welcome_text_status(db: AsyncSession, admin_id: int) -> bool:
|
||||
try:
|
||||
result = await db.execute(
|
||||
select(WelcomeText)
|
||||
.where(WelcomeText.is_active == True)
|
||||
.order_by(WelcomeText.updated_at.desc())
|
||||
)
|
||||
welcome_text = result.scalar_one_or_none()
|
||||
|
||||
if welcome_text:
|
||||
welcome_text.is_enabled = not welcome_text.is_enabled
|
||||
welcome_text.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(welcome_text)
|
||||
|
||||
status = "включен" if welcome_text.is_enabled else "отключен"
|
||||
logger.info(f"Приветственный текст {status} администратором {admin_id}")
|
||||
return welcome_text.is_enabled
|
||||
else:
|
||||
default_text = await get_current_welcome_text_or_default()
|
||||
new_welcome_text = WelcomeText(
|
||||
text_content=default_text,
|
||||
is_active=True,
|
||||
is_enabled=True,
|
||||
created_by=admin_id
|
||||
)
|
||||
|
||||
db.add(new_welcome_text)
|
||||
await db.commit()
|
||||
await db.refresh(new_welcome_text)
|
||||
|
||||
logger.info(f"Создан и включен дефолтный приветственный текст администратором {admin_id}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при переключении статуса приветственного текста: {e}")
|
||||
await db.rollback()
|
||||
return False
|
||||
|
||||
async def set_welcome_text(db: AsyncSession, text_content: str, admin_id: int) -> bool:
|
||||
try:
|
||||
current_settings = await get_current_welcome_text_settings(db)
|
||||
current_enabled_status = current_settings.get('is_enabled', True)
|
||||
|
||||
await db.execute(
|
||||
update(WelcomeText).values(is_active=False)
|
||||
)
|
||||
|
||||
new_welcome_text = WelcomeText(
|
||||
text_content=text_content,
|
||||
is_active=True,
|
||||
is_enabled=current_enabled_status,
|
||||
created_by=admin_id
|
||||
)
|
||||
|
||||
db.add(new_welcome_text)
|
||||
await db.commit()
|
||||
await db.refresh(new_welcome_text)
|
||||
|
||||
logger.info(f"Установлен новый приветственный текст администратором {admin_id}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при установке приветственного текста: {e}")
|
||||
await db.rollback()
|
||||
return False
|
||||
|
||||
async def get_current_welcome_text_or_default() -> str:
|
||||
return (
|
||||
f"Привет, {{user_name}}! 🎁 3 дней VPN бесплатно! "
|
||||
f"Подключайтесь за минуту и забудьте о блокировках. "
|
||||
f"✅ До 1 Гбит/с скорость "
|
||||
f"✅ Умный VPN — можно не отключать для большинства российских сервисов "
|
||||
f"✅ Современные протоколы — максимум защиты и анонимности "
|
||||
f"💉 Всего 99₽/мес за 1 устройство "
|
||||
f"👇 Жмите кнопку и подключайтесь!"
|
||||
)
|
||||
|
||||
def replace_placeholders(text: str, user) -> str:
|
||||
first_name = getattr(user, 'first_name', None)
|
||||
username = getattr(user, 'username', None)
|
||||
|
||||
first_name = first_name.strip() if first_name else None
|
||||
username = username.strip() if username else None
|
||||
|
||||
user_name = first_name or username or "друг"
|
||||
display_first_name = first_name or "друг"
|
||||
display_username = f"@{username}" if username else (first_name or "друг")
|
||||
clean_username = username or first_name or "друг"
|
||||
|
||||
replacements = {
|
||||
'{user_name}': user_name,
|
||||
'{first_name}': display_first_name,
|
||||
'{username}': display_username,
|
||||
'{username_clean}': clean_username,
|
||||
'Egor': user_name
|
||||
}
|
||||
|
||||
result = text
|
||||
for placeholder, value in replacements.items():
|
||||
result = result.replace(placeholder, value)
|
||||
|
||||
return result
|
||||
|
||||
async def get_welcome_text_for_user(db: AsyncSession, user) -> str:
|
||||
welcome_text = await get_active_welcome_text(db)
|
||||
|
||||
if not welcome_text:
|
||||
return None
|
||||
|
||||
if isinstance(user, str):
|
||||
class SimpleUser:
|
||||
def __init__(self, name):
|
||||
self.first_name = name
|
||||
self.username = None
|
||||
user = SimpleUser(user)
|
||||
|
||||
return replace_placeholders(welcome_text, user)
|
||||
|
||||
def get_available_placeholders() -> dict:
|
||||
return {
|
||||
'{user_name}': 'Имя или username пользователя (приоритет: имя → username → "друг")',
|
||||
'{first_name}': 'Только имя пользователя (или "друг" если не указано)',
|
||||
'{username}': 'Username с символом @ (или имя если username не указан)',
|
||||
'{username_clean}': 'Username без символа @ (или имя если username не указан)'
|
||||
}
|
||||
+361
-15
@@ -1,31 +1,167 @@
|
||||
import logging
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
from typing import AsyncGenerator, Optional
|
||||
from contextlib import asynccontextmanager
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncSession,
|
||||
create_async_engine,
|
||||
async_sessionmaker,
|
||||
AsyncEngine
|
||||
)
|
||||
from sqlalchemy.pool import NullPool, AsyncAdaptedQueuePool
|
||||
from sqlalchemy import event, text, bindparam, inspect
|
||||
from sqlalchemy.engine import Engine
|
||||
import time
|
||||
from app.config import settings
|
||||
from app.database.models import Base
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ============================================================================
|
||||
# PRODUCTION-GRADE CONNECTION POOLING
|
||||
# ============================================================================
|
||||
|
||||
if settings.get_database_url().startswith("sqlite"):
|
||||
poolclass = NullPool
|
||||
pool_kwargs = {}
|
||||
else:
|
||||
poolclass = AsyncAdaptedQueuePool
|
||||
pool_kwargs = {
|
||||
"pool_size": 20,
|
||||
"max_overflow": 30,
|
||||
"pool_timeout": 30,
|
||||
"pool_recycle": 3600,
|
||||
"pool_pre_ping": True,
|
||||
# 🔥 Агрессивная очистка мертвых соединений
|
||||
"pool_reset_on_return": "rollback",
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# ENGINE WITH ADVANCED OPTIMIZATIONS
|
||||
# ============================================================================
|
||||
|
||||
engine = create_async_engine(
|
||||
settings.DATABASE_URL,
|
||||
poolclass=NullPool,
|
||||
settings.get_database_url(),
|
||||
poolclass=poolclass,
|
||||
echo=settings.DEBUG,
|
||||
future=True
|
||||
future=True,
|
||||
**pool_kwargs,
|
||||
|
||||
connect_args={
|
||||
"server_settings": {
|
||||
"application_name": "remnawave_bot",
|
||||
"jit": "on",
|
||||
"statement_timeout": "60000", # 60 секунд
|
||||
"idle_in_transaction_session_timeout": "300000", # 5 минут
|
||||
},
|
||||
"command_timeout": 60,
|
||||
"timeout": 10,
|
||||
} if not settings.get_database_url().startswith("sqlite") else {},
|
||||
|
||||
execution_options={
|
||||
"isolation_level": "READ COMMITTED", # Оптимальный для большинства случаев
|
||||
"compiled_cache_size": 500, # Кеш скомпилированных запросов
|
||||
}
|
||||
)
|
||||
|
||||
# ============================================================================
|
||||
# SESSION FACTORY WITH OPTIMIZATIONS
|
||||
# ============================================================================
|
||||
|
||||
AsyncSessionLocal = async_sessionmaker(
|
||||
bind=engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
autoflush=True,
|
||||
autocommit=False
|
||||
autoflush=False, # 🔥 Критично для производительности
|
||||
autocommit=False,
|
||||
)
|
||||
|
||||
# ============================================================================
|
||||
# QUERY PERFORMANCE MONITORING
|
||||
# ============================================================================
|
||||
|
||||
if settings.DEBUG:
|
||||
@event.listens_for(Engine, "before_cursor_execute")
|
||||
def before_cursor_execute(conn, cursor, statement, parameters, context, executemany):
|
||||
conn.info.setdefault("query_start_time", []).append(time.time())
|
||||
logger.debug(f"🔍 Executing query: {statement[:100]}...")
|
||||
|
||||
@event.listens_for(Engine, "after_cursor_execute")
|
||||
def after_cursor_execute(conn, cursor, statement, parameters, context, executemany):
|
||||
total = time.time() - conn.info["query_start_time"].pop(-1)
|
||||
if total > 0.1: # Логируем медленные запросы > 100ms
|
||||
logger.warning(f"🐌 Slow query ({total:.3f}s): {statement[:100]}...")
|
||||
else:
|
||||
logger.debug(f"⚡ Query executed in {total:.3f}s")
|
||||
|
||||
# ============================================================================
|
||||
# ADVANCED SESSION MANAGER WITH READ REPLICAS
|
||||
# ============================================================================
|
||||
|
||||
class DatabaseManager:
|
||||
"""Продвинутый менеджер БД с поддержкой реплик и кеширования"""
|
||||
|
||||
def __init__(self):
|
||||
self.engine = engine
|
||||
self.read_replica_engine: Optional[AsyncEngine] = None
|
||||
|
||||
if hasattr(settings, 'DATABASE_READ_REPLICA_URL') and settings.DATABASE_READ_REPLICA_URL:
|
||||
self.read_replica_engine = create_async_engine(
|
||||
settings.DATABASE_READ_REPLICA_URL,
|
||||
poolclass=poolclass,
|
||||
pool_size=30, # Больше для read операций
|
||||
max_overflow=50,
|
||||
pool_pre_ping=True,
|
||||
echo=False,
|
||||
)
|
||||
|
||||
@asynccontextmanager
|
||||
async def session(self, read_only: bool = False):
|
||||
target_engine = self.read_replica_engine if (read_only and self.read_replica_engine) else self.engine
|
||||
|
||||
async_session = async_sessionmaker(
|
||||
bind=target_engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
autoflush=False,
|
||||
)
|
||||
|
||||
async with async_session() as session:
|
||||
try:
|
||||
yield session
|
||||
if not read_only:
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
|
||||
async def health_check(self) -> dict:
|
||||
pool = self.engine.pool
|
||||
|
||||
try:
|
||||
async with AsyncSessionLocal() as session:
|
||||
start = time.time()
|
||||
await session.execute(text("SELECT 1"))
|
||||
latency = (time.time() - start) * 1000
|
||||
status = "healthy"
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Database health check failed: {e}")
|
||||
status = "unhealthy"
|
||||
latency = None
|
||||
|
||||
return {
|
||||
"status": status,
|
||||
"latency_ms": round(latency, 2) if latency else None,
|
||||
"pool": _collect_health_pool_metrics(pool),
|
||||
}
|
||||
|
||||
db_manager = DatabaseManager()
|
||||
|
||||
# ============================================================================
|
||||
# SESSION DEPENDENCY FOR FASTAPI/AIOGRAM
|
||||
# ============================================================================
|
||||
|
||||
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
"""Стандартная dependency для FastAPI"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
yield session
|
||||
@@ -33,19 +169,229 @@ async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
async def get_db_read_only() -> AsyncGenerator[AsyncSession, None]:
|
||||
"""Read-only dependency для тяжелых SELECT запросов"""
|
||||
async with db_manager.session(read_only=True) as session:
|
||||
yield session
|
||||
|
||||
# ============================================================================
|
||||
# BATCH OPERATIONS FOR PERFORMANCE
|
||||
# ============================================================================
|
||||
|
||||
class BatchOperations:
|
||||
"""Утилиты для массовых операций"""
|
||||
|
||||
@staticmethod
|
||||
async def bulk_insert(session: AsyncSession, model, data: list[dict], chunk_size: int = 1000):
|
||||
"""Массовая вставка с чанками"""
|
||||
for i in range(0, len(data), chunk_size):
|
||||
chunk = data[i:i + chunk_size]
|
||||
session.add_all([model(**item) for item in chunk])
|
||||
await session.flush()
|
||||
await session.commit()
|
||||
|
||||
@staticmethod
|
||||
async def bulk_update(session: AsyncSession, model, data: list[dict], chunk_size: int = 1000):
|
||||
"""Массовое обновление с чанками"""
|
||||
if not data:
|
||||
return
|
||||
|
||||
primary_keys = [column.name for column in model.__table__.primary_key.columns]
|
||||
if not primary_keys:
|
||||
raise ValueError("Model must have a primary key for bulk_update")
|
||||
|
||||
updatable_columns = [
|
||||
column.name
|
||||
for column in model.__table__.columns
|
||||
if column.name not in primary_keys
|
||||
]
|
||||
|
||||
if not updatable_columns:
|
||||
raise ValueError("No columns available for update in bulk_update")
|
||||
|
||||
stmt = (
|
||||
model.__table__.update()
|
||||
.where(
|
||||
*[
|
||||
getattr(model.__table__.c, pk) == bindparam(pk)
|
||||
for pk in primary_keys
|
||||
]
|
||||
)
|
||||
.values(
|
||||
**{
|
||||
column: bindparam(column, required=False)
|
||||
for column in updatable_columns
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
for i in range(0, len(data), chunk_size):
|
||||
chunk = data[i:i + chunk_size]
|
||||
filtered_chunk = []
|
||||
for item in chunk:
|
||||
missing_keys = [pk for pk in primary_keys if pk not in item]
|
||||
if missing_keys:
|
||||
raise ValueError(
|
||||
f"Missing primary key values {missing_keys} for bulk_update"
|
||||
)
|
||||
|
||||
filtered_item = {
|
||||
key: value
|
||||
for key, value in item.items()
|
||||
if key in primary_keys or key in updatable_columns
|
||||
}
|
||||
filtered_chunk.append(filtered_item)
|
||||
|
||||
await session.execute(stmt, filtered_chunk)
|
||||
await session.commit()
|
||||
|
||||
batch_ops = BatchOperations()
|
||||
|
||||
# ============================================================================
|
||||
# INITIALIZATION AND CLEANUP
|
||||
# ============================================================================
|
||||
|
||||
async def init_db():
|
||||
logger.info("Создание таблиц базы данных...")
|
||||
"""Инициализация БД с оптимизациями"""
|
||||
logger.info("🚀 Создание таблиц базы данных...")
|
||||
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
if not settings.get_database_url().startswith("sqlite"):
|
||||
logger.info("📊 Создание индексов для оптимизации...")
|
||||
|
||||
async with engine.begin() as conn:
|
||||
indexes = [
|
||||
("users", "CREATE INDEX IF NOT EXISTS idx_users_telegram_id ON users(telegram_id)"),
|
||||
(
|
||||
"subscriptions",
|
||||
"CREATE INDEX IF NOT EXISTS idx_subscriptions_user_id ON subscriptions(user_id)",
|
||||
),
|
||||
(
|
||||
"subscriptions",
|
||||
"CREATE INDEX IF NOT EXISTS idx_subscriptions_status ON subscriptions(status) WHERE status = 'active'",
|
||||
),
|
||||
(
|
||||
"payments",
|
||||
"CREATE INDEX IF NOT EXISTS idx_payments_created_at ON payments(created_at DESC)",
|
||||
),
|
||||
]
|
||||
|
||||
for table_name, index_sql in indexes:
|
||||
table_exists = await conn.run_sync(lambda sync_conn: inspect(sync_conn).has_table(table_name))
|
||||
|
||||
if not table_exists:
|
||||
logger.debug(
|
||||
"Пропускаем создание индекса %s: таблица %s отсутствует",
|
||||
index_sql,
|
||||
table_name,
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
await conn.execute(text(index_sql))
|
||||
except Exception as e:
|
||||
logger.debug("Index creation skipped for %s: %s", table_name, e)
|
||||
|
||||
logger.info("✅ База данных успешно инициализирована")
|
||||
|
||||
|
||||
health = await db_manager.health_check()
|
||||
logger.info(f"📊 Database health: {health}")
|
||||
|
||||
async def close_db():
|
||||
"""Корректное закрытие всех соединений"""
|
||||
logger.info("🔄 Закрытие соединений с БД...")
|
||||
|
||||
await engine.dispose()
|
||||
logger.info("✅ Подключение к базе данных закрыто")
|
||||
|
||||
if db_manager.read_replica_engine:
|
||||
await db_manager.read_replica_engine.dispose()
|
||||
|
||||
logger.info("✅ Все подключения к базе данных закрыты")
|
||||
|
||||
# ============================================================================
|
||||
# CONNECTION POOL METRICS (для мониторинга)
|
||||
# ============================================================================
|
||||
|
||||
def _pool_counters(pool):
|
||||
"""Return basic pool counters or ``None`` when unsupported."""
|
||||
|
||||
required_methods = ("size", "checkedin", "checkedout", "overflow")
|
||||
|
||||
for method_name in required_methods:
|
||||
method = getattr(pool, method_name, None)
|
||||
if method is None or not callable(method):
|
||||
return None
|
||||
|
||||
size = pool.size()
|
||||
checked_in = pool.checkedin()
|
||||
checked_out = pool.checkedout()
|
||||
overflow = pool.overflow()
|
||||
|
||||
total_connections = size + overflow
|
||||
|
||||
return {
|
||||
"size": size,
|
||||
"checked_in": checked_in,
|
||||
"checked_out": checked_out,
|
||||
"overflow": overflow,
|
||||
"total_connections": total_connections,
|
||||
"utilization_percent": (checked_out / total_connections * 100) if total_connections else 0.0,
|
||||
}
|
||||
|
||||
|
||||
def _collect_health_pool_metrics(pool) -> dict:
|
||||
counters = _pool_counters(pool)
|
||||
|
||||
if counters is None:
|
||||
return {
|
||||
"metrics_available": False,
|
||||
"size": 0,
|
||||
"checked_in": 0,
|
||||
"checked_out": 0,
|
||||
"overflow": 0,
|
||||
"total_connections": 0,
|
||||
"utilization": "0.0%",
|
||||
}
|
||||
|
||||
return {
|
||||
"metrics_available": True,
|
||||
"size": counters["size"],
|
||||
"checked_in": counters["checked_in"],
|
||||
"checked_out": counters["checked_out"],
|
||||
"overflow": counters["overflow"],
|
||||
"total_connections": counters["total_connections"],
|
||||
"utilization": f"{counters['utilization_percent']:.1f}%",
|
||||
}
|
||||
|
||||
|
||||
async def get_pool_metrics() -> dict:
|
||||
"""Детальные метрики пула для Prometheus/Grafana"""
|
||||
pool = engine.pool
|
||||
|
||||
counters = _pool_counters(pool)
|
||||
|
||||
if counters is None:
|
||||
return {
|
||||
"metrics_available": False,
|
||||
"pool_size": 0,
|
||||
"checked_in_connections": 0,
|
||||
"checked_out_connections": 0,
|
||||
"overflow_connections": 0,
|
||||
"total_connections": 0,
|
||||
"max_possible_connections": 0,
|
||||
"pool_utilization_percent": 0.0,
|
||||
}
|
||||
|
||||
return {
|
||||
"metrics_available": True,
|
||||
"pool_size": counters["size"],
|
||||
"checked_in_connections": counters["checked_in"],
|
||||
"checked_out_connections": counters["checked_out"],
|
||||
"overflow_connections": counters["overflow"],
|
||||
"total_connections": counters["total_connections"],
|
||||
"max_possible_connections": counters["total_connections"] + (getattr(pool, "_max_overflow", 0) or 0),
|
||||
"pool_utilization_percent": round(counters["utilization_percent"], 2),
|
||||
}
|
||||
|
||||
+1075
-33
File diff suppressed because it is too large
Load Diff
+3797
-131
File diff suppressed because it is too large
Load Diff
Vendored
+187
@@ -0,0 +1,187 @@
|
||||
import logging
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import aiohttp
|
||||
from typing import Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CryptoBotService:
|
||||
|
||||
def __init__(self):
|
||||
self.api_token = settings.CRYPTOBOT_API_TOKEN
|
||||
self.base_url = settings.get_cryptobot_base_url()
|
||||
self.webhook_secret = settings.CRYPTOBOT_WEBHOOK_SECRET
|
||||
|
||||
async def _make_request(
|
||||
self,
|
||||
method: str,
|
||||
endpoint: str,
|
||||
data: Optional[Dict] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
|
||||
if not self.api_token:
|
||||
logger.error("CryptoBot API token не настроен")
|
||||
return None
|
||||
|
||||
url = f"{self.base_url}/api/{endpoint}"
|
||||
headers = {
|
||||
'Crypto-Pay-API-Token': self.api_token,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
request_kwargs: Dict[str, Any] = {"headers": headers}
|
||||
|
||||
if method.upper() == "GET":
|
||||
if data:
|
||||
request_kwargs["params"] = data
|
||||
elif data:
|
||||
request_kwargs["json"] = data
|
||||
|
||||
async with session.request(
|
||||
method,
|
||||
url,
|
||||
**request_kwargs,
|
||||
) as response:
|
||||
|
||||
response_data = await response.json()
|
||||
|
||||
if response.status == 200 and response_data.get('ok'):
|
||||
return response_data.get('result')
|
||||
else:
|
||||
logger.error(f"CryptoBot API ошибка: {response_data}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка запроса к CryptoBot API: {e}")
|
||||
return None
|
||||
|
||||
async def get_me(self) -> Optional[Dict[str, Any]]:
|
||||
return await self._make_request('GET', 'getMe')
|
||||
|
||||
async def create_invoice(
|
||||
self,
|
||||
amount: str,
|
||||
asset: str = "USDT",
|
||||
description: Optional[str] = None,
|
||||
payload: Optional[str] = None,
|
||||
expires_in: Optional[int] = None
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
|
||||
data = {
|
||||
'currency_type': 'crypto',
|
||||
'asset': asset,
|
||||
'amount': amount
|
||||
}
|
||||
|
||||
if description:
|
||||
data['description'] = description
|
||||
|
||||
if payload:
|
||||
data['payload'] = payload
|
||||
|
||||
if expires_in:
|
||||
data['expires_in'] = expires_in
|
||||
|
||||
result = await self._make_request('POST', 'createInvoice', data)
|
||||
|
||||
if result:
|
||||
logger.info(f"Создан CryptoBot invoice {result.get('invoice_id')} на {amount} {asset}")
|
||||
|
||||
return result
|
||||
|
||||
async def get_invoices(
|
||||
self,
|
||||
asset: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
offset: int = 0,
|
||||
count: int = 100,
|
||||
invoice_ids: Optional[list] = None,
|
||||
) -> Optional[list]:
|
||||
|
||||
data = {
|
||||
'offset': offset,
|
||||
'count': count
|
||||
}
|
||||
|
||||
if asset:
|
||||
data['asset'] = asset
|
||||
|
||||
if status:
|
||||
data['status'] = status
|
||||
|
||||
if invoice_ids:
|
||||
data['invoice_ids'] = invoice_ids
|
||||
|
||||
result = await self._make_request('GET', 'getInvoices', data)
|
||||
|
||||
if isinstance(result, dict):
|
||||
items = result.get('items')
|
||||
return items if isinstance(items, list) else []
|
||||
|
||||
if isinstance(result, list):
|
||||
return result
|
||||
|
||||
return []
|
||||
|
||||
async def get_balance(self) -> Optional[list]:
|
||||
return await self._make_request('GET', 'getBalance')
|
||||
|
||||
async def get_exchange_rates(self) -> Optional[list]:
|
||||
return await self._make_request('GET', 'getExchangeRates')
|
||||
|
||||
def verify_webhook_signature(self, body: str, signature: str) -> bool:
|
||||
|
||||
if not self.webhook_secret:
|
||||
logger.warning("CryptoBot webhook secret не настроен")
|
||||
return True
|
||||
|
||||
try:
|
||||
secret_hash = hashlib.sha256(self.webhook_secret.encode()).digest()
|
||||
expected_signature = hmac.new(secret_hash, body.encode(), hashlib.sha256).hexdigest()
|
||||
|
||||
is_valid = hmac.compare_digest(signature, expected_signature)
|
||||
|
||||
if is_valid:
|
||||
logger.info("✅ CryptoBot webhook подпись валидна")
|
||||
else:
|
||||
logger.error("❌ Неверная подпись CryptoBot webhook")
|
||||
|
||||
return is_valid
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка проверки подписи CryptoBot webhook: {e}")
|
||||
return False
|
||||
|
||||
async def process_webhook(self, webhook_data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
|
||||
try:
|
||||
update_type = webhook_data.get('update_type')
|
||||
|
||||
if update_type == 'invoice_paid':
|
||||
invoice_data = webhook_data.get('payload', {})
|
||||
|
||||
return {
|
||||
'event_type': 'payment',
|
||||
'payment_id': str(invoice_data.get('invoice_id')),
|
||||
'amount': invoice_data.get('amount'),
|
||||
'asset': invoice_data.get('asset'),
|
||||
'status': 'paid',
|
||||
'user_payload': invoice_data.get('payload'),
|
||||
'paid_at': invoice_data.get('paid_at'),
|
||||
'payment_system': 'cryptobot'
|
||||
}
|
||||
|
||||
logger.warning(f"Неизвестный тип CryptoBot webhook: {update_type}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обработки CryptoBot webhook: {e}")
|
||||
return None
|
||||
Vendored
+156
@@ -0,0 +1,156 @@
|
||||
"""HTTP client for Heleket payment API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import aiohttp
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HeleketService:
|
||||
"""Minimal wrapper around Heleket API endpoints."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.base_url = settings.HELEKET_BASE_URL.rstrip("/")
|
||||
self.merchant_id = settings.HELEKET_MERCHANT_ID
|
||||
self.api_key = settings.HELEKET_API_KEY
|
||||
|
||||
@property
|
||||
def is_configured(self) -> bool:
|
||||
return bool(self.merchant_id and self.api_key)
|
||||
|
||||
def _prepare_body(self, payload: Dict[str, Any]) -> str:
|
||||
cleaned = {key: value for key, value in payload.items() if value is not None}
|
||||
serialized = json.dumps(cleaned, ensure_ascii=False, separators=(",", ":"), sort_keys=True)
|
||||
if "/" in serialized:
|
||||
serialized = serialized.replace("/", "\\/")
|
||||
return serialized
|
||||
|
||||
def _generate_signature(self, body: str) -> str:
|
||||
api_key = self.api_key or ""
|
||||
encoded = base64.b64encode(body.encode("utf-8")).decode("utf-8")
|
||||
raw = f"{encoded}{api_key}"
|
||||
return hashlib.md5(raw.encode("utf-8")).hexdigest()
|
||||
|
||||
async def _request(
|
||||
self,
|
||||
endpoint: str,
|
||||
payload: Dict[str, Any],
|
||||
*,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
if not self.is_configured:
|
||||
logger.error("Heleket сервис не настроен: merchant или api_key отсутствуют")
|
||||
return None
|
||||
|
||||
body = self._prepare_body(payload)
|
||||
signature = self._generate_signature(body)
|
||||
|
||||
url = f"{self.base_url}/{endpoint.lstrip('/')}"
|
||||
headers = {
|
||||
"merchant": self.merchant_id or "",
|
||||
"sign": signature,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
try:
|
||||
timeout = aiohttp.ClientTimeout(total=30)
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
async with session.post(
|
||||
url,
|
||||
data=body.encode("utf-8"),
|
||||
headers=headers,
|
||||
params=params,
|
||||
) as response:
|
||||
text = await response.text()
|
||||
if response.content_type != "application/json":
|
||||
logger.error("Ответ Heleket не JSON (%s): %s", response.content_type, text)
|
||||
return None
|
||||
|
||||
try:
|
||||
data = json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
logger.error("Ошибка парсинга Heleket JSON: %s", text)
|
||||
return None
|
||||
|
||||
if response.status >= 400:
|
||||
logger.error("Heleket API %s вернул статус %s: %s", endpoint, response.status, data)
|
||||
return None
|
||||
|
||||
if isinstance(data, dict) and data.get("state") == 0:
|
||||
return data
|
||||
|
||||
logger.error("Heleket API вернул ошибку: %s", data)
|
||||
return None
|
||||
except Exception as error: # pragma: no cover - defensive
|
||||
logger.error("Ошибка запроса к Heleket API: %s", error)
|
||||
return None
|
||||
|
||||
async def create_payment(self, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
return await self._request("payment", payload)
|
||||
|
||||
async def get_payment_info(
|
||||
self,
|
||||
*,
|
||||
uuid: Optional[str] = None,
|
||||
order_id: Optional[str] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
if not uuid and not order_id:
|
||||
raise ValueError("Нужно указать uuid или order_id для Heleket payment/info")
|
||||
|
||||
payload: Dict[str, Any] = {}
|
||||
if uuid:
|
||||
payload["uuid"] = uuid
|
||||
if order_id:
|
||||
payload["order_id"] = order_id
|
||||
|
||||
return await self._request("payment/info", payload)
|
||||
|
||||
async def list_payments(
|
||||
self,
|
||||
*,
|
||||
date_from: Optional[str] = None,
|
||||
date_to: Optional[str] = None,
|
||||
cursor: Optional[str] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
payload: Dict[str, Any] = {}
|
||||
if date_from:
|
||||
payload["date_from"] = date_from
|
||||
if date_to:
|
||||
payload["date_to"] = date_to
|
||||
|
||||
params = {"cursor": cursor} if cursor else None
|
||||
return await self._request("payment/list", payload, params=params)
|
||||
|
||||
def verify_webhook_signature(self, payload: Dict[str, Any]) -> bool:
|
||||
if not self.is_configured:
|
||||
logger.warning("Heleket сервис не настроен, подпись пропускается")
|
||||
return True
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
logger.error("Heleket webhook payload не dict: %s", payload)
|
||||
return False
|
||||
|
||||
signature = payload.get("sign")
|
||||
if not signature:
|
||||
logger.error("Heleket webhook без подписи")
|
||||
return False
|
||||
|
||||
data = {key: value for key, value in payload.items() if key != "sign"}
|
||||
body = self._prepare_body(data)
|
||||
expected = self._generate_signature(body)
|
||||
|
||||
is_valid = hmac.compare_digest(expected, str(signature))
|
||||
|
||||
if not is_valid:
|
||||
logger.error("Неверная подпись Heleket webhook: ожидается %s, получено %s", expected, signature)
|
||||
return is_valid
|
||||
Vendored
+111
@@ -0,0 +1,111 @@
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from app.config import settings
|
||||
from app.database.database import get_db
|
||||
from app.external.heleket import HeleketService
|
||||
from app.services.payment_service import PaymentService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HeleketWebhookHandler:
|
||||
def __init__(self, payment_service: PaymentService) -> None:
|
||||
self.payment_service = payment_service
|
||||
self.service = HeleketService()
|
||||
|
||||
async def handle(self, request: web.Request) -> web.Response:
|
||||
if not settings.is_heleket_enabled():
|
||||
logger.warning("Получен Heleket webhook, но сервис отключен")
|
||||
return web.json_response({"status": "error", "reason": "disabled"}, status=503)
|
||||
|
||||
try:
|
||||
payload: Dict[str, Any] = await request.json()
|
||||
except json.JSONDecodeError:
|
||||
logger.error("Некорректный JSON Heleket webhook")
|
||||
return web.json_response({"status": "error", "reason": "invalid_json"}, status=400)
|
||||
|
||||
if not self.service.verify_webhook_signature(payload):
|
||||
return web.json_response({"status": "error", "reason": "invalid_signature"}, status=401)
|
||||
|
||||
processed: Optional[bool] = None
|
||||
async for db in get_db():
|
||||
processed = await self.payment_service.process_heleket_webhook(db, payload)
|
||||
|
||||
if processed:
|
||||
return web.json_response({"status": "ok"}, status=200)
|
||||
|
||||
return web.json_response({"status": "error", "reason": "not_processed"}, status=400)
|
||||
|
||||
async def health_check(self, _: web.Request) -> web.Response:
|
||||
return web.json_response(
|
||||
{
|
||||
"status": "ok",
|
||||
"service": "heleket_webhook",
|
||||
"enabled": settings.is_heleket_enabled(),
|
||||
"path": settings.HELEKET_WEBHOOK_PATH,
|
||||
}
|
||||
)
|
||||
|
||||
async def options_handler(self, _: web.Request) -> web.Response:
|
||||
return web.Response(
|
||||
status=200,
|
||||
headers={
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "POST, GET, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def create_heleket_app(payment_service: PaymentService) -> web.Application:
|
||||
handler = HeleketWebhookHandler(payment_service)
|
||||
app = web.Application()
|
||||
app.router.add_post(settings.HELEKET_WEBHOOK_PATH, handler.handle)
|
||||
app.router.add_get("/heleket/health", handler.health_check)
|
||||
app.router.add_get("/health", handler.health_check)
|
||||
app.router.add_options(settings.HELEKET_WEBHOOK_PATH, handler.options_handler)
|
||||
return app
|
||||
|
||||
|
||||
async def start_heleket_webhook_server(payment_service: PaymentService) -> None:
|
||||
if not settings.is_heleket_enabled():
|
||||
logger.info("Heleket отключен, webhook сервер не запускается")
|
||||
return
|
||||
|
||||
app = create_heleket_app(payment_service)
|
||||
runner = web.AppRunner(app)
|
||||
await runner.setup()
|
||||
|
||||
site = web.TCPSite(
|
||||
runner,
|
||||
host=settings.HELEKET_WEBHOOK_HOST,
|
||||
port=settings.HELEKET_WEBHOOK_PORT,
|
||||
)
|
||||
|
||||
try:
|
||||
await site.start()
|
||||
logger.info(
|
||||
"Heleket webhook сервер запущен на %s:%s",
|
||||
settings.HELEKET_WEBHOOK_HOST,
|
||||
settings.HELEKET_WEBHOOK_PORT,
|
||||
)
|
||||
logger.info(
|
||||
"Heleket webhook URL: http://%s:%s%s",
|
||||
settings.HELEKET_WEBHOOK_HOST,
|
||||
settings.HELEKET_WEBHOOK_PORT,
|
||||
settings.HELEKET_WEBHOOK_PATH,
|
||||
)
|
||||
|
||||
while True:
|
||||
await asyncio.sleep(1)
|
||||
except asyncio.CancelledError:
|
||||
logger.info("Heleket webhook сервер остановлен по запросу")
|
||||
finally:
|
||||
await site.stop()
|
||||
await runner.cleanup()
|
||||
logger.info("Heleket webhook сервер корректно остановлен")
|
||||
Vendored
+216
@@ -0,0 +1,216 @@
|
||||
"""Async client for PayPalych (Pal24) API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import aiohttp
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Pal24APIError(Exception):
|
||||
"""Base error for Pal24 API operations."""
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Pal24Response:
|
||||
"""Wrapper for Pal24 API responses."""
|
||||
|
||||
success: bool
|
||||
data: Dict[str, Any]
|
||||
status: int
|
||||
|
||||
@classmethod
|
||||
def from_payload(cls, payload: Dict[str, Any], status: int) -> "Pal24Response":
|
||||
success = bool(payload.get("success", status < 400))
|
||||
return cls(success=success, data=payload, status=status)
|
||||
|
||||
def raise_for_status(self, endpoint: str) -> None:
|
||||
if not self.success:
|
||||
detail = self.data.get("message") or self.data.get("error")
|
||||
raise Pal24APIError(
|
||||
f"Pal24 API error at {endpoint}: status={self.status}, detail={detail or self.data}"
|
||||
)
|
||||
|
||||
|
||||
class Pal24Client:
|
||||
"""Async client implementing PayPalych API methods."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
api_token: Optional[str] = None,
|
||||
base_url: Optional[str] = None,
|
||||
timeout: Optional[int] = None,
|
||||
) -> None:
|
||||
self.api_token = api_token or settings.PAL24_API_TOKEN
|
||||
self.base_url = (base_url or settings.PAL24_BASE_URL or "").rstrip("/") + "/"
|
||||
self.timeout = timeout or settings.PAL24_REQUEST_TIMEOUT
|
||||
|
||||
if not self.api_token:
|
||||
logger.warning("Pal24Client initialized without API token")
|
||||
|
||||
@property
|
||||
def is_configured(self) -> bool:
|
||||
return bool(self.api_token and self.base_url)
|
||||
|
||||
async def _request(
|
||||
self,
|
||||
method: str,
|
||||
endpoint: str,
|
||||
*,
|
||||
json_payload: Optional[Dict[str, Any]] = None,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
) -> Pal24Response:
|
||||
if not self.is_configured:
|
||||
raise Pal24APIError("Pal24 client is not configured")
|
||||
|
||||
url = f"{self.base_url}{endpoint.lstrip('/')}"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.api_token}",
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
|
||||
timeout = aiohttp.ClientTimeout(total=self.timeout)
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
async with session.request(
|
||||
method,
|
||||
url,
|
||||
headers=headers,
|
||||
json=json_payload,
|
||||
params=params,
|
||||
) as response:
|
||||
status = response.status
|
||||
try:
|
||||
payload = await response.json(content_type=None)
|
||||
except aiohttp.ContentTypeError:
|
||||
text_body = await response.text()
|
||||
logger.error(
|
||||
"Pal24 API returned non-JSON response for %s: %s",
|
||||
endpoint,
|
||||
text_body,
|
||||
)
|
||||
raise Pal24APIError(
|
||||
f"Pal24 API returned non-JSON response: {text_body}"
|
||||
) from None
|
||||
|
||||
result = Pal24Response.from_payload(payload, status)
|
||||
if status >= 400 or not result.success:
|
||||
logger.error(
|
||||
"Pal24 API error %s %s: %s",
|
||||
status,
|
||||
endpoint,
|
||||
payload,
|
||||
)
|
||||
result.raise_for_status(endpoint)
|
||||
|
||||
return result
|
||||
|
||||
except asyncio.TimeoutError as error:
|
||||
logger.error("Pal24 API request timeout for %s: %s", endpoint, error)
|
||||
raise Pal24APIError(f"Pal24 API request timeout for {endpoint}") from error
|
||||
except aiohttp.ClientError as error:
|
||||
logger.error("Pal24 API client error for %s: %s", endpoint, error)
|
||||
raise Pal24APIError(str(error)) from error
|
||||
|
||||
# API methods -----------------------------------------------------------------
|
||||
|
||||
async def create_bill(
|
||||
self,
|
||||
*,
|
||||
amount: Decimal,
|
||||
shop_id: str,
|
||||
order_id: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
currency_in: str = "RUB",
|
||||
type_: str = "normal",
|
||||
**kwargs: Any,
|
||||
) -> Dict[str, Any]:
|
||||
payload: Dict[str, Any] = {
|
||||
"amount": str(amount),
|
||||
"shop_id": shop_id,
|
||||
"currency_in": currency_in,
|
||||
"type": type_,
|
||||
}
|
||||
|
||||
if order_id:
|
||||
payload["order_id"] = order_id
|
||||
if description:
|
||||
payload["description"] = description
|
||||
|
||||
payload.update({k: v for k, v in kwargs.items() if v is not None})
|
||||
|
||||
response = await self._request("POST", "bill/create", json_payload=payload)
|
||||
return response.data
|
||||
|
||||
async def get_bill_status(self, bill_id: str) -> Dict[str, Any]:
|
||||
response = await self._request("GET", "bill/status", params={"id": bill_id})
|
||||
return response.data
|
||||
|
||||
async def toggle_bill_activity(self, bill_id: str, active: bool) -> Dict[str, Any]:
|
||||
payload = {"id": bill_id, "active": 1 if active else 0}
|
||||
response = await self._request("POST", "bill/toggle_activity", json_payload=payload)
|
||||
return response.data
|
||||
|
||||
async def search_payments(self, **params: Any) -> Dict[str, Any]:
|
||||
response = await self._request("GET", "payment/search", params=params)
|
||||
return response.data
|
||||
|
||||
async def get_payment_status(self, payment_id: str) -> Dict[str, Any]:
|
||||
response = await self._request("GET", "payment/status", params={"id": payment_id})
|
||||
return response.data
|
||||
|
||||
async def get_balance(self) -> Dict[str, Any]:
|
||||
response = await self._request("GET", "merchant/balance")
|
||||
return response.data
|
||||
|
||||
async def search_bills(self, **params: Any) -> Dict[str, Any]:
|
||||
response = await self._request("GET", "bill/search", params=params)
|
||||
return response.data
|
||||
|
||||
async def get_bill_payments(self, bill_id: str) -> Dict[str, Any]:
|
||||
response = await self._request("GET", "bill/payments", params={"id": bill_id})
|
||||
return response.data
|
||||
|
||||
# Helpers ---------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def calculate_signature(out_sum: str, inv_id: str, api_token: Optional[str] = None) -> str:
|
||||
token = api_token or settings.PAL24_SIGNATURE_TOKEN or settings.PAL24_API_TOKEN
|
||||
if not token:
|
||||
raise Pal24APIError("Pal24 signature token is not configured")
|
||||
raw = f"{out_sum}:{inv_id}:{token}".encode("utf-8")
|
||||
return hashlib.md5(raw).hexdigest().upper()
|
||||
|
||||
@staticmethod
|
||||
def verify_signature(
|
||||
out_sum: str,
|
||||
inv_id: str,
|
||||
signature: str,
|
||||
api_token: Optional[str] = None,
|
||||
) -> bool:
|
||||
try:
|
||||
expected = Pal24Client.calculate_signature(out_sum, inv_id, api_token)
|
||||
except Pal24APIError:
|
||||
logger.error("Pal24 signature verification failed: missing token")
|
||||
return False
|
||||
return expected == signature.upper()
|
||||
|
||||
@staticmethod
|
||||
def normalize_amount(amount_kopeks: int) -> Decimal:
|
||||
try:
|
||||
return (Decimal(amount_kopeks) / Decimal("100")).quantize(Decimal("0.01"))
|
||||
except (InvalidOperation, TypeError) as error:
|
||||
raise Pal24APIError(f"Invalid amount: {amount_kopeks}") from error
|
||||
|
||||
Vendored
+162
@@ -0,0 +1,162 @@
|
||||
"""Flask webhook server for PayPalych callbacks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from concurrent.futures import TimeoutError as FuturesTimeoutError
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
from asyncio import AbstractEventLoop
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from flask import Flask, jsonify, request
|
||||
from werkzeug.serving import make_server
|
||||
|
||||
from app.config import settings
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from app.services.pal24_service import Pal24Service, Pal24APIError
|
||||
from app.services.payment_service import PaymentService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _normalize_payload() -> Dict[str, str]:
|
||||
if request.is_json:
|
||||
payload = request.get_json(silent=True) or {}
|
||||
if isinstance(payload, dict):
|
||||
return {k: str(v) for k, v in payload.items()}
|
||||
logger.warning("Pal24 webhook JSON payload не является объектом: %s", payload)
|
||||
return {}
|
||||
|
||||
if request.form:
|
||||
return {k: v for k, v in request.form.items()}
|
||||
|
||||
try:
|
||||
raw_body = request.data.decode("utf-8")
|
||||
if raw_body:
|
||||
payload = json.loads(raw_body)
|
||||
if isinstance(payload, dict):
|
||||
return {k: str(v) for k, v in payload.items()}
|
||||
except json.JSONDecodeError:
|
||||
logger.debug("Pal24 webhook body не удалось распарсить как JSON")
|
||||
|
||||
return {}
|
||||
|
||||
|
||||
def create_pal24_flask_app(
|
||||
payment_service: PaymentService,
|
||||
loop: AbstractEventLoop,
|
||||
) -> Flask:
|
||||
pal24_service = Pal24Service()
|
||||
app = Flask(__name__)
|
||||
|
||||
@app.route(settings.PAL24_WEBHOOK_PATH, methods=["POST"])
|
||||
def pal24_webhook() -> tuple:
|
||||
if not pal24_service.is_configured:
|
||||
logger.error("Pal24 webhook получен, но сервис не настроен")
|
||||
return jsonify({"status": "error", "reason": "service_not_configured"}), 503
|
||||
|
||||
logger.debug("Получен Pal24 webhook: headers=%s", dict(request.headers))
|
||||
|
||||
payload = _normalize_payload()
|
||||
if not payload:
|
||||
logger.warning("Пустой Pal24 webhook")
|
||||
return jsonify({"status": "error", "reason": "empty_payload"}), 400
|
||||
|
||||
try:
|
||||
parsed_payload = pal24_service.parse_callback(payload)
|
||||
except Pal24APIError as error:
|
||||
logger.error("Ошибка валидации Pal24 webhook: %s", error)
|
||||
return jsonify({"status": "error", "reason": str(error)}), 400
|
||||
|
||||
async def process() -> bool:
|
||||
async with AsyncSessionLocal() as db:
|
||||
try:
|
||||
return await payment_service.process_pal24_callback(db, parsed_payload)
|
||||
except Exception:
|
||||
await db.rollback()
|
||||
raise
|
||||
|
||||
try:
|
||||
future = asyncio.run_coroutine_threadsafe(process(), loop)
|
||||
processed = future.result(timeout=settings.PAL24_REQUEST_TIMEOUT)
|
||||
except FuturesTimeoutError:
|
||||
logger.error("Обработка Pal24 webhook превысила таймаут %sс", settings.PAL24_REQUEST_TIMEOUT)
|
||||
return jsonify({"status": "error", "reason": "timeout"}), 504
|
||||
except Exception as error: # pragma: no cover - defensive
|
||||
logger.exception("Критическая ошибка обработки Pal24 webhook: %s", error)
|
||||
return jsonify({"status": "error", "reason": "internal_error"}), 500
|
||||
|
||||
if processed:
|
||||
return jsonify({"status": "ok"}), 200
|
||||
return jsonify({"status": "error", "reason": "not_processed"}), 400
|
||||
|
||||
@app.route(settings.PAL24_WEBHOOK_PATH, methods=["GET"])
|
||||
def pal24_health() -> tuple:
|
||||
return jsonify({
|
||||
"status": "ok",
|
||||
"service": "pal24_webhook",
|
||||
"enabled": settings.is_pal24_enabled(),
|
||||
}), 200
|
||||
|
||||
@app.route("/pal24/health", methods=["GET"])
|
||||
def pal24_additional_health() -> tuple:
|
||||
return jsonify({
|
||||
"status": "ok",
|
||||
"service": "pal24_webhook",
|
||||
"path": settings.PAL24_WEBHOOK_PATH,
|
||||
}), 200
|
||||
|
||||
return app
|
||||
|
||||
|
||||
class Pal24WebhookServer:
|
||||
"""Threaded Flask server for Pal24 callbacks."""
|
||||
|
||||
def __init__(self, payment_service: PaymentService, loop: AbstractEventLoop) -> None:
|
||||
self.app = create_pal24_flask_app(payment_service, loop)
|
||||
self._server: Optional[Any] = None
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
|
||||
def start(self) -> None:
|
||||
if self._server:
|
||||
logger.warning("Pal24 webhook server уже запущен")
|
||||
return
|
||||
|
||||
self._server = make_server(
|
||||
host="0.0.0.0",
|
||||
port=settings.PAL24_WEBHOOK_PORT,
|
||||
app=self.app,
|
||||
threaded=True,
|
||||
)
|
||||
|
||||
def _serve() -> None:
|
||||
logger.info(
|
||||
"Pal24 webhook сервер запущен на %s:%s%s",
|
||||
"0.0.0.0",
|
||||
settings.PAL24_WEBHOOK_PORT,
|
||||
settings.PAL24_WEBHOOK_PATH,
|
||||
)
|
||||
self._server.serve_forever()
|
||||
|
||||
self._thread = threading.Thread(target=_serve, daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def stop(self) -> None:
|
||||
if self._server:
|
||||
logger.info("Останавливаем Pal24 webhook сервер")
|
||||
self._server.shutdown()
|
||||
self._server = None
|
||||
|
||||
if self._thread and self._thread.is_alive():
|
||||
self._thread.join(timeout=5)
|
||||
self._thread = None
|
||||
|
||||
|
||||
async def start_pal24_webhook_server(payment_service: PaymentService) -> Pal24WebhookServer:
|
||||
loop = asyncio.get_running_loop()
|
||||
server = Pal24WebhookServer(payment_service, loop)
|
||||
await loop.run_in_executor(None, server.start)
|
||||
return server
|
||||
|
||||
Vendored
+116
-20
@@ -1,9 +1,9 @@
|
||||
import asyncio
|
||||
import json
|
||||
import ssl
|
||||
import base64
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Optional, Union, Any
|
||||
from urllib.parse import urlparse
|
||||
import aiohttp
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
@@ -57,6 +57,8 @@ class RemnaWaveUser:
|
||||
ss_password: Optional[str] = None
|
||||
first_connected_at: Optional[datetime] = None
|
||||
last_triggered_threshold: int = 0
|
||||
happ_link: Optional[str] = None
|
||||
happ_crypto_link: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -83,6 +85,18 @@ class RemnaWaveNode:
|
||||
traffic_limit_bytes: Optional[int]
|
||||
|
||||
|
||||
@dataclass
|
||||
class SubscriptionInfo:
|
||||
is_found: bool
|
||||
user: Optional[Dict[str, Any]]
|
||||
links: List[str]
|
||||
ss_conf_links: Dict[str, str]
|
||||
subscription_url: str
|
||||
happ: Optional[Dict[str, str]]
|
||||
happ_link: Optional[str] = None
|
||||
happ_crypto_link: Optional[str] = None
|
||||
|
||||
|
||||
class RemnaWaveAPIError(Exception):
|
||||
def __init__(self, message: str, status_code: int = None, response_data: dict = None):
|
||||
self.message = message
|
||||
@@ -93,12 +107,15 @@ class RemnaWaveAPIError(Exception):
|
||||
|
||||
class RemnaWaveAPI:
|
||||
|
||||
def __init__(self, base_url: str, api_key: str, secret_key: Optional[str] = None):
|
||||
def __init__(self, base_url: str, api_key: str, secret_key: Optional[str] = None,
|
||||
username: Optional[str] = None, password: Optional[str] = None):
|
||||
self.base_url = base_url.rstrip('/')
|
||||
self.api_key = api_key
|
||||
self.secret_key = secret_key
|
||||
self.secret_key = secret_key
|
||||
self.username = username
|
||||
self.password = password
|
||||
self.session: Optional[aiohttp.ClientSession] = None
|
||||
self.authenticated = False
|
||||
self.authenticated = False
|
||||
|
||||
def _detect_connection_type(self) -> str:
|
||||
parsed = urlparse(self.base_url)
|
||||
@@ -119,14 +136,9 @@ class RemnaWaveAPI:
|
||||
return "local"
|
||||
|
||||
return "external"
|
||||
|
||||
async def __aenter__(self):
|
||||
conn_type = self._detect_connection_type()
|
||||
|
||||
logger.info(f"🔗 Подключение к RemnaWave: {self.base_url} (тип: {conn_type})")
|
||||
|
||||
|
||||
def _prepare_auth_headers(self) -> Dict[str, str]:
|
||||
headers = {
|
||||
'Authorization': f'Bearer {self.api_key}',
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'X-Forwarded-Proto': 'https',
|
||||
@@ -134,20 +146,41 @@ class RemnaWaveAPI:
|
||||
'X-Real-IP': '127.0.0.1'
|
||||
}
|
||||
|
||||
if self.username and self.password:
|
||||
import base64
|
||||
credentials = f"{self.username}:{self.password}"
|
||||
encoded_credentials = base64.b64encode(credentials.encode()).decode()
|
||||
headers['X-Api-Key'] = f"Basic {encoded_credentials}"
|
||||
logger.debug("Используем Basic Auth в X-Api-Key заголовке")
|
||||
else:
|
||||
headers['X-Api-Key'] = self.api_key
|
||||
logger.debug("Используем API ключ в X-Api-Key заголовке")
|
||||
|
||||
headers['Authorization'] = f'Bearer {self.api_key}'
|
||||
|
||||
return headers
|
||||
|
||||
async def __aenter__(self):
|
||||
conn_type = self._detect_connection_type()
|
||||
|
||||
logger.debug(f"Подключение к Remnawave: {self.base_url} (тип: {conn_type})")
|
||||
|
||||
headers = self._prepare_auth_headers()
|
||||
|
||||
cookies = None
|
||||
if self.secret_key:
|
||||
if ':' in self.secret_key:
|
||||
key_name, key_value = self.secret_key.split(':', 1)
|
||||
cookies = {key_name: key_value}
|
||||
logger.debug(f"🍪 Используем куки: {key_name}=***")
|
||||
logger.debug(f"Используем куки: {key_name}=***")
|
||||
else:
|
||||
cookies = {self.secret_key: self.secret_key}
|
||||
logger.debug(f"🍪 Используем куки: {self.secret_key}=***")
|
||||
logger.debug(f"Используем куки: {self.secret_key}=***")
|
||||
|
||||
connector_kwargs = {}
|
||||
|
||||
if conn_type == "local":
|
||||
logger.debug("🏠 Использую локальные заголовки proxy")
|
||||
logger.debug("Используют локальные заголовки proxy")
|
||||
headers.update({
|
||||
'X-Forwarded-Host': 'localhost',
|
||||
'Host': 'localhost'
|
||||
@@ -158,10 +191,10 @@ class RemnaWaveAPI:
|
||||
ssl_context.check_hostname = False
|
||||
ssl_context.verify_mode = ssl.CERT_NONE
|
||||
connector_kwargs['ssl'] = ssl_context
|
||||
logger.debug("🔓 SSL проверка отключена для локального HTTPS")
|
||||
logger.debug("SSL проверка отключена для локального HTTPS")
|
||||
|
||||
elif conn_type == "external":
|
||||
logger.debug("🌐 Использую внешнее подключение с полной SSL проверкой")
|
||||
logger.debug("Используют внешнее подключение с полной SSL проверкой")
|
||||
pass
|
||||
|
||||
connector = aiohttp.TCPConnector(**connector_kwargs)
|
||||
@@ -280,7 +313,10 @@ class RemnaWaveAPI:
|
||||
async def get_user_by_telegram_id(self, telegram_id: int) -> List[RemnaWaveUser]:
|
||||
try:
|
||||
response = await self._make_request('GET', f'/api/users/by-telegram-id/{telegram_id}')
|
||||
return [self._parse_user(user) for user in response['response']]
|
||||
users_data = response.get('response', [])
|
||||
if not users_data:
|
||||
return []
|
||||
return [self._parse_user(user) for user in users_data]
|
||||
except RemnaWaveAPIError as e:
|
||||
if e.status_code == 404:
|
||||
return []
|
||||
@@ -440,9 +476,47 @@ class RemnaWaveAPI:
|
||||
return response['response']['eventSent']
|
||||
|
||||
|
||||
async def get_subscription_info(self, short_uuid: str) -> Dict[str, Any]:
|
||||
async def get_subscription_info(self, short_uuid: str) -> SubscriptionInfo:
|
||||
response = await self._make_request('GET', f'/api/sub/{short_uuid}/info')
|
||||
return response['response']
|
||||
return self._parse_subscription_info(response['response'])
|
||||
|
||||
async def get_subscription_by_short_uuid(self, short_uuid: str) -> str:
|
||||
async with self.session.get(f"{self.base_url}/api/sub/{short_uuid}") as response:
|
||||
if response.status >= 400:
|
||||
raise RemnaWaveAPIError(f"Failed to get subscription: {response.status}")
|
||||
return await response.text()
|
||||
|
||||
async def get_subscription_by_client_type(self, short_uuid: str, client_type: str) -> str:
|
||||
valid_types = ["stash", "singbox", "singbox-legacy", "mihomo", "json", "v2ray-json", "clash"]
|
||||
if client_type not in valid_types:
|
||||
raise ValueError(f"Invalid client type. Must be one of: {valid_types}")
|
||||
|
||||
async with self.session.get(f"{self.base_url}/api/sub/{short_uuid}/{client_type}") as response:
|
||||
if response.status >= 400:
|
||||
raise RemnaWaveAPIError(f"Failed to get subscription: {response.status}")
|
||||
return await response.text()
|
||||
|
||||
async def get_subscription_links(self, short_uuid: str) -> Dict[str, str]:
|
||||
base_url = f"{self.base_url}/api/sub/{short_uuid}"
|
||||
|
||||
links = {
|
||||
"base": base_url,
|
||||
"stash": f"{base_url}/stash",
|
||||
"singbox": f"{base_url}/singbox",
|
||||
"singbox_legacy": f"{base_url}/singbox-legacy",
|
||||
"mihomo": f"{base_url}/mihomo",
|
||||
"json": f"{base_url}/json",
|
||||
"v2ray_json": f"{base_url}/v2ray-json",
|
||||
"clash": f"{base_url}/clash"
|
||||
}
|
||||
|
||||
return links
|
||||
|
||||
async def get_outline_subscription(self, short_uuid: str, encoded_tag: str) -> str:
|
||||
async with self.session.get(f"{self.base_url}/api/sub/outline/{short_uuid}/ss/{encoded_tag}") as response:
|
||||
if response.status >= 400:
|
||||
raise RemnaWaveAPIError(f"Failed to get outline subscription: {response.status}")
|
||||
return await response.text()
|
||||
|
||||
|
||||
async def get_system_stats(self) -> Dict[str, Any]:
|
||||
@@ -513,6 +587,10 @@ class RemnaWaveAPI:
|
||||
|
||||
|
||||
def _parse_user(self, user_data: Dict) -> RemnaWaveUser:
|
||||
happ_data = user_data.get('happ') or {}
|
||||
happ_link = happ_data.get('link') or happ_data.get('url')
|
||||
happ_crypto_link = happ_data.get('cryptoLink') or happ_data.get('crypto_link')
|
||||
|
||||
return RemnaWaveUser(
|
||||
uuid=user_data['uuid'],
|
||||
short_uuid=user_data['shortUuid'],
|
||||
@@ -541,7 +619,9 @@ class RemnaWaveAPI:
|
||||
vless_uuid=user_data.get('vlessUuid'),
|
||||
ss_password=user_data.get('ssPassword'),
|
||||
first_connected_at=self._parse_optional_datetime(user_data.get('firstConnectedAt')),
|
||||
last_triggered_threshold=user_data.get('lastTriggeredThreshold', 0)
|
||||
last_triggered_threshold=user_data.get('lastTriggeredThreshold', 0),
|
||||
happ_link=happ_link,
|
||||
happ_crypto_link=happ_crypto_link
|
||||
)
|
||||
|
||||
def _parse_optional_datetime(self, date_str: Optional[str]) -> Optional[datetime]:
|
||||
@@ -572,6 +652,22 @@ class RemnaWaveAPI:
|
||||
traffic_used_bytes=node_data.get('trafficUsedBytes'),
|
||||
traffic_limit_bytes=node_data.get('trafficLimitBytes')
|
||||
)
|
||||
|
||||
def _parse_subscription_info(self, data: Dict) -> SubscriptionInfo:
|
||||
happ_data = data.get('happ') or {}
|
||||
happ_link = happ_data.get('link') or happ_data.get('url')
|
||||
happ_crypto_link = happ_data.get('cryptoLink') or happ_data.get('crypto_link')
|
||||
|
||||
return SubscriptionInfo(
|
||||
is_found=data['isFound'],
|
||||
user=data.get('user'),
|
||||
links=data.get('links', []),
|
||||
ss_conf_links=data.get('ssConfLinks', {}),
|
||||
subscription_url=data.get('subscriptionUrl', ''),
|
||||
happ=data.get('happ'),
|
||||
happ_link=happ_link,
|
||||
happ_crypto_link=happ_crypto_link
|
||||
)
|
||||
|
||||
|
||||
def format_bytes(bytes_value: int) -> str:
|
||||
|
||||
Vendored
+15
-11
@@ -1,4 +1,5 @@
|
||||
import logging
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
from typing import Optional, Dict, Any
|
||||
from aiogram import Bot
|
||||
from aiogram.types import LabeledPrice, InlineKeyboardMarkup, InlineKeyboardButton
|
||||
@@ -18,8 +19,9 @@ class TelegramStarsService:
|
||||
return settings.rubles_to_stars(rubles)
|
||||
|
||||
@staticmethod
|
||||
def calculate_rubles_from_stars(stars: int) -> float:
|
||||
return settings.stars_to_rubles(stars)
|
||||
def calculate_rubles_from_stars(stars: int) -> Decimal:
|
||||
rate = Decimal(str(settings.get_stars_rate()))
|
||||
return (Decimal(stars) * rate).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||
|
||||
async def create_invoice(
|
||||
self,
|
||||
@@ -31,9 +33,10 @@ class TelegramStarsService:
|
||||
start_parameter: Optional[str] = None
|
||||
) -> Optional[str]:
|
||||
try:
|
||||
amount_rubles = amount_kopeks / 100
|
||||
stars_amount = self.calculate_stars_from_rubles(amount_rubles)
|
||||
|
||||
amount_rubles = Decimal(amount_kopeks) / Decimal(100)
|
||||
stars_amount = self.calculate_stars_from_rubles(float(amount_rubles))
|
||||
stars_rate = settings.get_stars_rate()
|
||||
|
||||
invoice_link = await self.bot.create_invoice_link(
|
||||
title=title,
|
||||
description=description,
|
||||
@@ -45,8 +48,8 @@ class TelegramStarsService:
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Создан Stars invoice на {stars_amount} звезд (~{amount_rubles:.2f}₽) "
|
||||
f"для {chat_id}, курс: {settings.get_stars_rate():.2f}₽/⭐"
|
||||
f"Создан Stars invoice на {stars_amount} звезд (~{settings.format_price(amount_kopeks)}) "
|
||||
f"для {chat_id}, курс: {stars_rate}₽/⭐"
|
||||
)
|
||||
return invoice_link
|
||||
|
||||
@@ -64,8 +67,9 @@ class TelegramStarsService:
|
||||
keyboard: Optional[InlineKeyboardMarkup] = None
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
try:
|
||||
amount_rubles = amount_kopeks / 100
|
||||
stars_amount = self.calculate_stars_from_rubles(amount_rubles)
|
||||
amount_rubles = Decimal(amount_kopeks) / Decimal(100)
|
||||
stars_amount = self.calculate_stars_from_rubles(float(amount_rubles))
|
||||
stars_rate = settings.get_stars_rate()
|
||||
|
||||
message = await self.bot.send_invoice(
|
||||
chat_id=chat_id,
|
||||
@@ -80,12 +84,12 @@ class TelegramStarsService:
|
||||
|
||||
logger.info(
|
||||
f"Отправлен Stars invoice {message.message_id} на {stars_amount} звезд "
|
||||
f"(~{amount_rubles:.2f}₽), курс: {settings.get_stars_rate():.2f}₽/⭐"
|
||||
f"(~{settings.format_price(amount_kopeks)}), курс: {stars_rate}₽/⭐"
|
||||
)
|
||||
return {
|
||||
"message_id": message.message_id,
|
||||
"stars_amount": stars_amount,
|
||||
"rubles_amount": amount_rubles,
|
||||
"rubles_amount": float(amount_rubles),
|
||||
"payload": payload
|
||||
}
|
||||
|
||||
|
||||
Vendored
+11
-12
@@ -10,10 +10,9 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TributeService:
|
||||
|
||||
|
||||
def __init__(self):
|
||||
self.api_key = settings.TRIBUTE_API_KEY
|
||||
self.webhook_secret = settings.TRIBUTE_WEBHOOK_SECRET
|
||||
self.donate_link = settings.TRIBUTE_DONATE_LINK
|
||||
|
||||
async def create_payment_link(
|
||||
@@ -38,27 +37,27 @@ class TributeService:
|
||||
return None
|
||||
|
||||
def verify_webhook_signature(self, payload: str, signature: str) -> bool:
|
||||
|
||||
if not self.webhook_secret:
|
||||
logger.warning("Webhook secret не настроен, пропускаем проверку")
|
||||
return True
|
||||
|
||||
|
||||
if not self.api_key:
|
||||
logger.warning("API key не настроен, пропускаем проверку")
|
||||
return True
|
||||
|
||||
try:
|
||||
expected_signature = hmac.new(
|
||||
self.webhook_secret.encode(),
|
||||
self.api_key.encode(),
|
||||
payload.encode(),
|
||||
hashlib.sha256
|
||||
).hexdigest()
|
||||
|
||||
|
||||
is_valid = hmac.compare_digest(signature, expected_signature)
|
||||
|
||||
|
||||
if is_valid:
|
||||
logger.info("✅ Подпись Tribute webhook проверена успешно")
|
||||
else:
|
||||
logger.error("❌ Неверная подпись Tribute webhook")
|
||||
|
||||
|
||||
return is_valid
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка проверки подписи webhook: {e}")
|
||||
return False
|
||||
|
||||
Vendored
+262
@@ -0,0 +1,262 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import aiohttp
|
||||
from aiohttp import web
|
||||
from cryptography.exceptions import InvalidSignature
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import padding
|
||||
|
||||
from app.config import settings
|
||||
from app.database.database import get_db
|
||||
from app.services.payment_service import PaymentService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WataPublicKeyProvider:
|
||||
"""Loads and caches the WATA public key used for webhook signature validation."""
|
||||
|
||||
def __init__(self, *, cache_seconds: Optional[int] = None) -> None:
|
||||
self._cache_seconds = cache_seconds or int(settings.WATA_PUBLIC_KEY_CACHE_SECONDS)
|
||||
self._cached_key: Optional[str] = None
|
||||
self._expires_at: Optional[datetime] = None
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def get_public_key(self) -> Optional[str]:
|
||||
"""Returns a cached public key or fetches a new one from WATA."""
|
||||
|
||||
now = datetime.utcnow()
|
||||
if self._cached_key and self._expires_at and now < self._expires_at:
|
||||
return self._cached_key
|
||||
|
||||
async with self._lock:
|
||||
now = datetime.utcnow()
|
||||
if self._cached_key and self._expires_at and now < self._expires_at:
|
||||
return self._cached_key
|
||||
|
||||
key = await self._fetch_public_key()
|
||||
if key:
|
||||
self._cached_key = key
|
||||
if self._cache_seconds > 0:
|
||||
self._expires_at = datetime.utcnow() + timedelta(seconds=self._cache_seconds)
|
||||
else:
|
||||
self._expires_at = None
|
||||
logger.debug("Получен и закеширован публичный ключ WATA")
|
||||
return self._cached_key
|
||||
|
||||
if self._cached_key:
|
||||
logger.warning("Используем ранее закешированный публичный ключ WATA")
|
||||
return self._cached_key
|
||||
|
||||
logger.error("Публичный ключ WATA недоступен")
|
||||
return None
|
||||
|
||||
async def _fetch_public_key(self) -> Optional[str]:
|
||||
url = settings.WATA_PUBLIC_KEY_URL or f"{settings.WATA_BASE_URL.rstrip('/')}/public-key"
|
||||
timeout = aiohttp.ClientTimeout(total=settings.WATA_REQUEST_TIMEOUT)
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
async with session.get(url) as response:
|
||||
text = await response.text()
|
||||
if response.status >= 400:
|
||||
logger.error(
|
||||
"Ошибка получения публичного ключа WATA %s: %s",
|
||||
response.status,
|
||||
text,
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
payload = await response.json()
|
||||
except aiohttp.ContentTypeError:
|
||||
logger.error("Ответ WATA public-key не является JSON: %s", text)
|
||||
return None
|
||||
|
||||
if isinstance(payload, dict):
|
||||
value = payload.get("value")
|
||||
if value:
|
||||
return value
|
||||
logger.error("Ответ WATA public-key не содержит ключ: %s", payload)
|
||||
else:
|
||||
logger.error("Неожиданный формат ответа WATA public-key: %s", payload)
|
||||
except Exception as error:
|
||||
logger.error("Ошибка запроса публичного ключа WATA: %s", error)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
class WataWebhookHandler:
|
||||
"""Processes webhook callbacks coming from WATA."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
payment_service: PaymentService,
|
||||
*,
|
||||
public_key_provider: Optional[WataPublicKeyProvider] = None,
|
||||
) -> None:
|
||||
self.payment_service = payment_service
|
||||
self.public_key_provider = public_key_provider or WataPublicKeyProvider()
|
||||
|
||||
async def _verify_signature(self, raw_body: str, signature: str) -> bool:
|
||||
signature = (signature or "").strip()
|
||||
if not signature:
|
||||
logger.error("WATA webhook без подписи")
|
||||
return False
|
||||
|
||||
public_key_pem = await self.public_key_provider.get_public_key()
|
||||
if not public_key_pem:
|
||||
logger.error("Публичный ключ WATA отсутствует, проверка подписи невозможна")
|
||||
return False
|
||||
|
||||
try:
|
||||
signature_bytes = base64.b64decode(signature)
|
||||
except (ValueError, TypeError):
|
||||
logger.error("Некорректная подпись WATA (не Base64)")
|
||||
return False
|
||||
|
||||
try:
|
||||
public_key = serialization.load_pem_public_key(public_key_pem.encode("utf-8"))
|
||||
except ValueError as error:
|
||||
logger.error("Ошибка загрузки публичного ключа WATA: %s", error)
|
||||
return False
|
||||
|
||||
try:
|
||||
public_key.verify(
|
||||
signature_bytes,
|
||||
raw_body.encode("utf-8"),
|
||||
padding.PKCS1v15(),
|
||||
hashes.SHA512(),
|
||||
)
|
||||
return True
|
||||
except InvalidSignature:
|
||||
logger.warning("Подпись WATA webhook не прошла проверку")
|
||||
return False
|
||||
except Exception as error:
|
||||
logger.error("Ошибка проверки подписи WATA: %s", error)
|
||||
return False
|
||||
|
||||
async def handle_webhook(self, request: web.Request) -> web.Response:
|
||||
if not settings.is_wata_enabled():
|
||||
logger.warning("Получен WATA webhook, но сервис отключен")
|
||||
return web.json_response({"status": "error", "reason": "wata_disabled"}, status=503)
|
||||
|
||||
raw_body = await request.text()
|
||||
if not raw_body:
|
||||
logger.warning("Получен пустой WATA webhook")
|
||||
return web.json_response({"status": "error", "reason": "empty_body"}, status=400)
|
||||
|
||||
signature = request.headers.get("X-Signature")
|
||||
if not await self._verify_signature(raw_body, signature or ""):
|
||||
return web.json_response({"status": "error", "reason": "invalid_signature"}, status=401)
|
||||
|
||||
try:
|
||||
payload: Dict[str, Any] = json.loads(raw_body)
|
||||
except json.JSONDecodeError:
|
||||
logger.error("Некорректный JSON WATA webhook")
|
||||
return web.json_response({"status": "error", "reason": "invalid_json"}, status=400)
|
||||
|
||||
logger.info(
|
||||
"Получен WATA webhook: order_id=%s, status=%s",
|
||||
payload.get("orderId"),
|
||||
payload.get("transactionStatus"),
|
||||
)
|
||||
|
||||
processed: Optional[bool] = None
|
||||
async for db in get_db():
|
||||
processed = await self.payment_service.process_wata_webhook(db, payload)
|
||||
# Allow the generator to finish naturally so it can commit/rollback.
|
||||
# get_db() yields only once, so exiting the loop body without breaking
|
||||
# triggers the generator cleanup logic on the next iteration attempt.
|
||||
|
||||
if processed is None:
|
||||
logger.error("Не удалось обработать WATA webhook: нет сессии БД")
|
||||
return web.json_response({"status": "error", "reason": "db_session_unavailable"}, status=500)
|
||||
|
||||
if processed:
|
||||
return web.json_response({"status": "ok"}, status=200)
|
||||
|
||||
return web.json_response({"status": "error", "reason": "not_processed"}, status=400)
|
||||
|
||||
async def health_check(self, request: web.Request) -> web.Response:
|
||||
return web.json_response(
|
||||
{
|
||||
"status": "ok",
|
||||
"service": "wata_webhook",
|
||||
"enabled": settings.is_wata_enabled(),
|
||||
"path": settings.WATA_WEBHOOK_PATH,
|
||||
}
|
||||
)
|
||||
|
||||
async def options_handler(self, _: web.Request) -> web.Response:
|
||||
return web.Response(
|
||||
status=200,
|
||||
headers={
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "POST, GET, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type, X-Signature",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def create_wata_webhook_app(payment_service: PaymentService) -> web.Application:
|
||||
app = web.Application()
|
||||
handler = WataWebhookHandler(payment_service)
|
||||
|
||||
app.router.add_post(settings.WATA_WEBHOOK_PATH, handler.handle_webhook)
|
||||
app.router.add_get(settings.WATA_WEBHOOK_PATH, handler.health_check)
|
||||
app.router.add_options(settings.WATA_WEBHOOK_PATH, handler.options_handler)
|
||||
app.router.add_get("/health", handler.health_check)
|
||||
|
||||
logger.info(
|
||||
"Настроен WATA webhook endpoint на %s",
|
||||
settings.WATA_WEBHOOK_PATH,
|
||||
)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
async def start_wata_webhook_server(payment_service: PaymentService) -> None:
|
||||
if not settings.is_wata_enabled():
|
||||
logger.info("WATA отключен, webhook сервер не запускается")
|
||||
return
|
||||
|
||||
app = create_wata_webhook_app(payment_service)
|
||||
runner = web.AppRunner(app)
|
||||
await runner.setup()
|
||||
|
||||
site = web.TCPSite(
|
||||
runner,
|
||||
host=settings.WATA_WEBHOOK_HOST,
|
||||
port=settings.WATA_WEBHOOK_PORT,
|
||||
)
|
||||
|
||||
try:
|
||||
await site.start()
|
||||
logger.info(
|
||||
"WATA webhook сервер запущен на %s:%s",
|
||||
settings.WATA_WEBHOOK_HOST,
|
||||
settings.WATA_WEBHOOK_PORT,
|
||||
)
|
||||
logger.info(
|
||||
"WATA webhook URL: http://%s:%s%s",
|
||||
settings.WATA_WEBHOOK_HOST,
|
||||
settings.WATA_WEBHOOK_PORT,
|
||||
settings.WATA_WEBHOOK_PATH,
|
||||
)
|
||||
|
||||
while True:
|
||||
await asyncio.sleep(1)
|
||||
except asyncio.CancelledError:
|
||||
logger.info("WATA webhook сервер остановлен по запросу")
|
||||
finally:
|
||||
await site.stop()
|
||||
await runner.cleanup()
|
||||
logger.info("WATA webhook сервер корректно остановлен")
|
||||
Vendored
+330
-32
@@ -1,12 +1,17 @@
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import logging
|
||||
import json
|
||||
from typing import Optional
|
||||
from typing import Optional, Iterable
|
||||
|
||||
from aiohttp import web
|
||||
from aiogram import Bot
|
||||
|
||||
from app.config import settings
|
||||
from app.services.tribute_service import TributeService
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.database.database import get_db
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -25,12 +30,32 @@ class WebhookServer:
|
||||
self.app = web.Application()
|
||||
|
||||
self.app.router.add_post(settings.TRIBUTE_WEBHOOK_PATH, self._tribute_webhook_handler)
|
||||
|
||||
if settings.is_mulenpay_enabled():
|
||||
self.app.router.add_post(settings.MULENPAY_WEBHOOK_PATH, self._mulenpay_webhook_handler)
|
||||
|
||||
if settings.is_cryptobot_enabled():
|
||||
self.app.router.add_post(settings.CRYPTOBOT_WEBHOOK_PATH, self._cryptobot_webhook_handler)
|
||||
|
||||
self.app.router.add_get('/health', self._health_check)
|
||||
|
||||
self.app.router.add_options(settings.TRIBUTE_WEBHOOK_PATH, self._options_handler)
|
||||
if settings.is_mulenpay_enabled():
|
||||
self.app.router.add_options(settings.MULENPAY_WEBHOOK_PATH, self._options_handler)
|
||||
if settings.is_cryptobot_enabled():
|
||||
self.app.router.add_options(settings.CRYPTOBOT_WEBHOOK_PATH, self._options_handler)
|
||||
|
||||
logger.info(f"Webhook сервер настроен:")
|
||||
logger.info(f" - Tribute webhook: POST {settings.TRIBUTE_WEBHOOK_PATH}")
|
||||
if settings.is_mulenpay_enabled():
|
||||
mulenpay_name = settings.get_mulenpay_display_name()
|
||||
logger.info(
|
||||
" - %s webhook: POST %s",
|
||||
mulenpay_name,
|
||||
settings.MULENPAY_WEBHOOK_PATH,
|
||||
)
|
||||
if settings.is_cryptobot_enabled():
|
||||
logger.info(f" - CryptoBot webhook: POST {settings.CRYPTOBOT_WEBHOOK_PATH}")
|
||||
logger.info(f" - Health check: GET /health")
|
||||
|
||||
return self.app
|
||||
@@ -46,17 +71,42 @@ class WebhookServer:
|
||||
|
||||
self.site = web.TCPSite(
|
||||
self.runner,
|
||||
host='0.0.0.0',
|
||||
host=settings.TRIBUTE_WEBHOOK_HOST,
|
||||
port=settings.TRIBUTE_WEBHOOK_PORT
|
||||
)
|
||||
|
||||
|
||||
await self.site.start()
|
||||
|
||||
logger.info(f"✅ Tribute webhook сервер запущен на порту {settings.TRIBUTE_WEBHOOK_PORT}")
|
||||
logger.info(f"🎯 Tribute webhook URL: http://0.0.0.0:{settings.TRIBUTE_WEBHOOK_PORT}{settings.TRIBUTE_WEBHOOK_PATH}")
|
||||
|
||||
logger.info(
|
||||
"Webhook сервер запущен на %s:%s",
|
||||
settings.TRIBUTE_WEBHOOK_HOST,
|
||||
settings.TRIBUTE_WEBHOOK_PORT,
|
||||
)
|
||||
logger.info(
|
||||
"Tribute webhook URL: http://%s:%s%s",
|
||||
settings.TRIBUTE_WEBHOOK_HOST,
|
||||
settings.TRIBUTE_WEBHOOK_PORT,
|
||||
settings.TRIBUTE_WEBHOOK_PATH,
|
||||
)
|
||||
if settings.is_mulenpay_enabled():
|
||||
mulenpay_name = settings.get_mulenpay_display_name()
|
||||
logger.info(
|
||||
"%s webhook URL: http://%s:%s%s",
|
||||
mulenpay_name,
|
||||
settings.TRIBUTE_WEBHOOK_HOST,
|
||||
settings.TRIBUTE_WEBHOOK_PORT,
|
||||
settings.MULENPAY_WEBHOOK_PATH,
|
||||
)
|
||||
if settings.is_cryptobot_enabled():
|
||||
logger.info(
|
||||
"CryptoBot webhook URL: http://%s:%s%s",
|
||||
settings.TRIBUTE_WEBHOOK_HOST,
|
||||
settings.TRIBUTE_WEBHOOK_PORT,
|
||||
settings.CRYPTOBOT_WEBHOOK_PATH,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка запуска Tribute webhook сервера: {e}")
|
||||
logger.error(f"Ошибка запуска webhook сервера: {e}")
|
||||
raise
|
||||
|
||||
async def stop(self):
|
||||
@@ -64,14 +114,14 @@ class WebhookServer:
|
||||
try:
|
||||
if self.site:
|
||||
await self.site.stop()
|
||||
logger.info("Tribute webhook сайт остановлен")
|
||||
logger.info("Webhook сайт остановлен")
|
||||
|
||||
if self.runner:
|
||||
await self.runner.cleanup()
|
||||
logger.info("Tribute webhook runner очищен")
|
||||
logger.info("Webhook runner очищен")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка остановки Tribute webhook сервера: {e}")
|
||||
logger.error(f"Ошибка остановки webhook сервера: {e}")
|
||||
|
||||
async def _options_handler(self, request: web.Request) -> web.Response:
|
||||
return web.Response(
|
||||
@@ -79,65 +129,311 @@ class WebhookServer:
|
||||
headers={
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'POST, GET, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, X-Tribute-Signature',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, trbt-signature, Crypto-Pay-API-Signature, X-MulenPay-Signature, Authorization',
|
||||
}
|
||||
)
|
||||
|
||||
async def _tribute_webhook_handler(self, request: web.Request) -> web.Response:
|
||||
|
||||
|
||||
async def _mulenpay_webhook_handler(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
logger.info(f"📥 Получен Tribute webhook: {request.method} {request.path}")
|
||||
logger.info(f"📋 Headers: {dict(request.headers)}")
|
||||
mulenpay_name = settings.get_mulenpay_display_name()
|
||||
logger.info(
|
||||
"%s webhook: %s %s",
|
||||
mulenpay_name,
|
||||
request.method,
|
||||
request.path,
|
||||
)
|
||||
logger.info(
|
||||
"%s webhook headers: %s",
|
||||
mulenpay_name,
|
||||
dict(request.headers),
|
||||
)
|
||||
raw_body = await request.read()
|
||||
|
||||
if not raw_body:
|
||||
logger.warning("Пустой %s webhook", mulenpay_name)
|
||||
return web.json_response({"status": "error", "reason": "empty_body"}, status=400)
|
||||
|
||||
# Временно отключаем проверку подписи для отладки
|
||||
# TODO: Включить обратно после настройки MulenPay
|
||||
if not self._verify_mulenpay_signature(request, raw_body):
|
||||
logger.warning(
|
||||
"%s webhook signature verification failed, but processing anyway for debugging",
|
||||
mulenpay_name,
|
||||
)
|
||||
# return web.json_response({"status": "error", "reason": "invalid_signature"}, status=401)
|
||||
|
||||
try:
|
||||
payload = json.loads(raw_body.decode('utf-8'))
|
||||
except json.JSONDecodeError as error:
|
||||
logger.error(f"Ошибка парсинга {mulenpay_name} webhook: {error}")
|
||||
return web.json_response({"status": "error", "reason": "invalid_json"}, status=400)
|
||||
|
||||
payment_service = PaymentService(self.bot)
|
||||
|
||||
# Получаем соединение с БД
|
||||
db_generator = get_db()
|
||||
db = await db_generator.__anext__()
|
||||
|
||||
try:
|
||||
success = await payment_service.process_mulenpay_callback(db, payload)
|
||||
if success:
|
||||
return web.json_response({"status": "ok"}, status=200)
|
||||
return web.json_response({"status": "error", "reason": "processing_failed"}, status=400)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
f"Ошибка обработки {mulenpay_name} webhook: {error}",
|
||||
exc_info=True,
|
||||
)
|
||||
return web.json_response({"status": "error", "reason": "internal_error"}, status=500)
|
||||
finally:
|
||||
try:
|
||||
await db_generator.__anext__()
|
||||
except StopAsyncIteration:
|
||||
pass
|
||||
|
||||
except Exception as error:
|
||||
mulenpay_name = settings.get_mulenpay_display_name()
|
||||
logger.error(
|
||||
f"Критическая ошибка {mulenpay_name} webhook: {error}",
|
||||
exc_info=True,
|
||||
)
|
||||
return web.json_response({"status": "error", "reason": "internal_error", "message": str(error)}, status=500)
|
||||
|
||||
@staticmethod
|
||||
def _extract_mulenpay_header(request: web.Request, header_names: Iterable[str]) -> Optional[str]:
|
||||
for header_name in header_names:
|
||||
value = request.headers.get(header_name)
|
||||
if value:
|
||||
return value.strip()
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _verify_mulenpay_signature(request: web.Request, raw_body: bytes) -> bool:
|
||||
secret_key = settings.MULENPAY_SECRET_KEY
|
||||
display_name = settings.get_mulenpay_display_name()
|
||||
if not secret_key:
|
||||
logger.error("%s secret key is not configured", display_name)
|
||||
return False
|
||||
|
||||
# Логируем все заголовки для отладки
|
||||
logger.info("%s webhook headers for signature verification:", display_name)
|
||||
for header_name, header_value in request.headers.items():
|
||||
if any(keyword in header_name.lower() for keyword in ['signature', 'sign', 'token', 'auth']):
|
||||
logger.info(f" {header_name}: {header_value}")
|
||||
|
||||
signature = WebhookServer._extract_mulenpay_header(
|
||||
request,
|
||||
(
|
||||
'X-MulenPay-Signature',
|
||||
'X-Mulenpay-Signature',
|
||||
'X-MULENPAY-SIGNATURE',
|
||||
'X-MulenPay-Webhook-Signature',
|
||||
'X-Mulenpay-Webhook-Signature',
|
||||
'X-MULENPAY-WEBHOOK-SIGNATURE',
|
||||
'X-Signature',
|
||||
'Signature',
|
||||
'X-MulenPay-Sign',
|
||||
'X-Mulenpay-Sign',
|
||||
'X-MULENPAY-SIGN',
|
||||
'MulenPay-Signature',
|
||||
'Mulenpay-Signature',
|
||||
'MULENPAY-SIGNATURE',
|
||||
'signature',
|
||||
'sign',
|
||||
)
|
||||
)
|
||||
if signature:
|
||||
normalized_signature = signature
|
||||
if normalized_signature.lower().startswith('sha256='):
|
||||
normalized_signature = normalized_signature.split('=', 1)[1].strip()
|
||||
|
||||
hmac_digest = hmac.new(
|
||||
secret_key.encode('utf-8'),
|
||||
raw_body,
|
||||
hashlib.sha256,
|
||||
).digest()
|
||||
expected_hex_signature = hmac_digest.hex()
|
||||
expected_base64_signature = base64.b64encode(hmac_digest).decode('utf-8').strip()
|
||||
expected_urlsafe_base64_signature = base64.urlsafe_b64encode(hmac_digest).decode('utf-8').strip()
|
||||
|
||||
normalized_signature_lower = normalized_signature.lower()
|
||||
if hmac.compare_digest(normalized_signature_lower, expected_hex_signature.lower()):
|
||||
return True
|
||||
|
||||
normalized_signature_no_padding = normalized_signature.rstrip('=')
|
||||
if hmac.compare_digest(normalized_signature_no_padding, expected_base64_signature.rstrip('=')):
|
||||
return True
|
||||
|
||||
if hmac.compare_digest(normalized_signature_no_padding, expected_urlsafe_base64_signature.rstrip('=')):
|
||||
return True
|
||||
|
||||
logger.error("Неверная подпись %s webhook", display_name)
|
||||
return False
|
||||
|
||||
authorization_header = request.headers.get('Authorization')
|
||||
if authorization_header:
|
||||
scheme, _, value = authorization_header.partition(' ')
|
||||
scheme_lower = scheme.lower()
|
||||
token = value.strip() if value else scheme.strip()
|
||||
|
||||
if scheme_lower in ('bearer', 'token'):
|
||||
if hmac.compare_digest(token, secret_key):
|
||||
return True
|
||||
|
||||
logger.error("Неверный %s токен %s webhook", scheme, display_name)
|
||||
return False
|
||||
|
||||
if not value and hmac.compare_digest(token, secret_key):
|
||||
return True
|
||||
|
||||
fallback_token = WebhookServer._extract_mulenpay_header(
|
||||
request,
|
||||
(
|
||||
'X-MulenPay-Token',
|
||||
'X-Mulenpay-Token',
|
||||
'X-Webhook-Token',
|
||||
)
|
||||
)
|
||||
if fallback_token and hmac.compare_digest(fallback_token, secret_key):
|
||||
return True
|
||||
|
||||
logger.info(
|
||||
"%s webhook headers received: %s",
|
||||
display_name,
|
||||
{key: value for key, value in request.headers.items() if 'authorization' not in key.lower()}
|
||||
)
|
||||
|
||||
logger.error("Отсутствует подпись %s webhook", display_name)
|
||||
return False
|
||||
|
||||
async def _tribute_webhook_handler(self, request: web.Request) -> web.Response:
|
||||
|
||||
try:
|
||||
logger.info(f"Получен Tribute webhook: {request.method} {request.path}")
|
||||
logger.info(f"Headers: {dict(request.headers)}")
|
||||
|
||||
raw_body = await request.read()
|
||||
|
||||
if not raw_body:
|
||||
logger.warning("⚠️ Получен пустой webhook от Tribute")
|
||||
logger.warning("Получен пустой webhook от Tribute")
|
||||
return web.json_response(
|
||||
{"status": "error", "reason": "empty_body"},
|
||||
status=400
|
||||
)
|
||||
|
||||
payload = raw_body.decode('utf-8')
|
||||
logger.info(f"📄 Payload: {payload}")
|
||||
logger.info(f"Payload: {payload}")
|
||||
|
||||
try:
|
||||
webhook_data = json.loads(payload)
|
||||
logger.info(f"📊 Распарсенные данные: {webhook_data}")
|
||||
logger.info(f"Распарсенные данные: {webhook_data}")
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"❌ Ошибка парсинга JSON: {e}")
|
||||
logger.error(f"Ошибка парсинга JSON: {e}")
|
||||
return web.json_response(
|
||||
{"status": "error", "reason": "invalid_json"},
|
||||
status=400
|
||||
)
|
||||
|
||||
signature = request.headers.get('X-Tribute-Signature')
|
||||
logger.info(f"🔐 Signature: {signature}")
|
||||
|
||||
if signature and settings.TRIBUTE_WEBHOOK_SECRET:
|
||||
signature = request.headers.get('trbt-signature')
|
||||
logger.info(f"Signature: {signature}")
|
||||
|
||||
if not signature:
|
||||
logger.error("Отсутствует заголовок подписи Tribute webhook")
|
||||
return web.json_response(
|
||||
{"status": "error", "reason": "missing_signature"},
|
||||
status=401
|
||||
)
|
||||
|
||||
if settings.TRIBUTE_API_KEY:
|
||||
from app.external.tribute import TributeService as TributeAPI
|
||||
tribute_api = TributeAPI()
|
||||
if not tribute_api.verify_webhook_signature(payload, signature):
|
||||
logger.error("❌ Неверная подпись Tribute webhook")
|
||||
logger.error("Неверная подпись Tribute webhook")
|
||||
return web.json_response(
|
||||
{"status": "error", "reason": "invalid_signature"},
|
||||
status=401
|
||||
)
|
||||
|
||||
result = await self.tribute_service.process_webhook(payload, signature)
|
||||
|
||||
result = await self.tribute_service.process_webhook(payload)
|
||||
|
||||
if result:
|
||||
logger.info(f"✅ Tribute webhook обработан успешно: {result}")
|
||||
logger.info(f"Tribute webhook обработан успешно: {result}")
|
||||
return web.json_response({"status": "ok", "result": result}, status=200)
|
||||
else:
|
||||
logger.error("❌ Ошибка обработки Tribute webhook")
|
||||
logger.error("Ошибка обработки Tribute webhook")
|
||||
return web.json_response(
|
||||
{"status": "error", "reason": "processing_failed"},
|
||||
status=400
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Критическая ошибка обработки Tribute webhook: {e}", exc_info=True)
|
||||
logger.error(f"Критическая ошибка обработки Tribute webhook: {e}", exc_info=True)
|
||||
return web.json_response(
|
||||
{"status": "error", "reason": "internal_error", "message": str(e)},
|
||||
status=500
|
||||
)
|
||||
|
||||
async def _cryptobot_webhook_handler(self, request: web.Request) -> web.Response:
|
||||
|
||||
try:
|
||||
logger.info(f"Получен CryptoBot webhook: {request.method} {request.path}")
|
||||
logger.info(f"Headers: {dict(request.headers)}")
|
||||
|
||||
raw_body = await request.read()
|
||||
|
||||
if not raw_body:
|
||||
logger.warning("Получен пустой CryptoBot webhook")
|
||||
return web.json_response(
|
||||
{"status": "error", "reason": "empty_body"},
|
||||
status=400
|
||||
)
|
||||
|
||||
payload = raw_body.decode('utf-8')
|
||||
logger.info(f"CryptoBot Payload: {payload}")
|
||||
|
||||
try:
|
||||
webhook_data = json.loads(payload)
|
||||
logger.info(f"CryptoBot данные: {webhook_data}")
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"Ошибка парсинга CryptoBot JSON: {e}")
|
||||
return web.json_response(
|
||||
{"status": "error", "reason": "invalid_json"},
|
||||
status=400
|
||||
)
|
||||
|
||||
signature = request.headers.get('Crypto-Pay-API-Signature')
|
||||
logger.info(f"CryptoBot Signature: {signature}")
|
||||
|
||||
if signature and settings.CRYPTOBOT_WEBHOOK_SECRET:
|
||||
from app.external.cryptobot import CryptoBotService
|
||||
cryptobot_service = CryptoBotService()
|
||||
if not cryptobot_service.verify_webhook_signature(payload, signature):
|
||||
logger.error("Неверная подпись CryptoBot webhook")
|
||||
return web.json_response(
|
||||
{"status": "error", "reason": "invalid_signature"},
|
||||
status=401
|
||||
)
|
||||
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.database.database import AsyncSessionLocal
|
||||
|
||||
payment_service = PaymentService(self.bot)
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
result = await payment_service.process_cryptobot_webhook(db, webhook_data)
|
||||
|
||||
if result:
|
||||
logger.info(f"CryptoBot webhook обработан успешно")
|
||||
return web.json_response({"status": "ok"}, status=200)
|
||||
else:
|
||||
logger.error("Ошибка обработки CryptoBot webhook")
|
||||
return web.json_response(
|
||||
{"status": "error", "reason": "processing_failed"},
|
||||
status=400
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Критическая ошибка обработки CryptoBot webhook: {e}", exc_info=True)
|
||||
return web.json_response(
|
||||
{"status": "error", "reason": "internal_error", "message": str(e)},
|
||||
status=500
|
||||
@@ -147,8 +443,10 @@ class WebhookServer:
|
||||
|
||||
return web.json_response({
|
||||
"status": "ok",
|
||||
"service": "tribute-webhooks",
|
||||
"service": "payment-webhooks",
|
||||
"tribute_enabled": settings.TRIBUTE_ENABLED,
|
||||
"cryptobot_enabled": settings.is_cryptobot_enabled(),
|
||||
"port": settings.TRIBUTE_WEBHOOK_PORT,
|
||||
"path": settings.TRIBUTE_WEBHOOK_PATH
|
||||
"tribute_path": settings.TRIBUTE_WEBHOOK_PATH,
|
||||
"cryptobot_path": settings.CRYPTOBOT_WEBHOOK_PATH if settings.is_cryptobot_enabled() else None
|
||||
})
|
||||
|
||||
Vendored
+281
-131
@@ -1,12 +1,16 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import json
|
||||
import hashlib
|
||||
import hmac
|
||||
import base64
|
||||
from typing import Optional, Dict, Any
|
||||
from ipaddress import (
|
||||
IPv4Address,
|
||||
IPv4Network,
|
||||
IPv6Address,
|
||||
IPv6Network,
|
||||
ip_address,
|
||||
ip_network,
|
||||
)
|
||||
from typing import Iterable, Optional, Dict, Any, List, Union, Tuple
|
||||
from aiohttp import web
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.services.payment_service import PaymentService
|
||||
@@ -15,155 +19,292 @@ from app.database.database import get_db
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
IPAddress = Union[IPv4Address, IPv6Address]
|
||||
IPNetwork = Union[IPv4Network, IPv6Network]
|
||||
|
||||
YOOKASSA_ALLOWED_IP_NETWORKS: tuple[IPNetwork, ...] = (
|
||||
ip_network("185.71.76.0/27"),
|
||||
ip_network("185.71.77.0/27"),
|
||||
ip_network("77.75.153.0/25"),
|
||||
ip_network("77.75.154.128/25"),
|
||||
ip_network("77.75.156.11/32"),
|
||||
ip_network("77.75.156.35/32"),
|
||||
ip_network("2a02:5180::/32"),
|
||||
)
|
||||
|
||||
|
||||
CLOUDFLARE_TRUSTED_NETWORKS: tuple[IPNetwork, ...] = (
|
||||
ip_network("173.245.48.0/20"),
|
||||
ip_network("103.21.244.0/22"),
|
||||
ip_network("103.22.200.0/22"),
|
||||
ip_network("103.31.4.0/22"),
|
||||
ip_network("141.101.64.0/18"),
|
||||
ip_network("108.162.192.0/18"),
|
||||
ip_network("190.93.240.0/20"),
|
||||
ip_network("188.114.96.0/20"),
|
||||
ip_network("197.234.240.0/22"),
|
||||
ip_network("198.41.128.0/17"),
|
||||
ip_network("162.158.0.0/15"),
|
||||
ip_network("104.16.0.0/13"),
|
||||
ip_network("104.24.0.0/14"),
|
||||
ip_network("172.64.0.0/13"),
|
||||
ip_network("131.0.72.0/22"),
|
||||
ip_network("2400:cb00::/32"),
|
||||
ip_network("2606:4700::/32"),
|
||||
ip_network("2803:f800::/32"),
|
||||
ip_network("2405:b500::/32"),
|
||||
ip_network("2405:8100::/32"),
|
||||
ip_network("2a06:98c0::/29"),
|
||||
ip_network("2c0f:f248::/32"),
|
||||
)
|
||||
|
||||
|
||||
YOOKASSA_ALLOWED_EVENTS: tuple[str, ...] = (
|
||||
"payment.succeeded",
|
||||
"payment.waiting_for_capture",
|
||||
"payment.canceled",
|
||||
)
|
||||
|
||||
|
||||
def collect_yookassa_ip_candidates(*values: Optional[str]) -> List[str]:
|
||||
candidates: List[str] = []
|
||||
for value in values:
|
||||
if not value:
|
||||
continue
|
||||
for part in value.split(","):
|
||||
normalized = part.strip()
|
||||
if normalized:
|
||||
candidates.append(normalized)
|
||||
return candidates
|
||||
|
||||
|
||||
def _parse_candidate_ip(candidate: str) -> Optional[IPAddress]:
|
||||
value = candidate.strip()
|
||||
if not value:
|
||||
return None
|
||||
|
||||
if value.startswith("[") and "]" in value:
|
||||
value = value[1:value.index("]")]
|
||||
|
||||
if "%" in value:
|
||||
value = value.split("%", 1)[0]
|
||||
|
||||
if value.count(":") == 1 and "." in value:
|
||||
host, _, port = value.rpartition(":")
|
||||
if port.isdigit():
|
||||
value = host
|
||||
|
||||
try:
|
||||
return ip_address(value)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _should_trust_forwarded_headers(remote_ip: Optional[IPAddress]) -> bool:
|
||||
if remote_ip is None:
|
||||
return True
|
||||
|
||||
if _is_trusted_proxy_ip(remote_ip):
|
||||
return True
|
||||
|
||||
return any(
|
||||
getattr(remote_ip, attribute)
|
||||
for attribute in ("is_private", "is_loopback", "is_link_local", "is_reserved")
|
||||
)
|
||||
|
||||
|
||||
_TRUSTED_PROXY_NETWORKS_CACHE: Tuple[str, Tuple[IPNetwork, ...]] = ("", ())
|
||||
|
||||
|
||||
def _get_trusted_proxy_networks() -> Tuple[IPNetwork, ...]:
|
||||
global _TRUSTED_PROXY_NETWORKS_CACHE
|
||||
|
||||
raw_value = getattr(settings, "YOOKASSA_TRUSTED_PROXY_NETWORKS", "") or ""
|
||||
cached_raw, cached_networks = _TRUSTED_PROXY_NETWORKS_CACHE
|
||||
|
||||
if raw_value == cached_raw:
|
||||
return cached_networks
|
||||
|
||||
networks: List[IPNetwork] = []
|
||||
for part in raw_value.split(","):
|
||||
candidate = part.strip()
|
||||
if not candidate:
|
||||
continue
|
||||
|
||||
try:
|
||||
networks.append(ip_network(candidate, strict=False))
|
||||
except ValueError:
|
||||
logger.warning("Неверная сеть доверенного прокси YooKassa: %s", candidate)
|
||||
|
||||
cached_networks = tuple(networks)
|
||||
_TRUSTED_PROXY_NETWORKS_CACHE = (raw_value, cached_networks)
|
||||
return cached_networks
|
||||
|
||||
|
||||
def _is_trusted_proxy_ip(ip_object: IPAddress) -> bool:
|
||||
if any(
|
||||
getattr(ip_object, attribute)
|
||||
for attribute in ("is_private", "is_loopback", "is_link_local", "is_reserved")
|
||||
):
|
||||
return True
|
||||
|
||||
if any(ip_object in network for network in CLOUDFLARE_TRUSTED_NETWORKS):
|
||||
return True
|
||||
|
||||
return any(ip_object in network for network in _get_trusted_proxy_networks())
|
||||
|
||||
|
||||
def resolve_yookassa_ip(
|
||||
candidates: Iterable[str],
|
||||
*,
|
||||
remote: Optional[str] = None,
|
||||
) -> Optional[IPAddress]:
|
||||
remote_ip = _parse_candidate_ip(remote) if remote else None
|
||||
|
||||
if (
|
||||
remote_ip is not None
|
||||
and remote_ip.is_global
|
||||
and not _is_trusted_proxy_ip(remote_ip)
|
||||
):
|
||||
return remote_ip
|
||||
|
||||
candidate_list = list(candidates)
|
||||
|
||||
if _should_trust_forwarded_headers(remote_ip):
|
||||
last_hop = remote_ip
|
||||
for candidate in reversed(candidate_list):
|
||||
ip_object = _parse_candidate_ip(candidate)
|
||||
if ip_object is not None:
|
||||
if last_hop is None or _is_trusted_proxy_ip(last_hop):
|
||||
if _is_trusted_proxy_ip(ip_object):
|
||||
last_hop = ip_object
|
||||
continue
|
||||
return ip_object
|
||||
break
|
||||
|
||||
if last_hop is not None and not _is_trusted_proxy_ip(last_hop):
|
||||
return last_hop
|
||||
|
||||
return remote_ip if remote_ip is not None else next(
|
||||
(ip for ip in (_parse_candidate_ip(value) for value in candidate_list) if ip is not None),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
def is_yookassa_ip_allowed(ip_object: IPAddress) -> bool:
|
||||
return any(ip_object in network for network in YOOKASSA_ALLOWED_IP_NETWORKS)
|
||||
|
||||
|
||||
class YooKassaWebhookHandler:
|
||||
|
||||
@staticmethod
|
||||
def verify_webhook_signature(body: str, signature: str, secret: str) -> bool:
|
||||
try:
|
||||
signature_parts = signature.strip().split(' ')
|
||||
|
||||
if len(signature_parts) < 4:
|
||||
logger.error(f"Неверный формат подписи YooKassa: {signature}")
|
||||
return False
|
||||
|
||||
version = signature_parts[0]
|
||||
payment_id = signature_parts[1]
|
||||
timestamp = signature_parts[2]
|
||||
received_signature = signature_parts[3]
|
||||
|
||||
if version != "v1":
|
||||
logger.error(f"Неподдерживаемая версия подписи: {version}")
|
||||
return False
|
||||
|
||||
logger.info(f"Проверка подписи v1 для платежа {payment_id}, timestamp: {timestamp}")
|
||||
|
||||
|
||||
expected_signature_1 = hmac.new(
|
||||
secret.encode('utf-8'),
|
||||
body.encode('utf-8'),
|
||||
hashlib.sha256
|
||||
).digest()
|
||||
expected_signature_1_b64 = base64.b64encode(expected_signature_1).decode('utf-8')
|
||||
|
||||
signed_payload_2 = f"{payment_id}.{timestamp}.{body}"
|
||||
expected_signature_2 = hmac.new(
|
||||
secret.encode('utf-8'),
|
||||
signed_payload_2.encode('utf-8'),
|
||||
hashlib.sha256
|
||||
).digest()
|
||||
expected_signature_2_b64 = base64.b64encode(expected_signature_2).decode('utf-8')
|
||||
|
||||
signed_payload_3 = f"{timestamp}.{body}"
|
||||
expected_signature_3 = hmac.new(
|
||||
secret.encode('utf-8'),
|
||||
signed_payload_3.encode('utf-8'),
|
||||
hashlib.sha256
|
||||
).digest()
|
||||
expected_signature_3_b64 = base64.b64encode(expected_signature_3).decode('utf-8')
|
||||
|
||||
logger.debug(f"Получена подпись: {received_signature}")
|
||||
logger.debug(f"Ожидаемая подпись (вариант 1): {expected_signature_1_b64}")
|
||||
logger.debug(f"Ожидаемая подпись (вариант 2): {expected_signature_2_b64}")
|
||||
logger.debug(f"Ожидаемая подпись (вариант 3): {expected_signature_3_b64}")
|
||||
|
||||
is_valid = (
|
||||
hmac.compare_digest(received_signature, expected_signature_1_b64) or
|
||||
hmac.compare_digest(received_signature, expected_signature_2_b64) or
|
||||
hmac.compare_digest(received_signature, expected_signature_3_b64)
|
||||
)
|
||||
|
||||
if is_valid:
|
||||
logger.info("✅ Подпись YooKassa webhook проверена успешно")
|
||||
else:
|
||||
logger.warning("⚠️ Подпись YooKassa webhook не совпадает ни с одним вариантом")
|
||||
|
||||
return is_valid
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка проверки подписи YooKassa: {e}")
|
||||
return False
|
||||
|
||||
def __init__(self, payment_service: PaymentService):
|
||||
self.payment_service = payment_service
|
||||
|
||||
|
||||
async def handle_webhook(self, request: web.Request) -> web.Response:
|
||||
|
||||
|
||||
try:
|
||||
logger.info(f"📥 Получен YooKassa webhook: {request.method} {request.path}")
|
||||
logger.info(f"📋 Headers: {dict(request.headers)}")
|
||||
|
||||
|
||||
header_ip_candidates = collect_yookassa_ip_candidates(
|
||||
request.headers.get("X-Forwarded-For"),
|
||||
request.headers.get("X-Real-IP"),
|
||||
request.headers.get("Cf-Connecting-Ip"),
|
||||
)
|
||||
client_ip = resolve_yookassa_ip(
|
||||
header_ip_candidates,
|
||||
remote=request.remote,
|
||||
)
|
||||
|
||||
if client_ip is None:
|
||||
logger.warning(
|
||||
"🚫 Не удалось определить IP-адрес отправителя YooKassa webhook. Кандидаты: %s",
|
||||
header_ip_candidates + ([request.remote] if request.remote else []),
|
||||
)
|
||||
return web.Response(status=403, text="Forbidden")
|
||||
|
||||
if not is_yookassa_ip_allowed(client_ip):
|
||||
logger.warning(
|
||||
"🚫 YooKassa webhook отклонён: IP %s не входит в доверенные диапазоны (%s)",
|
||||
client_ip,
|
||||
", ".join(str(network) for network in YOOKASSA_ALLOWED_IP_NETWORKS),
|
||||
)
|
||||
return web.Response(status=403, text="Forbidden")
|
||||
|
||||
logger.info("🌐 IP-адрес YooKassa подтверждён: %s", client_ip)
|
||||
|
||||
body = await request.text()
|
||||
|
||||
|
||||
if not body:
|
||||
logger.warning("⚠️ Получен пустой webhook от YooKassa")
|
||||
return web.Response(status=400, text="Empty body")
|
||||
|
||||
|
||||
logger.info(f"📄 Body: {body}")
|
||||
|
||||
|
||||
signature = request.headers.get('Signature') or request.headers.get('X-YooKassa-Signature')
|
||||
|
||||
if settings.YOOKASSA_WEBHOOK_SECRET and signature:
|
||||
logger.info(f"🔐 Получена подпись: {signature}")
|
||||
|
||||
if not YooKassaWebhookHandler.verify_webhook_signature(body, signature, settings.YOOKASSA_WEBHOOK_SECRET):
|
||||
logger.warning("❌ Подпись не совпала, но продолжаем обработку (режим отладки)")
|
||||
else:
|
||||
logger.info("✅ Подпись webhook проверена успешно")
|
||||
|
||||
elif settings.YOOKASSA_WEBHOOK_SECRET and not signature:
|
||||
logger.warning("⚠️ Webhook без подписи, но секрет настроен")
|
||||
|
||||
elif signature and not settings.YOOKASSA_WEBHOOK_SECRET:
|
||||
logger.info("ℹ️ Подпись получена, но проверка отключена (YOOKASSA_WEBHOOK_SECRET не настроен)")
|
||||
|
||||
else:
|
||||
logger.info("ℹ️ Проверка подписи отключена")
|
||||
|
||||
if signature:
|
||||
logger.info("ℹ️ Получена подпись YooKassa: %s", signature)
|
||||
|
||||
try:
|
||||
webhook_data = json.loads(body)
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"❌ Ошибка парсинга JSON webhook YooKassa: {e}")
|
||||
return web.Response(status=400, text="Invalid JSON")
|
||||
|
||||
|
||||
logger.info(f"📊 Обработка webhook YooKassa: {webhook_data.get('event', 'unknown_event')}")
|
||||
logger.debug(f"🔍 Полные данные webhook: {webhook_data}")
|
||||
|
||||
|
||||
# Извлекаем ID платежа из вебхука для предотвращения дублирования
|
||||
yookassa_payment_id = webhook_data.get("object", {}).get("id")
|
||||
if not yookassa_payment_id:
|
||||
logger.warning("⚠️ Webhook YooKassa без ID платежа")
|
||||
return web.Response(status=400, text="No payment ID")
|
||||
|
||||
event_type = webhook_data.get("event")
|
||||
if not event_type:
|
||||
logger.warning("⚠️ Webhook YooKassa без типа события")
|
||||
return web.Response(status=400, text="No event type")
|
||||
|
||||
if event_type not in ["payment.succeeded", "payment.waiting_for_capture"]:
|
||||
|
||||
if event_type not in YOOKASSA_ALLOWED_EVENTS:
|
||||
logger.info(f"ℹ️ Игнорируем событие YooKassa: {event_type}")
|
||||
return web.Response(status=200, text="OK")
|
||||
|
||||
|
||||
async for db in get_db():
|
||||
try:
|
||||
success = await self.payment_service.process_yookassa_webhook(db, webhook_data)
|
||||
# Проверяем, не обрабатывается ли этот платеж уже (защита от дублирования)
|
||||
from app.database.models import PaymentMethod
|
||||
from app.database.crud.transaction import get_transaction_by_external_id
|
||||
existing_transaction = await get_transaction_by_external_id(db, yookassa_payment_id, PaymentMethod.YOOKASSA)
|
||||
|
||||
if existing_transaction and event_type == "payment.succeeded":
|
||||
logger.info(f"ℹ️ Платеж YooKassa {yookassa_payment_id} уже был обработан. Пропускаем дублирующий вебхук.")
|
||||
return web.Response(status=200, text="OK")
|
||||
|
||||
success = await self.payment_service.process_yookassa_webhook(db, webhook_data)
|
||||
|
||||
if success:
|
||||
logger.info(f"✅ Успешно обработан webhook YooKassa: {event_type}")
|
||||
logger.info(f"✅ Успешно обработан webhook YooKassa: {event_type} для платежа {yookassa_payment_id}")
|
||||
return web.Response(status=200, text="OK")
|
||||
else:
|
||||
logger.error(f"❌ Ошибка обработки webhook YooKassa: {event_type}")
|
||||
logger.error(f"❌ Ошибка обработки webhook YooKassa: {event_type} для платежа {yookassa_payment_id}")
|
||||
return web.Response(status=500, text="Processing error")
|
||||
|
||||
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Критическая ошибка обработки webhook YooKassa: {e}", exc_info=True)
|
||||
return web.Response(status=500, text="Internal server error")
|
||||
|
||||
|
||||
def setup_routes(self, app: web.Application) -> None:
|
||||
|
||||
|
||||
webhook_path = settings.YOOKASSA_WEBHOOK_PATH
|
||||
app.router.add_post(webhook_path, self.handle_webhook)
|
||||
app.router.add_get(webhook_path, self._get_handler)
|
||||
app.router.add_options(webhook_path, self._options_handler)
|
||||
|
||||
app.router.add_get(webhook_path, self._get_handler)
|
||||
app.router.add_options(webhook_path, self._options_handler)
|
||||
|
||||
logger.info(f"✅ Настроен YooKassa webhook на пути: POST {webhook_path}")
|
||||
|
||||
|
||||
async def _get_handler(self, request: web.Request) -> web.Response:
|
||||
return web.json_response({
|
||||
"status": "ok",
|
||||
@@ -172,7 +313,7 @@ class YooKassaWebhookHandler:
|
||||
"path": request.path,
|
||||
"note": "Use POST method for actual webhooks"
|
||||
})
|
||||
|
||||
|
||||
async def _options_handler(self, request: web.Request) -> web.Response:
|
||||
return web.Response(
|
||||
status=200,
|
||||
@@ -185,49 +326,58 @@ class YooKassaWebhookHandler:
|
||||
|
||||
|
||||
def create_yookassa_webhook_app(payment_service: PaymentService) -> web.Application:
|
||||
|
||||
|
||||
app = web.Application()
|
||||
|
||||
|
||||
webhook_handler = YooKassaWebhookHandler(payment_service)
|
||||
webhook_handler.setup_routes(app)
|
||||
|
||||
|
||||
async def health_check(request):
|
||||
return web.json_response({
|
||||
"status": "ok",
|
||||
"status": "ok",
|
||||
"service": "yookassa_webhook",
|
||||
"port": settings.YOOKASSA_WEBHOOK_PORT,
|
||||
"path": settings.YOOKASSA_WEBHOOK_PATH,
|
||||
"enabled": settings.is_yookassa_enabled()
|
||||
})
|
||||
|
||||
|
||||
app.router.add_get("/health", health_check)
|
||||
|
||||
|
||||
return app
|
||||
|
||||
|
||||
async def start_yookassa_webhook_server(payment_service: PaymentService) -> None:
|
||||
|
||||
|
||||
if not settings.is_yookassa_enabled():
|
||||
logger.info("ℹ️ YooKassa отключена, webhook сервер не запускается")
|
||||
return
|
||||
|
||||
|
||||
try:
|
||||
app = create_yookassa_webhook_app(payment_service)
|
||||
|
||||
|
||||
runner = web.AppRunner(app)
|
||||
await runner.setup()
|
||||
|
||||
|
||||
site = web.TCPSite(
|
||||
runner,
|
||||
host='0.0.0.0',
|
||||
runner,
|
||||
host=settings.YOOKASSA_WEBHOOK_HOST,
|
||||
port=settings.YOOKASSA_WEBHOOK_PORT
|
||||
)
|
||||
|
||||
|
||||
await site.start()
|
||||
|
||||
logger.info(f"✅ YooKassa webhook сервер запущен на порту {settings.YOOKASSA_WEBHOOK_PORT}")
|
||||
logger.info(f"🎯 YooKassa webhook URL: http://0.0.0.0:{settings.YOOKASSA_WEBHOOK_PORT}{settings.YOOKASSA_WEBHOOK_PATH}")
|
||||
|
||||
|
||||
logger.info(
|
||||
"✅ YooKassa webhook сервер запущен на %s:%s",
|
||||
settings.YOOKASSA_WEBHOOK_HOST,
|
||||
settings.YOOKASSA_WEBHOOK_PORT,
|
||||
)
|
||||
logger.info(
|
||||
"🎯 YooKassa webhook URL: http://%s:%s%s",
|
||||
settings.YOOKASSA_WEBHOOK_HOST,
|
||||
settings.YOOKASSA_WEBHOOK_PORT,
|
||||
settings.YOOKASSA_WEBHOOK_PATH,
|
||||
)
|
||||
|
||||
try:
|
||||
while True:
|
||||
await asyncio.sleep(1)
|
||||
@@ -237,7 +387,7 @@ async def start_yookassa_webhook_server(payment_service: PaymentService) -> None
|
||||
await site.stop()
|
||||
await runner.cleanup()
|
||||
logger.info("✅ YooKassa webhook сервер остановлен")
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка запуска YooKassa webhook сервера: {e}", exc_info=True)
|
||||
raise
|
||||
raise
|
||||
@@ -0,0 +1,3 @@
|
||||
from . import balance
|
||||
|
||||
__all__ = ["balance"]
|
||||
@@ -0,0 +1,647 @@
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.services.backup_service import backup_service
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BackupStates(StatesGroup):
|
||||
waiting_backup_file = State()
|
||||
waiting_settings_update = State()
|
||||
|
||||
|
||||
def get_backup_main_keyboard(language: str = "ru"):
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="🚀 Создать бекап", callback_data="backup_create"),
|
||||
InlineKeyboardButton(text="📥 Восстановить", callback_data="backup_restore")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="📋 Список бекапов", callback_data="backup_list"),
|
||||
InlineKeyboardButton(text="⚙️ Настройки", callback_data="backup_settings")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="◀️ Назад", callback_data="admin_panel")
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_backup_list_keyboard(backups: list, page: int = 1, per_page: int = 5):
|
||||
keyboard = []
|
||||
|
||||
start_idx = (page - 1) * per_page
|
||||
end_idx = start_idx + per_page
|
||||
page_backups = backups[start_idx:end_idx]
|
||||
|
||||
for backup in page_backups:
|
||||
try:
|
||||
if backup.get("timestamp"):
|
||||
dt = datetime.fromisoformat(backup["timestamp"].replace('Z', '+00:00'))
|
||||
date_str = dt.strftime("%d.%m %H:%M")
|
||||
else:
|
||||
date_str = "?"
|
||||
except:
|
||||
date_str = "?"
|
||||
|
||||
size_str = f"{backup.get('file_size_mb', 0):.1f}MB"
|
||||
records_str = backup.get('total_records', '?')
|
||||
|
||||
button_text = f"📦 {date_str} • {size_str} • {records_str} записей"
|
||||
callback_data = f"backup_manage_{backup['filename']}"
|
||||
|
||||
keyboard.append([InlineKeyboardButton(text=button_text, callback_data=callback_data)])
|
||||
|
||||
if len(backups) > per_page:
|
||||
total_pages = (len(backups) + per_page - 1) // per_page
|
||||
nav_row = []
|
||||
|
||||
if page > 1:
|
||||
nav_row.append(InlineKeyboardButton(text="⬅️", callback_data=f"backup_list_page_{page-1}"))
|
||||
|
||||
nav_row.append(InlineKeyboardButton(text=f"{page}/{total_pages}", callback_data="noop"))
|
||||
|
||||
if page < total_pages:
|
||||
nav_row.append(InlineKeyboardButton(text="➡️", callback_data=f"backup_list_page_{page+1}"))
|
||||
|
||||
keyboard.append(nav_row)
|
||||
|
||||
keyboard.extend([
|
||||
[InlineKeyboardButton(text="◀️ Назад", callback_data="backup_panel")]
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
def get_backup_manage_keyboard(backup_filename: str):
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="📥 Восстановить", callback_data=f"backup_restore_file_{backup_filename}")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="🗑️ Удалить", callback_data=f"backup_delete_{backup_filename}")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="◀️ К списку", callback_data="backup_list")
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_backup_settings_keyboard(settings_obj):
|
||||
auto_status = "✅ Включены" if settings_obj.auto_backup_enabled else "❌ Отключены"
|
||||
compression_status = "✅ Включено" if settings_obj.compression_enabled else "❌ Отключено"
|
||||
logs_status = "✅ Включены" if settings_obj.include_logs else "❌ Отключены"
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=f"🔄 Автобекапы: {auto_status}",
|
||||
callback_data="backup_toggle_auto"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=f"🗜️ Сжатие: {compression_status}",
|
||||
callback_data="backup_toggle_compression"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=f"📋 Логи в бекапе: {logs_status}",
|
||||
callback_data="backup_toggle_logs"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="◀️ Назад", callback_data="backup_panel")
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_backup_panel(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
settings_obj = await backup_service.get_backup_settings()
|
||||
|
||||
status_auto = "✅ Включены" if settings_obj.auto_backup_enabled else "❌ Отключены"
|
||||
|
||||
text = f"""🗄️ <b>СИСТЕМА БЕКАПОВ</b>
|
||||
|
||||
📊 <b>Статус:</b>
|
||||
• Автобекапы: {status_auto}
|
||||
• Интервал: {settings_obj.backup_interval_hours} часов
|
||||
• Хранить: {settings_obj.max_backups_keep} файлов
|
||||
• Сжатие: {'Да' if settings_obj.compression_enabled else 'Нет'}
|
||||
|
||||
📁 <b>Расположение:</b> <code>/app/data/backups</code>
|
||||
|
||||
⚡ <b>Доступные операции:</b>
|
||||
• Создание полного бекапа всех данных
|
||||
• Восстановление из файла бекапа
|
||||
• Управление автоматическими бекапами
|
||||
"""
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
parse_mode="HTML",
|
||||
reply_markup=get_backup_main_keyboard(db_user.language)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def create_backup_handler(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
await callback.answer("🔄 Создание бекапа запущено...")
|
||||
|
||||
progress_msg = await callback.message.edit_text(
|
||||
"🔄 <b>Создание бекапа...</b>\n\n"
|
||||
"⏳ Экспортируем данные из базы...\n"
|
||||
"Это может занять несколько минут.",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
# Создаем бекап
|
||||
success, message, file_path = await backup_service.create_backup(
|
||||
created_by=db_user.telegram_id,
|
||||
compress=True
|
||||
)
|
||||
|
||||
if success:
|
||||
await progress_msg.edit_text(
|
||||
f"✅ <b>Бекап создан успешно!</b>\n\n{message}",
|
||||
parse_mode="HTML",
|
||||
reply_markup=get_backup_main_keyboard(db_user.language)
|
||||
)
|
||||
else:
|
||||
await progress_msg.edit_text(
|
||||
f"❌ <b>Ошибка создания бекапа</b>\n\n{message}",
|
||||
parse_mode="HTML",
|
||||
reply_markup=get_backup_main_keyboard(db_user.language)
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_backup_list(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
page = 1
|
||||
if callback.data.startswith("backup_list_page_"):
|
||||
try:
|
||||
page = int(callback.data.split("_")[-1])
|
||||
except:
|
||||
page = 1
|
||||
|
||||
backups = await backup_service.get_backup_list()
|
||||
|
||||
if not backups:
|
||||
text = "📦 <b>Список бекапов пуст</b>\n\nБекапы еще не создавались."
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="🚀 Создать первый бекап", callback_data="backup_create")],
|
||||
[InlineKeyboardButton(text="◀️ Назад", callback_data="backup_panel")]
|
||||
])
|
||||
else:
|
||||
text = f"📦 <b>Список бекапов</b> (всего: {len(backups)})\n\n"
|
||||
text += "Выберите бекап для управления:"
|
||||
keyboard = get_backup_list_keyboard(backups, page)
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def manage_backup_file(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
filename = callback.data.replace("backup_manage_", "")
|
||||
|
||||
backups = await backup_service.get_backup_list()
|
||||
backup_info = None
|
||||
|
||||
for backup in backups:
|
||||
if backup["filename"] == filename:
|
||||
backup_info = backup
|
||||
break
|
||||
|
||||
if not backup_info:
|
||||
await callback.answer("❌ Файл бекапа не найден", show_alert=True)
|
||||
return
|
||||
|
||||
try:
|
||||
if backup_info.get("timestamp"):
|
||||
dt = datetime.fromisoformat(backup_info["timestamp"].replace('Z', '+00:00'))
|
||||
date_str = dt.strftime("%d.%m.%Y %H:%M:%S")
|
||||
else:
|
||||
date_str = "Неизвестно"
|
||||
except:
|
||||
date_str = "Ошибка формата даты"
|
||||
|
||||
text = f"""📦 <b>Информация о бекапе</b>
|
||||
|
||||
📄 <b>Файл:</b> <code>{filename}</code>
|
||||
📅 <b>Создан:</b> {date_str}
|
||||
💾 <b>Размер:</b> {backup_info.get('file_size_mb', 0):.2f} MB
|
||||
📊 <b>Таблиц:</b> {backup_info.get('tables_count', '?')}
|
||||
📈 <b>Записей:</b> {backup_info.get('total_records', '?'):,}
|
||||
🗜️ <b>Сжатие:</b> {'Да' if backup_info.get('compressed') else 'Нет'}
|
||||
🗄️ <b>БД:</b> {backup_info.get('database_type', 'unknown')}
|
||||
"""
|
||||
|
||||
if backup_info.get("error"):
|
||||
text += f"\n⚠️ <b>Ошибка:</b> {backup_info['error']}"
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
parse_mode="HTML",
|
||||
reply_markup=get_backup_manage_keyboard(filename)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def delete_backup_confirm(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
filename = callback.data.replace("backup_delete_", "")
|
||||
|
||||
text = f"🗑️ <b>Удаление бекапа</b>\n\n"
|
||||
text += f"Вы уверены, что хотите удалить бекап?\n\n"
|
||||
text += f"📄 <code>{filename}</code>\n\n"
|
||||
text += "⚠️ <b>Это действие нельзя отменить!</b>"
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="✅ Да, удалить", callback_data=f"backup_delete_confirm_{filename}"),
|
||||
InlineKeyboardButton(text="❌ Отмена", callback_data=f"backup_manage_{filename}")
|
||||
]
|
||||
])
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def delete_backup_execute(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
filename = callback.data.replace("backup_delete_confirm_", "")
|
||||
|
||||
success, message = await backup_service.delete_backup(filename)
|
||||
|
||||
if success:
|
||||
await callback.message.edit_text(
|
||||
f"✅ <b>Бекап удален</b>\n\n{message}",
|
||||
parse_mode="HTML",
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="📋 К списку бекапов", callback_data="backup_list")]
|
||||
])
|
||||
)
|
||||
else:
|
||||
await callback.message.edit_text(
|
||||
f"❌ <b>Ошибка удаления</b>\n\n{message}",
|
||||
parse_mode="HTML",
|
||||
reply_markup=get_backup_manage_keyboard(filename)
|
||||
)
|
||||
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def restore_backup_start(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext
|
||||
):
|
||||
if callback.data.startswith("backup_restore_file_"):
|
||||
# Восстановление из конкретного файла
|
||||
filename = callback.data.replace("backup_restore_file_", "")
|
||||
|
||||
text = f"📥 <b>Восстановление из бекапа</b>\n\n"
|
||||
text += f"📄 <b>Файл:</b> <code>{filename}</code>\n\n"
|
||||
text += "⚠️ <b>ВНИМАНИЕ!</b>\n"
|
||||
text += "• Процесс может занять несколько минут\n"
|
||||
text += "• Рекомендуется создать бекап перед восстановлением\n"
|
||||
text += "• Существующие данные будут дополнены\n\n"
|
||||
text += "Продолжить восстановление?"
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="✅ Да, восстановить", callback_data=f"backup_restore_execute_{filename}"),
|
||||
InlineKeyboardButton(text="🗑️ Очистить и восстановить", callback_data=f"backup_restore_clear_{filename}")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="❌ Отмена", callback_data=f"backup_manage_{filename}")
|
||||
]
|
||||
])
|
||||
else:
|
||||
text = """📥 <b>Восстановление из бекапа</b>
|
||||
|
||||
📎 Отправьте файл бекапа (.json или .json.gz)
|
||||
|
||||
⚠️ <b>ВАЖНО:</b>
|
||||
• Файл должен быть создан этой системой бекапов
|
||||
• Процесс может занять несколько минут
|
||||
• Рекомендуется создать бекап перед восстановлением
|
||||
|
||||
💡 Или выберите из существующих бекапов ниже."""
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="📋 Выбрать из списка", callback_data="backup_list")],
|
||||
[InlineKeyboardButton(text="❌ Отмена", callback_data="backup_panel")]
|
||||
])
|
||||
|
||||
await state.set_state(BackupStates.waiting_backup_file)
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def restore_backup_execute(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
if callback.data.startswith("backup_restore_execute_"):
|
||||
filename = callback.data.replace("backup_restore_execute_", "")
|
||||
clear_existing = False
|
||||
elif callback.data.startswith("backup_restore_clear_"):
|
||||
filename = callback.data.replace("backup_restore_clear_", "")
|
||||
clear_existing = True
|
||||
else:
|
||||
await callback.answer("❌ Неверный формат команды", show_alert=True)
|
||||
return
|
||||
|
||||
await callback.answer("🔄 Восстановление запущено...")
|
||||
|
||||
# Показываем прогресс
|
||||
action_text = "очисткой и восстановлением" if clear_existing else "восстановлением"
|
||||
progress_msg = await callback.message.edit_text(
|
||||
f"📥 <b>Восстановление из бекапа...</b>\n\n"
|
||||
f"⏳ Работаем с {action_text} данных...\n"
|
||||
f"📄 Файл: <code>{filename}</code>\n\n"
|
||||
f"Это может занять несколько минут.",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
backup_path = backup_service.backup_dir / filename
|
||||
|
||||
success, message = await backup_service.restore_backup(
|
||||
str(backup_path),
|
||||
clear_existing=clear_existing
|
||||
)
|
||||
|
||||
if success:
|
||||
await progress_msg.edit_text(
|
||||
f"✅ <b>Восстановление завершено!</b>\n\n{message}",
|
||||
parse_mode="HTML",
|
||||
reply_markup=get_backup_main_keyboard(db_user.language)
|
||||
)
|
||||
else:
|
||||
await progress_msg.edit_text(
|
||||
f"❌ <b>Ошибка восстановления</b>\n\n{message}",
|
||||
parse_mode="HTML",
|
||||
reply_markup=get_backup_manage_keyboard(filename)
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def handle_backup_file_upload(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext
|
||||
):
|
||||
if not message.document:
|
||||
await message.answer(
|
||||
"❌ Пожалуйста, отправьте файл бекапа (.json или .json.gz)",
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="◀️ Отмена", callback_data="backup_panel")]
|
||||
])
|
||||
)
|
||||
return
|
||||
|
||||
document = message.document
|
||||
|
||||
if not (document.file_name.endswith('.json') or document.file_name.endswith('.json.gz')):
|
||||
await message.answer(
|
||||
"❌ Неподдерживаемый формат файла. Загрузите .json или .json.gz файл",
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="◀️ Отмена", callback_data="backup_panel")]
|
||||
])
|
||||
)
|
||||
return
|
||||
|
||||
if document.file_size > 50 * 1024 * 1024:
|
||||
await message.answer(
|
||||
"❌ Файл слишком большой (максимум 50MB)",
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="◀️ Отмена", callback_data="backup_panel")]
|
||||
])
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
file = await message.bot.get_file(document.file_id)
|
||||
|
||||
temp_path = backup_service.backup_dir / f"uploaded_{document.file_name}"
|
||||
|
||||
await message.bot.download_file(file.file_path, temp_path)
|
||||
|
||||
text = f"""📥 <b>Файл загружен</b>
|
||||
|
||||
📄 <b>Имя:</b> <code>{document.file_name}</code>
|
||||
💾 <b>Размер:</b> {document.file_size / 1024 / 1024:.2f} MB
|
||||
|
||||
⚠️ <b>ВНИМАНИЕ!</b>
|
||||
Процесс восстановления изменит данные в базе.
|
||||
Рекомендуется создать бекап перед восстановлением.
|
||||
|
||||
Продолжить?"""
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="✅ Восстановить", callback_data=f"backup_restore_uploaded_{temp_path.name}"),
|
||||
InlineKeyboardButton(text="🗑️ Очистить и восстановить", callback_data=f"backup_restore_uploaded_clear_{temp_path.name}")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="❌ Отмена", callback_data="backup_panel")
|
||||
]
|
||||
])
|
||||
|
||||
await message.answer(text, parse_mode="HTML", reply_markup=keyboard)
|
||||
await state.clear()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка загрузки файла бекапа: {e}")
|
||||
await message.answer(
|
||||
f"❌ Ошибка загрузки файла: {str(e)}",
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="◀️ Отмена", callback_data="backup_panel")]
|
||||
])
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_backup_settings(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
settings_obj = await backup_service.get_backup_settings()
|
||||
|
||||
text = f"""⚙️ <b>Настройки системы бекапов</b>
|
||||
|
||||
🔄 <b>Автоматические бекапы:</b>
|
||||
• Статус: {'✅ Включены' if settings_obj.auto_backup_enabled else '❌ Отключены'}
|
||||
• Интервал: {settings_obj.backup_interval_hours} часов
|
||||
• Время запуска: {settings_obj.backup_time}
|
||||
|
||||
📦 <b>Хранение:</b>
|
||||
• Максимум файлов: {settings_obj.max_backups_keep}
|
||||
• Сжатие: {'✅ Включено' if settings_obj.compression_enabled else '❌ Отключено'}
|
||||
• Включать логи: {'✅ Да' if settings_obj.include_logs else '❌ Нет'}
|
||||
|
||||
📁 <b>Расположение:</b> <code>{settings_obj.backup_location}</code>
|
||||
"""
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
parse_mode="HTML",
|
||||
reply_markup=get_backup_settings_keyboard(settings_obj)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def toggle_backup_setting(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
settings_obj = await backup_service.get_backup_settings()
|
||||
|
||||
if callback.data == "backup_toggle_auto":
|
||||
new_value = not settings_obj.auto_backup_enabled
|
||||
await backup_service.update_backup_settings(auto_backup_enabled=new_value)
|
||||
status = "включены" if new_value else "отключены"
|
||||
await callback.answer(f"Автобекапы {status}")
|
||||
|
||||
elif callback.data == "backup_toggle_compression":
|
||||
new_value = not settings_obj.compression_enabled
|
||||
await backup_service.update_backup_settings(compression_enabled=new_value)
|
||||
status = "включено" if new_value else "отключено"
|
||||
await callback.answer(f"Сжатие {status}")
|
||||
|
||||
elif callback.data == "backup_toggle_logs":
|
||||
new_value = not settings_obj.include_logs
|
||||
await backup_service.update_backup_settings(include_logs=new_value)
|
||||
status = "включены" if new_value else "отключены"
|
||||
await callback.answer(f"Логи в бекапе {status}")
|
||||
|
||||
await show_backup_settings(callback, db_user, db)
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
|
||||
dp.callback_query.register(
|
||||
show_backup_panel,
|
||||
F.data == "backup_panel"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
create_backup_handler,
|
||||
F.data == "backup_create"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_backup_list,
|
||||
F.data.startswith("backup_list")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
manage_backup_file,
|
||||
F.data.startswith("backup_manage_")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
delete_backup_confirm,
|
||||
F.data.startswith("backup_delete_") & ~F.data.startswith("backup_delete_confirm_")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
delete_backup_execute,
|
||||
F.data.startswith("backup_delete_confirm_")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
restore_backup_start,
|
||||
F.data.in_(["backup_restore"]) | F.data.startswith("backup_restore_file_")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
restore_backup_execute,
|
||||
F.data.startswith("backup_restore_execute_") | F.data.startswith("backup_restore_clear_")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_backup_settings,
|
||||
F.data == "backup_settings"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
toggle_backup_setting,
|
||||
F.data.in_(["backup_toggle_auto", "backup_toggle_compression", "backup_toggle_logs"])
|
||||
)
|
||||
|
||||
dp.message.register(
|
||||
handle_backup_file_upload,
|
||||
BackupStates.waiting_backup_file
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+418
-3
@@ -1,12 +1,27 @@
|
||||
import logging
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.filters import Command
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.keyboards.admin import get_admin_main_keyboard
|
||||
from app.keyboards.admin import (
|
||||
get_admin_main_keyboard,
|
||||
get_admin_users_submenu_keyboard,
|
||||
get_admin_promo_submenu_keyboard,
|
||||
get_admin_communications_submenu_keyboard,
|
||||
get_admin_support_submenu_keyboard,
|
||||
get_admin_settings_submenu_keyboard,
|
||||
get_admin_system_submenu_keyboard
|
||||
)
|
||||
from app.localization.texts import get_texts
|
||||
from app.handlers.admin import support_settings as support_settings_handlers
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
from app.services.support_settings_service import SupportSettingsService
|
||||
from app.database.crud.rules import clear_all_rules, get_rules_statistics
|
||||
from app.localization.texts import clear_rules_cache
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
from app.database.crud.ticket import TicketCRUD
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -21,6 +36,25 @@ async def show_admin_panel(
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
admin_text = texts.ADMIN_PANEL
|
||||
try:
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
remnawave_service = RemnaWaveService()
|
||||
stats = await remnawave_service.get_system_statistics()
|
||||
system_stats = stats.get("system", {})
|
||||
users_online = system_stats.get("users_online", 0)
|
||||
users_today = system_stats.get("users_last_day", 0)
|
||||
users_week = system_stats.get("users_last_week", 0)
|
||||
admin_text = admin_text.replace(
|
||||
"\n\nВыберите раздел для управления:",
|
||||
(
|
||||
f"\n\n- 🟢 Онлайн сейчас: {users_online}"
|
||||
f"\n- 📅 Онлайн сегодня: {users_today}"
|
||||
f"\n- 🗓️ На этой неделе: {users_week}"
|
||||
"\n\nВыберите раздел для управления:"
|
||||
),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Не удалось получить статистику Remnawave для админ-панели: {e}")
|
||||
|
||||
await callback.message.edit_text(
|
||||
admin_text,
|
||||
@@ -29,9 +63,390 @@ async def show_admin_panel(
|
||||
await callback.answer()
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_users_submenu(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.t("ADMIN_USERS_SUBMENU_TITLE", "👥 **Управление пользователями и подписками**\n\n") +
|
||||
texts.t("ADMIN_SUBMENU_SELECT_SECTION", "Выберите нужный раздел:"),
|
||||
reply_markup=get_admin_users_submenu_keyboard(db_user.language),
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_promo_submenu(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.t("ADMIN_PROMO_SUBMENU_TITLE", "💰 **Промокоды и статистика**\n\n") +
|
||||
texts.t("ADMIN_SUBMENU_SELECT_SECTION", "Выберите нужный раздел:"),
|
||||
reply_markup=get_admin_promo_submenu_keyboard(db_user.language),
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_communications_submenu(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.t("ADMIN_COMMUNICATIONS_SUBMENU_TITLE", "📨 **Коммуникации**\n\n") +
|
||||
texts.t("ADMIN_COMMUNICATIONS_SUBMENU_DESCRIPTION", "Управление рассылками и текстами интерфейса:"),
|
||||
reply_markup=get_admin_communications_submenu_keyboard(db_user.language),
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_support_submenu(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
# Moderators have access only to tickets and not to settings
|
||||
is_moderator_only = (not settings.is_admin(callback.from_user.id) and SupportSettingsService.is_moderator(callback.from_user.id))
|
||||
|
||||
kb = get_admin_support_submenu_keyboard(db_user.language)
|
||||
if is_moderator_only:
|
||||
# Rebuild keyboard to include only tickets and back to main menu
|
||||
kb = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text=texts.t("ADMIN_SUPPORT_TICKETS", "🎫 Тикеты поддержки"), callback_data="admin_tickets")],
|
||||
[InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")]
|
||||
])
|
||||
await callback.message.edit_text(
|
||||
texts.t("ADMIN_SUPPORT_SUBMENU_TITLE", "🛟 **Поддержка**\n\n") + (
|
||||
texts.t("ADMIN_SUPPORT_SUBMENU_DESCRIPTION_MODERATOR", "Доступ к тикетам.")
|
||||
if is_moderator_only
|
||||
else texts.t("ADMIN_SUPPORT_SUBMENU_DESCRIPTION", "Управление тикетами и настройками поддержки:")
|
||||
),
|
||||
reply_markup=kb,
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
# Moderator panel entry (from main menu quick button)
|
||||
async def show_moderator_panel(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
kb = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text=texts.t("ADMIN_SUPPORT_TICKETS", "🎫 Тикеты поддержки"), callback_data="admin_tickets")],
|
||||
[InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), callback_data="back_to_menu")]
|
||||
])
|
||||
await callback.message.edit_text(
|
||||
texts.t("ADMIN_SUPPORT_MODERATION_TITLE", "🧑⚖️ <b>Модерация поддержки</b>") + "\n\n" +
|
||||
texts.t("ADMIN_SUPPORT_MODERATION_DESCRIPTION", "Доступ к тикетам поддержки."),
|
||||
parse_mode="HTML",
|
||||
reply_markup=kb
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_support_audit(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
# pagination
|
||||
page = 1
|
||||
if callback.data.startswith("admin_support_audit_page_"):
|
||||
try:
|
||||
page = int(callback.data.split("_")[-1])
|
||||
except Exception:
|
||||
page = 1
|
||||
per_page = 10
|
||||
total = await TicketCRUD.count_support_audit(db)
|
||||
total_pages = max(1, (total + per_page - 1) // per_page)
|
||||
if page < 1:
|
||||
page = 1
|
||||
if page > total_pages:
|
||||
page = total_pages
|
||||
offset = (page - 1) * per_page
|
||||
logs = await TicketCRUD.list_support_audit(db, limit=per_page, offset=offset)
|
||||
|
||||
lines = [texts.t("ADMIN_SUPPORT_AUDIT_TITLE", "🧾 <b>Аудит модераторов</b>"), ""]
|
||||
if not logs:
|
||||
lines.append(texts.t("ADMIN_SUPPORT_AUDIT_EMPTY", "Пока пусто"))
|
||||
else:
|
||||
for log in logs:
|
||||
role = (
|
||||
texts.t("ADMIN_SUPPORT_AUDIT_ROLE_MODERATOR", "Модератор")
|
||||
if getattr(log, 'is_moderator', False)
|
||||
else texts.t("ADMIN_SUPPORT_AUDIT_ROLE_ADMIN", "Админ")
|
||||
)
|
||||
ts = log.created_at.strftime('%d.%m.%Y %H:%M') if getattr(log, 'created_at', None) else ''
|
||||
action_map = {
|
||||
'close_ticket': texts.t("ADMIN_SUPPORT_AUDIT_ACTION_CLOSE_TICKET", "Закрытие тикета"),
|
||||
'block_user_timed': texts.t("ADMIN_SUPPORT_AUDIT_ACTION_BLOCK_TIMED", "Блокировка (время)"),
|
||||
'block_user_perm': texts.t("ADMIN_SUPPORT_AUDIT_ACTION_BLOCK_PERM", "Блокировка (навсегда)"),
|
||||
'close_all_tickets': texts.t("ADMIN_SUPPORT_AUDIT_ACTION_CLOSE_ALL_TICKETS", "Массовое закрытие тикетов"),
|
||||
'unblock_user': texts.t("ADMIN_SUPPORT_AUDIT_ACTION_UNBLOCK", "Снятие блока"),
|
||||
}
|
||||
action_text = action_map.get(log.action, log.action)
|
||||
ticket_part = f" тикет #{log.ticket_id}" if log.ticket_id else ""
|
||||
details = log.details or {}
|
||||
extra = ""
|
||||
if log.action == 'block_user_timed' and 'minutes' in details:
|
||||
extra = f" ({details['minutes']} мин)"
|
||||
elif log.action == 'close_all_tickets' and 'count' in details:
|
||||
extra = f" ({details['count']})"
|
||||
lines.append(f"{ts} • {role} <code>{log.actor_telegram_id}</code> — {action_text}{ticket_part}{extra}")
|
||||
|
||||
# keyboard with pagination
|
||||
nav_row = []
|
||||
if total_pages > 1:
|
||||
if page > 1:
|
||||
nav_row.append(InlineKeyboardButton(text="⬅️", callback_data=f"admin_support_audit_page_{page-1}"))
|
||||
nav_row.append(InlineKeyboardButton(text=f"{page}/{total_pages}", callback_data="current_page"))
|
||||
if page < total_pages:
|
||||
nav_row.append(InlineKeyboardButton(text="➡️", callback_data=f"admin_support_audit_page_{page+1}"))
|
||||
|
||||
kb_rows = []
|
||||
if nav_row:
|
||||
kb_rows.append(nav_row)
|
||||
kb_rows.append([InlineKeyboardButton(text=texts.BACK, callback_data="admin_submenu_support")])
|
||||
kb = InlineKeyboardMarkup(inline_keyboard=kb_rows)
|
||||
|
||||
await callback.message.edit_text("\n".join(lines), parse_mode="HTML", reply_markup=kb)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_settings_submenu(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.t("ADMIN_SETTINGS_SUBMENU_TITLE", "⚙️ **Настройки системы**\n\n") +
|
||||
texts.t("ADMIN_SETTINGS_SUBMENU_DESCRIPTION", "Управление Remnawave, мониторингом и другими настройками:"),
|
||||
reply_markup=get_admin_settings_submenu_keyboard(db_user.language),
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_system_submenu(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.t("ADMIN_SYSTEM_SUBMENU_TITLE", "🛠️ **Системные функции**\n\n") +
|
||||
texts.t("ADMIN_SYSTEM_SUBMENU_DESCRIPTION", "Отчеты, обновления, логи, резервные копии и системные операции:"),
|
||||
reply_markup=get_admin_system_submenu_keyboard(db_user.language),
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def clear_rules_command(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
try:
|
||||
stats = await get_rules_statistics(db)
|
||||
|
||||
if stats['total_active'] == 0:
|
||||
await message.reply(
|
||||
"ℹ️ <b>Правила уже очищены</b>\n\n"
|
||||
"В системе нет активных правил. Используются стандартные правила по умолчанию."
|
||||
)
|
||||
return
|
||||
|
||||
success = await clear_all_rules(db, db_user.language)
|
||||
|
||||
if success:
|
||||
clear_rules_cache()
|
||||
|
||||
await message.reply(
|
||||
f"✅ <b>Правила успешно очищены!</b>\n\n"
|
||||
f"📊 <b>Статистика:</b>\n"
|
||||
f"• Очищено правил: {stats['total_active']}\n"
|
||||
f"• Язык: {db_user.language}\n"
|
||||
f"• Выполнил: {db_user.full_name}\n\n"
|
||||
f"Теперь используются стандартные правила по умолчанию."
|
||||
)
|
||||
|
||||
logger.info(f"Правила очищены командой администратором {db_user.telegram_id} ({db_user.full_name})")
|
||||
else:
|
||||
await message.reply(
|
||||
"⚠️ <b>Нет правил для очистки</b>\n\n"
|
||||
"Активные правила не найдены."
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при очистке правил командой: {e}")
|
||||
await message.reply(
|
||||
"❌ <b>Ошибка при очистке правил</b>\n\n"
|
||||
f"Произошла ошибка: {str(e)}\n"
|
||||
"Попробуйте через админ-панель или повторите позже."
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def rules_stats_command(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
try:
|
||||
stats = await get_rules_statistics(db)
|
||||
|
||||
if 'error' in stats:
|
||||
await message.reply(f"❌ Ошибка получения статистики: {stats['error']}")
|
||||
return
|
||||
|
||||
text = f"📊 <b>Статистика правил сервиса</b>\n\n"
|
||||
text += f"📋 <b>Общая информация:</b>\n"
|
||||
text += f"• Активных правил: {stats['total_active']}\n"
|
||||
text += f"• Всего в истории: {stats['total_all_time']}\n"
|
||||
text += f"• Поддерживаемых языков: {stats['total_languages']}\n\n"
|
||||
|
||||
if stats['languages']:
|
||||
text += f"🌐 <b>По языкам:</b>\n"
|
||||
for lang, lang_stats in stats['languages'].items():
|
||||
text += f"• <code>{lang}</code>: {lang_stats['active_count']} правил, "
|
||||
text += f"{lang_stats['content_length']} символов\n"
|
||||
if lang_stats['last_updated']:
|
||||
text += f" Обновлено: {lang_stats['last_updated'].strftime('%d.%m.%Y %H:%M')}\n"
|
||||
else:
|
||||
text += "ℹ️ Активных правил нет - используются правила по умолчанию"
|
||||
|
||||
await message.reply(text)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при получении статистики правил: {e}")
|
||||
await message.reply(
|
||||
f"❌ <b>Ошибка получения статистики</b>\n\n"
|
||||
f"Произошла ошибка: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def admin_commands_help(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
help_text = """
|
||||
🔧 <b>Доступные админские команды:</b>
|
||||
|
||||
<b>📋 Управление правилами:</b>
|
||||
• <code>/clear_rules</code> - очистить все правила
|
||||
• <code>/rules_stats</code> - статистика правил
|
||||
|
||||
<b>ℹ️ Справка:</b>
|
||||
• <code>/admin_help</code> - это сообщение
|
||||
|
||||
<b>📱 Панель управления:</b>
|
||||
Используйте кнопку "Админ панель" в главном меню для полного доступа ко всем функциям.
|
||||
|
||||
<b>⚠️ Важно:</b>
|
||||
Все команды логируются и требуют админских прав.
|
||||
"""
|
||||
|
||||
await message.reply(help_text)
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(
|
||||
show_admin_panel,
|
||||
F.data == "admin_panel"
|
||||
)
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_users_submenu,
|
||||
F.data == "admin_submenu_users"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_promo_submenu,
|
||||
F.data == "admin_submenu_promo"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_communications_submenu,
|
||||
F.data == "admin_submenu_communications"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_support_submenu,
|
||||
F.data == "admin_submenu_support"
|
||||
)
|
||||
dp.callback_query.register(
|
||||
show_support_audit,
|
||||
F.data.in_(["admin_support_audit"]) | F.data.startswith("admin_support_audit_page_")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_settings_submenu,
|
||||
F.data == "admin_submenu_settings"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_system_submenu,
|
||||
F.data == "admin_submenu_system"
|
||||
)
|
||||
dp.callback_query.register(
|
||||
show_moderator_panel,
|
||||
F.data == "moderator_panel"
|
||||
)
|
||||
# Support settings module
|
||||
support_settings_handlers.register_handlers(dp)
|
||||
|
||||
dp.message.register(
|
||||
clear_rules_command,
|
||||
Command("clear_rules")
|
||||
)
|
||||
|
||||
dp.message.register(
|
||||
rules_stats_command,
|
||||
Command("rules_stats")
|
||||
)
|
||||
|
||||
dp.message.register(
|
||||
admin_commands_help,
|
||||
Command("admin_help")
|
||||
)
|
||||
|
||||
@@ -16,6 +16,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
class MaintenanceStates(StatesGroup):
|
||||
waiting_for_reason = State()
|
||||
waiting_for_notification_message = State()
|
||||
|
||||
|
||||
@admin_required
|
||||
@@ -30,6 +31,14 @@ async def show_maintenance_panel(
|
||||
|
||||
status_info = maintenance_service.get_status_info()
|
||||
|
||||
try:
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
rw_service = RemnaWaveService()
|
||||
panel_status = await rw_service.get_panel_status_summary()
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения статуса панели: {e}")
|
||||
panel_status = {"description": "❓ Не удалось проверить", "has_issues": True}
|
||||
|
||||
status_emoji = "🔧" if status_info["is_active"] else "✅"
|
||||
status_text = "Включен" if status_info["is_active"] else "Выключен"
|
||||
|
||||
@@ -55,14 +64,20 @@ async def show_maintenance_panel(
|
||||
if status_info["consecutive_failures"] > 0:
|
||||
failures_info = f"\n⚠️ <b>Неудачных проверок подряд:</b> {status_info['consecutive_failures']}"
|
||||
|
||||
panel_info = f"\n🌐 <b>Панель Remnawave:</b> {panel_status['description']}"
|
||||
if panel_status.get("response_time"):
|
||||
panel_info += f"\n⚡ <b>Время отклика:</b> {panel_status['response_time']}с"
|
||||
|
||||
message_text = f"""
|
||||
🔧 <b>Режим технических работ</b>
|
||||
🔧 <b>Управление техническими работами</b>
|
||||
|
||||
{status_emoji} <b>Статус:</b> {status_text}
|
||||
{api_emoji} <b>API RemnaWave:</b> {api_text}
|
||||
{status_emoji} <b>Режим техработ:</b> {status_text}
|
||||
{api_emoji} <b>API Remnawave:</b> {api_text}
|
||||
{monitoring_emoji} <b>Мониторинг:</b> {monitoring_text}
|
||||
🛠️ <b>Автозапуск мониторинга:</b> {'Включен' if status_info['monitoring_configured'] else 'Отключен'}
|
||||
⏱️ <b>Интервал проверки:</b> {status_info['check_interval']}с
|
||||
🤖 <b>Автовключение:</b> {'Включено' if status_info['auto_enable_configured'] else 'Отключено'}
|
||||
{panel_info}
|
||||
{enabled_info}
|
||||
{last_check_info}
|
||||
{failures_info}
|
||||
@@ -72,7 +87,12 @@ async def show_maintenance_panel(
|
||||
|
||||
await callback.message.edit_text(
|
||||
message_text,
|
||||
reply_markup=get_maintenance_keyboard(db_user.language, status_info["is_active"], status_info["monitoring_active"])
|
||||
reply_markup=get_maintenance_keyboard(
|
||||
db_user.language,
|
||||
status_info["is_active"],
|
||||
status_info["monitoring_active"],
|
||||
panel_status.get("has_issues", False)
|
||||
)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
@@ -186,6 +206,170 @@ async def force_api_check(
|
||||
await show_maintenance_panel(callback, db_user, db, None)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def check_panel_status(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
await callback.answer("Проверка статуса панели...", show_alert=False)
|
||||
|
||||
try:
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
rw_service = RemnaWaveService()
|
||||
|
||||
status_data = await rw_service.check_panel_health()
|
||||
|
||||
status_text = {
|
||||
"online": "🟢 Панель работает нормально",
|
||||
"offline": "🔴 Панель недоступна",
|
||||
"degraded": "🟡 Панель работает со сбоями"
|
||||
}.get(status_data["status"], "❓ Статус неизвестен")
|
||||
|
||||
message_parts = [
|
||||
f"🌐 <b>Статус панели Remnawave</b>\n",
|
||||
f"{status_text}",
|
||||
f"⚡ Время отклика: {status_data.get('response_time', 0)}с",
|
||||
f"👥 Пользователей онлайн: {status_data.get('users_online', 0)}",
|
||||
f"🖥️ Нод онлайн: {status_data.get('nodes_online', 0)}/{status_data.get('total_nodes', 0)}"
|
||||
]
|
||||
|
||||
attempts_used = status_data.get("attempts_used")
|
||||
if attempts_used:
|
||||
message_parts.append(f"🔁 Попыток проверки: {attempts_used}")
|
||||
|
||||
if status_data.get("api_error"):
|
||||
message_parts.append(f"❌ Ошибка: {status_data['api_error'][:100]}")
|
||||
|
||||
message = "\n".join(message_parts)
|
||||
|
||||
await callback.message.answer(message, parse_mode="HTML")
|
||||
|
||||
except Exception as e:
|
||||
await callback.message.answer(f"❌ Ошибка проверки статуса: {str(e)}")
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def send_manual_notification(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext
|
||||
):
|
||||
await state.set_state(MaintenanceStates.waiting_for_notification_message)
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(text="🟢 Онлайн", callback_data="manual_notify_online"),
|
||||
types.InlineKeyboardButton(text="🔴 Офлайн", callback_data="manual_notify_offline")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="🟡 Проблемы", callback_data="manual_notify_degraded"),
|
||||
types.InlineKeyboardButton(text="🔧 Обслуживание", callback_data="manual_notify_maintenance")
|
||||
],
|
||||
[types.InlineKeyboardButton(text="❌ Отмена", callback_data="maintenance_panel")]
|
||||
])
|
||||
|
||||
await callback.message.edit_text(
|
||||
"📢 <b>Ручная отправка уведомления</b>\n\nВыберите статус для уведомления:",
|
||||
reply_markup=keyboard
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def handle_manual_notification(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext
|
||||
):
|
||||
status_map = {
|
||||
"manual_notify_online": "online",
|
||||
"manual_notify_offline": "offline",
|
||||
"manual_notify_degraded": "degraded",
|
||||
"manual_notify_maintenance": "maintenance"
|
||||
}
|
||||
|
||||
status = status_map.get(callback.data)
|
||||
if not status:
|
||||
await callback.answer("Неизвестный статус")
|
||||
return
|
||||
|
||||
await state.update_data(notification_status=status)
|
||||
|
||||
status_names = {
|
||||
"online": "🟢 Онлайн",
|
||||
"offline": "🔴 Офлайн",
|
||||
"degraded": "🟡 Проблемы",
|
||||
"maintenance": "🔧 Обслуживание"
|
||||
}
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"📢 <b>Отправка уведомления: {status_names[status]}</b>\n\n"
|
||||
f"Введите сообщение для уведомления или отправьте /skip для отправки без дополнительного текста:",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="❌ Отмена", callback_data="maintenance_panel")]
|
||||
])
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_notification_message(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext
|
||||
):
|
||||
current_state = await state.get_state()
|
||||
|
||||
if current_state != MaintenanceStates.waiting_for_notification_message:
|
||||
return
|
||||
|
||||
data = await state.get_data()
|
||||
status = data.get("notification_status")
|
||||
|
||||
if not status:
|
||||
await message.answer("Ошибка: статус не выбран")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
notification_message = ""
|
||||
if message.text and message.text != "/skip":
|
||||
notification_message = message.text[:300]
|
||||
|
||||
try:
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
rw_service = RemnaWaveService()
|
||||
|
||||
success = await rw_service.send_manual_status_notification(
|
||||
message.bot,
|
||||
status,
|
||||
notification_message
|
||||
)
|
||||
|
||||
if success:
|
||||
await message.answer("✅ Уведомление отправлено")
|
||||
else:
|
||||
await message.answer("❌ Ошибка отправки уведомления")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки ручного уведомления: {e}")
|
||||
await message.answer(f"❌ Ошибка: {str(e)}")
|
||||
|
||||
await state.clear()
|
||||
|
||||
await message.answer(
|
||||
"Вернуться к панели техработ:",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="🔧 Панель техработ", callback_data="maintenance_panel")]
|
||||
])
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def back_to_admin_panel(
|
||||
@@ -224,6 +408,21 @@ def register_handlers(dp: Dispatcher):
|
||||
F.data == "maintenance_check_api"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
check_panel_status,
|
||||
F.data == "maintenance_check_panel"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
send_manual_notification,
|
||||
F.data == "maintenance_manual_notify"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
handle_manual_notification,
|
||||
F.data.startswith("manual_notify_")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
back_to_admin_panel,
|
||||
F.data == "admin_panel"
|
||||
@@ -233,3 +432,8 @@ def register_handlers(dp: Dispatcher):
|
||||
process_maintenance_reason,
|
||||
MaintenanceStates.waiting_for_reason
|
||||
)
|
||||
|
||||
dp.message.register(
|
||||
process_notification_message,
|
||||
MaintenanceStates.waiting_for_notification_message
|
||||
)
|
||||
|
||||
+615
-59
@@ -9,19 +9,82 @@ from sqlalchemy import select, func, and_, or_
|
||||
|
||||
from app.config import settings
|
||||
from app.states import AdminStates
|
||||
from app.database.models import User, UserStatus, Subscription, BroadcastHistory
|
||||
from app.database.models import (
|
||||
User,
|
||||
UserStatus,
|
||||
Subscription,
|
||||
SubscriptionStatus,
|
||||
BroadcastHistory,
|
||||
)
|
||||
from app.keyboards.admin import (
|
||||
get_admin_messages_keyboard, get_broadcast_target_keyboard,
|
||||
get_custom_criteria_keyboard, get_broadcast_history_keyboard,
|
||||
get_admin_pagination_keyboard
|
||||
get_admin_pagination_keyboard, get_broadcast_media_keyboard,
|
||||
get_media_confirm_keyboard, get_updated_message_buttons_selector_keyboard_with_media,
|
||||
BROADCAST_BUTTON_ROWS, DEFAULT_BROADCAST_BUTTONS,
|
||||
get_broadcast_button_config, get_broadcast_button_labels
|
||||
)
|
||||
from app.localization.texts import get_texts
|
||||
from app.database.crud.user import get_users_list
|
||||
from app.database.crud.subscription import get_expiring_subscriptions
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
from app.utils.miniapp_buttons import build_miniapp_or_callback_button
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
BUTTON_ROWS = BROADCAST_BUTTON_ROWS
|
||||
DEFAULT_SELECTED_BUTTONS = DEFAULT_BROADCAST_BUTTONS
|
||||
|
||||
TEXT_MENU_MINIAPP_BUTTON_KEYS = {
|
||||
"balance",
|
||||
"referrals",
|
||||
"promocode",
|
||||
"connect",
|
||||
"subscription",
|
||||
}
|
||||
|
||||
|
||||
def get_message_buttons_selector_keyboard(language: str = "ru") -> types.InlineKeyboardMarkup:
|
||||
return get_updated_message_buttons_selector_keyboard(list(DEFAULT_SELECTED_BUTTONS), language)
|
||||
|
||||
|
||||
def get_updated_message_buttons_selector_keyboard(selected_buttons: list, language: str = "ru") -> types.InlineKeyboardMarkup:
|
||||
return get_updated_message_buttons_selector_keyboard_with_media(selected_buttons, False, language)
|
||||
|
||||
|
||||
def create_broadcast_keyboard(selected_buttons: list, language: str = "ru") -> Optional[types.InlineKeyboardMarkup]:
|
||||
selected_buttons = selected_buttons or []
|
||||
keyboard: list[list[types.InlineKeyboardButton]] = []
|
||||
button_config_map = get_broadcast_button_config(language)
|
||||
|
||||
for row in BUTTON_ROWS:
|
||||
row_buttons: list[types.InlineKeyboardButton] = []
|
||||
for button_key in row:
|
||||
if button_key not in selected_buttons:
|
||||
continue
|
||||
button_config = button_config_map[button_key]
|
||||
if settings.is_text_main_menu_mode() and button_key in TEXT_MENU_MINIAPP_BUTTON_KEYS:
|
||||
row_buttons.append(
|
||||
build_miniapp_or_callback_button(
|
||||
text=button_config["text"],
|
||||
callback_data=button_config["callback"],
|
||||
)
|
||||
)
|
||||
else:
|
||||
row_buttons.append(
|
||||
types.InlineKeyboardButton(
|
||||
text=button_config["text"],
|
||||
callback_data=button_config["callback"]
|
||||
)
|
||||
)
|
||||
if row_buttons:
|
||||
keyboard.append(row_buttons)
|
||||
|
||||
if not keyboard:
|
||||
return None
|
||||
|
||||
return types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
@@ -107,13 +170,16 @@ async def show_messages_history(
|
||||
|
||||
message_preview = broadcast.message_text[:100] + "..." if len(broadcast.message_text) > 100 else broadcast.message_text
|
||||
|
||||
import html
|
||||
message_preview = html.escape(message_preview)
|
||||
|
||||
text += f"""
|
||||
{status_emoji} <b>{broadcast.created_at.strftime('%d.%m.%Y %H:%M')}</b>
|
||||
📊 Отправлено: {broadcast.sent_count}/{broadcast.total_count} ({success_rate}%)
|
||||
🎯 Аудитория: {get_target_name(broadcast.target_type)}
|
||||
👤 Админ: {broadcast.admin_name}
|
||||
📝 Сообщение: <i>{message_preview}</i>
|
||||
━━━━━━━━━━━━━━━━━━━━
|
||||
📝 Сообщение: {message_preview}
|
||||
━━━━━━━━━━━━━━━━━━━━━━━
|
||||
"""
|
||||
|
||||
keyboard = get_broadcast_history_keyboard(page, total_pages, db_user.language).inline_keyboard
|
||||
@@ -121,7 +187,7 @@ async def show_messages_history(
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
parse_mode="HTML"
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
@@ -216,14 +282,21 @@ async def select_broadcast_target(
|
||||
state: FSMContext,
|
||||
db: AsyncSession
|
||||
):
|
||||
target = callback.data.split('_')[-1]
|
||||
|
||||
raw_target = callback.data[len("broadcast_"):]
|
||||
target_aliases = {
|
||||
"no_sub": "no",
|
||||
}
|
||||
target = target_aliases.get(raw_target, raw_target)
|
||||
|
||||
target_names = {
|
||||
"all": "Всем пользователям",
|
||||
"active": "С активной подпиской",
|
||||
"trial": "С триальной подпиской",
|
||||
"trial": "С триальной подпиской",
|
||||
"no": "Без подписки",
|
||||
"expiring": "С истекающей подпиской"
|
||||
"expiring": "С истекающей подпиской",
|
||||
"expired": "С истекшей подпиской",
|
||||
"active_zero": "Активная подписка, трафик 0 ГБ",
|
||||
"trial_zero": "Триальная подписка, трафик 0 ГБ",
|
||||
}
|
||||
|
||||
user_count = await get_target_users_count(db, target)
|
||||
@@ -260,15 +333,324 @@ async def process_broadcast_message(
|
||||
await message.answer("❌ Сообщение слишком длинное (максимум 4000 символов)")
|
||||
return
|
||||
|
||||
data = await state.get_data()
|
||||
target = data.get('broadcast_target')
|
||||
|
||||
user_count = await get_target_users_count(db, target) if not target.startswith('custom_') else await get_custom_users_count(db, target.replace('custom_', ''))
|
||||
|
||||
await state.update_data(broadcast_message=broadcast_text)
|
||||
|
||||
await message.answer(
|
||||
"🖼️ <b>Добавление медиафайла</b>\n\n"
|
||||
"Вы можете добавить к сообщению фото, видео или документ.\n"
|
||||
"Или пропустить этот шаг.\n\n"
|
||||
"Выберите тип медиа:",
|
||||
reply_markup=get_broadcast_media_keyboard(db_user.language),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def handle_media_selection(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
):
|
||||
if callback.data == "skip_media":
|
||||
await state.update_data(has_media=False)
|
||||
await show_button_selector_callback(callback, db_user, state)
|
||||
return
|
||||
|
||||
media_type = callback.data.replace('add_media_', '')
|
||||
|
||||
media_instructions = {
|
||||
"photo": "📷 Отправьте фотографию для рассылки:",
|
||||
"video": "🎥 Отправьте видео для рассылки:",
|
||||
"document": "📄 Отправьте документ для рассылки:"
|
||||
}
|
||||
|
||||
await state.update_data(
|
||||
media_type=media_type,
|
||||
waiting_for_media=True
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"{media_instructions.get(media_type, 'Отправьте медиафайл:')}\n\n"
|
||||
f"<i>Размер файла не должен превышать 50 МБ</i>",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_messages")]
|
||||
]),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
await state.set_state(AdminStates.waiting_for_broadcast_media)
|
||||
await callback.answer()
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_broadcast_media(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
):
|
||||
data = await state.get_data()
|
||||
expected_type = data.get('media_type')
|
||||
|
||||
media_file_id = None
|
||||
media_type = None
|
||||
|
||||
if message.photo and expected_type == "photo":
|
||||
media_file_id = message.photo[-1].file_id
|
||||
media_type = "photo"
|
||||
elif message.video and expected_type == "video":
|
||||
media_file_id = message.video.file_id
|
||||
media_type = "video"
|
||||
elif message.document and expected_type == "document":
|
||||
media_file_id = message.document.file_id
|
||||
media_type = "document"
|
||||
else:
|
||||
await message.answer(
|
||||
f"❌ Пожалуйста, отправьте {expected_type} как указано в инструкции."
|
||||
)
|
||||
return
|
||||
|
||||
await state.update_data(
|
||||
has_media=True,
|
||||
media_file_id=media_file_id,
|
||||
media_type=media_type,
|
||||
media_caption=message.caption
|
||||
)
|
||||
|
||||
await show_media_preview(message, db_user, state)
|
||||
|
||||
async def show_media_preview(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
):
|
||||
data = await state.get_data()
|
||||
media_type = data.get('media_type')
|
||||
media_file_id = data.get('media_file_id')
|
||||
|
||||
preview_text = f"🖼️ <b>Медиафайл добавлен</b>\n\n" \
|
||||
f"📎 <b>Тип:</b> {media_type}\n" \
|
||||
f"✅ Файл сохранен и готов к отправке\n\n" \
|
||||
f"Что делать дальше?"
|
||||
|
||||
# Для предпросмотра рассылки используем оригинальный метод без патчинга логотипа
|
||||
# чтобы показать именно загруженное фото
|
||||
from app.utils.message_patch import _original_answer
|
||||
|
||||
if media_type == "photo" and media_file_id:
|
||||
# Показываем предпросмотр с загруженным фото
|
||||
await message.bot.send_photo(
|
||||
chat_id=message.chat.id,
|
||||
photo=media_file_id,
|
||||
caption=preview_text,
|
||||
reply_markup=get_media_confirm_keyboard(db_user.language),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
else:
|
||||
# Для других типов медиа или если нет фото, используем обычное сообщение
|
||||
await _original_answer(message, preview_text,
|
||||
reply_markup=get_media_confirm_keyboard(db_user.language),
|
||||
parse_mode="HTML")
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def handle_media_confirmation(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
):
|
||||
action = callback.data
|
||||
|
||||
if action == "confirm_media":
|
||||
await show_button_selector_callback(callback, db_user, state)
|
||||
elif action == "replace_media":
|
||||
data = await state.get_data()
|
||||
media_type = data.get('media_type', 'photo')
|
||||
await handle_media_selection(callback, db_user, state)
|
||||
elif action == "skip_media":
|
||||
await state.update_data(
|
||||
has_media=False,
|
||||
media_file_id=None,
|
||||
media_type=None,
|
||||
media_caption=None
|
||||
)
|
||||
await show_button_selector_callback(callback, db_user, state)
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def handle_change_media(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
):
|
||||
await callback.message.edit_text(
|
||||
"🖼️ <b>Изменение медиафайла</b>\n\n"
|
||||
"Выберите новый тип медиа:",
|
||||
reply_markup=get_broadcast_media_keyboard(db_user.language),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_button_selector_callback(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
):
|
||||
data = await state.get_data()
|
||||
has_media = data.get('has_media', False)
|
||||
selected_buttons = data.get('selected_buttons')
|
||||
|
||||
if selected_buttons is None:
|
||||
selected_buttons = list(DEFAULT_SELECTED_BUTTONS)
|
||||
await state.update_data(selected_buttons=selected_buttons)
|
||||
|
||||
media_info = ""
|
||||
if has_media:
|
||||
media_type = data.get('media_type', 'файл')
|
||||
media_info = f"\n🖼️ <b>Медиафайл:</b> {media_type} добавлен"
|
||||
|
||||
text = f"""
|
||||
📘 <b>Выбор дополнительных кнопок</b>
|
||||
|
||||
Выберите кнопки, которые будут добавлены к сообщению рассылки:
|
||||
|
||||
💰 <b>Пополнить баланс</b> — откроет методы пополнения
|
||||
🤝 <b>Партнерка</b> — откроет реферальную программу
|
||||
🎫 <b>Промокод</b> — откроет форму ввода промокода
|
||||
🔗 <b>Подключиться</b> — поможет подключить приложение
|
||||
📱 <b>Подписка</b> — покажет состояние подписки
|
||||
🛠️ <b>Техподдержка</b> — свяжет с поддержкой
|
||||
|
||||
🏠 <b>Кнопка "На главную"</b> включена по умолчанию, но вы можете отключить её при необходимости.{media_info}
|
||||
|
||||
Выберите нужные кнопки и нажмите "Продолжить":
|
||||
"""
|
||||
|
||||
keyboard = get_updated_message_buttons_selector_keyboard_with_media(
|
||||
selected_buttons, has_media, db_user.language
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_button_selector(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
):
|
||||
data = await state.get_data()
|
||||
selected_buttons = data.get('selected_buttons')
|
||||
if selected_buttons is None:
|
||||
selected_buttons = list(DEFAULT_SELECTED_BUTTONS)
|
||||
await state.update_data(selected_buttons=selected_buttons)
|
||||
|
||||
has_media = data.get('has_media', False)
|
||||
|
||||
text = """
|
||||
📘 <b>Выбор дополнительных кнопок</b>
|
||||
|
||||
Выберите кнопки, которые будут добавлены к сообщению рассылки:
|
||||
|
||||
💰 <b>Пополнить баланс</b> — откроет методы пополнения
|
||||
🤝 <b>Партнерка</b> — откроет реферальную программу
|
||||
🎫 <b>Промокод</b> — откроет форму ввода промокода
|
||||
🔗 <b>Подключиться</b> — поможет подключить приложение
|
||||
📱 <b>Подписка</b> — покажет состояние подписки
|
||||
🛠️ <b>Техподдержка</b> — свяжет с поддержкой
|
||||
|
||||
🏠 <b>Кнопка "На главную"</b> включена по умолчанию, но вы можете отключить её при необходимости.
|
||||
|
||||
Выберите нужные кнопки и нажмите "Продолжить":
|
||||
"""
|
||||
|
||||
keyboard = get_updated_message_buttons_selector_keyboard_with_media(
|
||||
selected_buttons, has_media, db_user.language
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def toggle_button_selection(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
):
|
||||
button_type = callback.data.replace('btn_', '')
|
||||
data = await state.get_data()
|
||||
selected_buttons = data.get('selected_buttons')
|
||||
if selected_buttons is None:
|
||||
selected_buttons = list(DEFAULT_SELECTED_BUTTONS)
|
||||
else:
|
||||
selected_buttons = list(selected_buttons)
|
||||
|
||||
if button_type in selected_buttons:
|
||||
selected_buttons.remove(button_type)
|
||||
else:
|
||||
selected_buttons.append(button_type)
|
||||
|
||||
await state.update_data(selected_buttons=selected_buttons)
|
||||
|
||||
has_media = data.get('has_media', False)
|
||||
keyboard = get_updated_message_buttons_selector_keyboard_with_media(
|
||||
selected_buttons, has_media, db_user.language
|
||||
)
|
||||
|
||||
await callback.message.edit_reply_markup(reply_markup=keyboard)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def confirm_button_selection(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession
|
||||
):
|
||||
data = await state.get_data()
|
||||
target = data.get('broadcast_target')
|
||||
message_text = data.get('broadcast_message')
|
||||
selected_buttons = data.get('selected_buttons')
|
||||
if selected_buttons is None:
|
||||
selected_buttons = list(DEFAULT_SELECTED_BUTTONS)
|
||||
await state.update_data(selected_buttons=selected_buttons)
|
||||
has_media = data.get('has_media', False)
|
||||
media_type = data.get('media_type')
|
||||
|
||||
user_count = await get_target_users_count(db, target) if not target.startswith('custom_') else await get_custom_users_count(db, target.replace('custom_', ''))
|
||||
target_display = get_target_display_name(target)
|
||||
|
||||
media_info = ""
|
||||
if has_media:
|
||||
media_type_names = {
|
||||
"photo": "Фотография",
|
||||
"video": "Видео",
|
||||
"document": "Документ"
|
||||
}
|
||||
media_info = f"\n🖼️ <b>Медиафайл:</b> {media_type_names.get(media_type, media_type)}"
|
||||
|
||||
ordered_keys = [button_key for row in BUTTON_ROWS for button_key in row]
|
||||
button_labels = get_broadcast_button_labels(db_user.language)
|
||||
selected_names = [button_labels[key] for key in ordered_keys if key in selected_buttons]
|
||||
if selected_names:
|
||||
buttons_info = f"\n📘 <b>Кнопки:</b> {', '.join(selected_names)}"
|
||||
else:
|
||||
buttons_info = "\n📘 <b>Кнопки:</b> отсутствуют"
|
||||
|
||||
preview_text = f"""
|
||||
📨 <b>Предварительный просмотр рассылки</b>
|
||||
|
||||
@@ -276,7 +658,9 @@ async def process_broadcast_message(
|
||||
👥 <b>Получателей:</b> {user_count}
|
||||
|
||||
📝 <b>Сообщение:</b>
|
||||
{broadcast_text}
|
||||
{message_text}{media_info}
|
||||
|
||||
{buttons_info}
|
||||
|
||||
Подтвердить отправку?
|
||||
"""
|
||||
@@ -284,18 +668,48 @@ async def process_broadcast_message(
|
||||
keyboard = [
|
||||
[
|
||||
types.InlineKeyboardButton(text="✅ Отправить", callback_data="admin_confirm_broadcast"),
|
||||
types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_messages")
|
||||
types.InlineKeyboardButton(text="📘 Изменить кнопки", callback_data="edit_buttons")
|
||||
]
|
||||
]
|
||||
|
||||
await message.answer(
|
||||
preview_text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await state.set_state(AdminStates.confirming_broadcast)
|
||||
|
||||
|
||||
if has_media:
|
||||
keyboard.append([
|
||||
types.InlineKeyboardButton(text="🖼️ Изменить медиа", callback_data="change_media")
|
||||
])
|
||||
|
||||
keyboard.append([
|
||||
types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_messages")
|
||||
])
|
||||
|
||||
# Если есть медиа, показываем его с загруженным фото, иначе обычное текстовое сообщение
|
||||
if has_media and media_type == "photo":
|
||||
media_file_id = data.get('media_file_id')
|
||||
if media_file_id:
|
||||
# Удаляем текущее сообщение и отправляем новое с фото
|
||||
await callback.message.delete()
|
||||
await callback.bot.send_photo(
|
||||
chat_id=callback.message.chat.id,
|
||||
photo=media_file_id,
|
||||
caption=preview_text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
else:
|
||||
# Если нет file_id, используем обычное редактирование
|
||||
await callback.message.edit_text(
|
||||
preview_text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
else:
|
||||
# Для текстовых сообщений или других типов медиа используем обычное редактирование
|
||||
await callback.message.edit_text(
|
||||
preview_text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
await callback.answer()
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def confirm_broadcast(
|
||||
@@ -307,6 +721,13 @@ async def confirm_broadcast(
|
||||
data = await state.get_data()
|
||||
target = data.get('broadcast_target')
|
||||
message_text = data.get('broadcast_message')
|
||||
selected_buttons = data.get('selected_buttons')
|
||||
if selected_buttons is None:
|
||||
selected_buttons = list(DEFAULT_SELECTED_BUTTONS)
|
||||
has_media = data.get('has_media', False)
|
||||
media_type = data.get('media_type')
|
||||
media_file_id = data.get('media_file_id')
|
||||
media_caption = data.get('media_caption')
|
||||
|
||||
await callback.message.edit_text(
|
||||
"📨 Начинаю рассылку...\n\n"
|
||||
@@ -323,6 +744,10 @@ async def confirm_broadcast(
|
||||
broadcast_history = BroadcastHistory(
|
||||
target_type=target,
|
||||
message_text=message_text,
|
||||
has_media=has_media,
|
||||
media_type=media_type,
|
||||
media_file_id=media_file_id,
|
||||
media_caption=media_caption,
|
||||
total_count=len(users),
|
||||
sent_count=0,
|
||||
failed_count=0,
|
||||
@@ -337,21 +762,71 @@ async def confirm_broadcast(
|
||||
sent_count = 0
|
||||
failed_count = 0
|
||||
|
||||
for user in users:
|
||||
try:
|
||||
await callback.bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=message_text,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
sent_count += 1
|
||||
|
||||
if sent_count % 20 == 0:
|
||||
await asyncio.sleep(1)
|
||||
|
||||
except Exception as e:
|
||||
failed_count += 1
|
||||
logger.error(f"Ошибка отправки рассылки пользователю {user.telegram_id}: {e}")
|
||||
broadcast_keyboard = create_broadcast_keyboard(selected_buttons, db_user.language)
|
||||
|
||||
# Ограничение на количество одновременных отправок
|
||||
semaphore = asyncio.Semaphore(20)
|
||||
|
||||
async def send_single_broadcast(user):
|
||||
"""Отправляет одно сообщение рассылки с семафором ограничения"""
|
||||
async with semaphore:
|
||||
try:
|
||||
if has_media and media_file_id:
|
||||
if media_type == "photo":
|
||||
await callback.bot.send_photo(
|
||||
chat_id=user.telegram_id,
|
||||
photo=media_file_id,
|
||||
caption=message_text,
|
||||
parse_mode="HTML",
|
||||
reply_markup=broadcast_keyboard
|
||||
)
|
||||
elif media_type == "video":
|
||||
await callback.bot.send_video(
|
||||
chat_id=user.telegram_id,
|
||||
video=media_file_id,
|
||||
caption=message_text,
|
||||
parse_mode="HTML",
|
||||
reply_markup=broadcast_keyboard
|
||||
)
|
||||
elif media_type == "document":
|
||||
await callback.bot.send_document(
|
||||
chat_id=user.telegram_id,
|
||||
document=media_file_id,
|
||||
caption=message_text,
|
||||
parse_mode="HTML",
|
||||
reply_markup=broadcast_keyboard
|
||||
)
|
||||
else:
|
||||
await callback.bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=message_text,
|
||||
parse_mode="HTML",
|
||||
reply_markup=broadcast_keyboard
|
||||
)
|
||||
return True, user.telegram_id
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки рассылки пользователю {user.telegram_id}: {e}")
|
||||
return False, user.telegram_id
|
||||
|
||||
# Отправляем сообщения пакетами для эффективности
|
||||
batch_size = 100
|
||||
for i in range(0, len(users), batch_size):
|
||||
batch = users[i:i + batch_size]
|
||||
tasks = [send_single_broadcast(user) for user in batch]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
for result in results:
|
||||
if isinstance(result, tuple): # (success, telegram_id)
|
||||
success, _ = result
|
||||
if success:
|
||||
sent_count += 1
|
||||
else:
|
||||
failed_count += 1
|
||||
elif isinstance(result, Exception):
|
||||
failed_count += 1
|
||||
|
||||
# Небольшая задержка между пакетами для снижения нагрузки на API
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
broadcast_history.sent_count = sent_count
|
||||
broadcast_history.failed_count = failed_count
|
||||
@@ -359,6 +834,10 @@ async def confirm_broadcast(
|
||||
broadcast_history.completed_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
|
||||
media_info = ""
|
||||
if has_media:
|
||||
media_info = f"\n🖼️ <b>Медиафайл:</b> {media_type}"
|
||||
|
||||
result_text = f"""
|
||||
✅ <b>Рассылка завершена!</b>
|
||||
|
||||
@@ -366,7 +845,7 @@ async def confirm_broadcast(
|
||||
- Отправлено: {sent_count}
|
||||
- Не доставлено: {failed_count}
|
||||
- Всего пользователей: {len(users)}
|
||||
- Успешность: {round(sent_count / len(users) * 100, 1) if users else 0}%
|
||||
- Успешность: {round(sent_count / len(users) * 100, 1) if users else 0}%{media_info}
|
||||
|
||||
<b>Администратор:</b> {db_user.full_name}
|
||||
"""
|
||||
@@ -380,7 +859,7 @@ async def confirm_broadcast(
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
logger.info(f"Рассылка выполнена админом {db_user.telegram_id}: {sent_count}/{len(users)}")
|
||||
logger.info(f"Рассылка выполнена админом {db_user.telegram_id}: {sent_count}/{len(users)} (медиа: {has_media})")
|
||||
|
||||
|
||||
async def get_target_users_count(db: AsyncSession, target: str) -> int:
|
||||
@@ -389,22 +868,88 @@ async def get_target_users_count(db: AsyncSession, target: str) -> int:
|
||||
|
||||
|
||||
async def get_target_users(db: AsyncSession, target: str) -> list:
|
||||
if target == "all":
|
||||
return await get_users_list(db, offset=0, limit=10000, status=UserStatus.ACTIVE)
|
||||
elif target == "active":
|
||||
users = await get_users_list(db, offset=0, limit=10000, status=UserStatus.ACTIVE)
|
||||
return [user for user in users if user.subscription and user.subscription.is_active and not user.subscription.is_trial]
|
||||
elif target == "trial":
|
||||
users = await get_users_list(db, offset=0, limit=10000, status=UserStatus.ACTIVE)
|
||||
return [user for user in users if user.subscription and user.subscription.is_trial]
|
||||
elif target == "no":
|
||||
users = await get_users_list(db, offset=0, limit=10000, status=UserStatus.ACTIVE)
|
||||
return [user for user in users if not user.subscription or not user.subscription.is_active]
|
||||
elif target == "expiring":
|
||||
expiring_subs = await get_expiring_subscriptions(db, 3)
|
||||
return [sub.user for sub in expiring_subs if sub.user]
|
||||
else:
|
||||
return []
|
||||
users = await get_users_list(db, offset=0, limit=10000, status=UserStatus.ACTIVE)
|
||||
|
||||
if target == "all":
|
||||
return users
|
||||
|
||||
if target == "active":
|
||||
return [
|
||||
user
|
||||
for user in users
|
||||
if user.subscription
|
||||
and user.subscription.is_active
|
||||
and not user.subscription.is_trial
|
||||
]
|
||||
|
||||
if target == "trial":
|
||||
return [
|
||||
user
|
||||
for user in users
|
||||
if user.subscription and user.subscription.is_trial
|
||||
]
|
||||
|
||||
if target == "no":
|
||||
return [
|
||||
user
|
||||
for user in users
|
||||
if not user.subscription or not user.subscription.is_active
|
||||
]
|
||||
|
||||
if target == "expiring":
|
||||
expiring_subs = await get_expiring_subscriptions(db, 3)
|
||||
return [sub.user for sub in expiring_subs if sub.user]
|
||||
|
||||
if target == "expired":
|
||||
now = datetime.utcnow()
|
||||
expired_statuses = {
|
||||
SubscriptionStatus.EXPIRED.value,
|
||||
SubscriptionStatus.DISABLED.value,
|
||||
}
|
||||
expired_users = []
|
||||
for user in users:
|
||||
subscription = user.subscription
|
||||
if subscription:
|
||||
if subscription.status in expired_statuses:
|
||||
expired_users.append(user)
|
||||
continue
|
||||
if subscription.end_date <= now and not subscription.is_active:
|
||||
expired_users.append(user)
|
||||
continue
|
||||
elif user.has_had_paid_subscription:
|
||||
expired_users.append(user)
|
||||
return expired_users
|
||||
|
||||
if target == "active_zero":
|
||||
return [
|
||||
user
|
||||
for user in users
|
||||
if user.subscription
|
||||
and not user.subscription.is_trial
|
||||
and user.subscription.is_active
|
||||
and (user.subscription.traffic_used_gb or 0) <= 0
|
||||
]
|
||||
|
||||
if target == "trial_zero":
|
||||
return [
|
||||
user
|
||||
for user in users
|
||||
if user.subscription
|
||||
and user.subscription.is_trial
|
||||
and user.subscription.is_active
|
||||
and (user.subscription.traffic_used_gb or 0) <= 0
|
||||
]
|
||||
|
||||
if target == "zero":
|
||||
return [
|
||||
user
|
||||
for user in users
|
||||
if user.subscription
|
||||
and user.subscription.is_active
|
||||
and (user.subscription.traffic_used_gb or 0) <= 0
|
||||
]
|
||||
|
||||
return []
|
||||
|
||||
|
||||
async def get_custom_users_count(db: AsyncSession, criteria: str) -> int:
|
||||
@@ -413,7 +958,6 @@ async def get_custom_users_count(db: AsyncSession, criteria: str) -> int:
|
||||
|
||||
|
||||
async def get_custom_users(db: AsyncSession, criteria: str) -> list:
|
||||
"""Получение пользователей по настраиваемым критериям"""
|
||||
now = datetime.utcnow()
|
||||
today = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
week_ago = now - timedelta(days=7)
|
||||
@@ -462,7 +1006,6 @@ async def get_custom_users(db: AsyncSession, criteria: str) -> list:
|
||||
|
||||
|
||||
async def get_users_statistics(db: AsyncSession) -> dict:
|
||||
"""Получение статистики пользователей для отображения"""
|
||||
now = datetime.utcnow()
|
||||
today = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
week_ago = now - timedelta(days=7)
|
||||
@@ -530,7 +1073,12 @@ def get_target_name(target_type: str) -> str:
|
||||
"active": "С активной подпиской",
|
||||
"trial": "С триальной подпиской",
|
||||
"no": "Без подписки",
|
||||
"sub": "Без подписки",
|
||||
"expiring": "С истекающей подпиской",
|
||||
"expired": "С истекшей подпиской",
|
||||
"active_zero": "Активная подписка, трафик 0 ГБ",
|
||||
"trial_zero": "Триальная подписка, трафик 0 ГБ",
|
||||
"zero": "Подписка, трафик 0 ГБ",
|
||||
"custom_today": "Зарегистрированные сегодня",
|
||||
"custom_week": "Зарегистрированные за неделю",
|
||||
"custom_month": "Зарегистрированные за месяц",
|
||||
@@ -557,4 +1105,12 @@ def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(show_custom_broadcast, F.data == "admin_msg_custom")
|
||||
dp.callback_query.register(select_custom_criteria, F.data.startswith("criteria_"))
|
||||
|
||||
dp.callback_query.register(toggle_button_selection, F.data.startswith("btn_"))
|
||||
dp.callback_query.register(confirm_button_selection, F.data == "buttons_confirm")
|
||||
dp.callback_query.register(show_button_selector_callback, F.data == "edit_buttons")
|
||||
dp.callback_query.register(handle_media_selection, F.data.startswith("add_media_"))
|
||||
dp.callback_query.register(handle_media_selection, F.data == "skip_media")
|
||||
dp.callback_query.register(handle_media_confirmation, F.data.in_(["confirm_media", "replace_media"]))
|
||||
dp.callback_query.register(handle_change_media, F.data == "change_media")
|
||||
dp.message.register(process_broadcast_message, AdminStates.waiting_for_broadcast_message)
|
||||
dp.message.register(process_broadcast_media, AdminStates.waiting_for_broadcast_media)
|
||||
|
||||
@@ -2,24 +2,381 @@ import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from aiogram import Router, F
|
||||
from aiogram.types import Message, CallbackQuery
|
||||
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton
|
||||
from aiogram.filters import Command
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.exceptions import TelegramBadRequest
|
||||
|
||||
from app.config import settings
|
||||
from app.database.database import get_db
|
||||
from app.services.monitoring_service import monitoring_service
|
||||
from app.utils.decorators import admin_required
|
||||
from app.utils.pagination import paginate_list
|
||||
from app.keyboards.admin import get_monitoring_keyboard, get_admin_main_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.notification_settings_service import NotificationSettingsService
|
||||
from app.states import AdminStates
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = Router()
|
||||
|
||||
|
||||
def _format_toggle(enabled: bool) -> str:
|
||||
return "🟢 Вкл" if enabled else "🔴 Выкл"
|
||||
|
||||
|
||||
def _build_notification_settings_view(language: str):
|
||||
texts = get_texts(language)
|
||||
config = NotificationSettingsService.get_config()
|
||||
|
||||
second_percent = NotificationSettingsService.get_second_wave_discount_percent()
|
||||
second_hours = NotificationSettingsService.get_second_wave_valid_hours()
|
||||
third_percent = NotificationSettingsService.get_third_wave_discount_percent()
|
||||
third_hours = NotificationSettingsService.get_third_wave_valid_hours()
|
||||
third_days = NotificationSettingsService.get_third_wave_trigger_days()
|
||||
|
||||
trial_1h_status = _format_toggle(config["trial_inactive_1h"].get("enabled", True))
|
||||
trial_24h_status = _format_toggle(config["trial_inactive_24h"].get("enabled", True))
|
||||
trial_channel_status = _format_toggle(
|
||||
config["trial_channel_unsubscribed"].get("enabled", True)
|
||||
)
|
||||
expired_1d_status = _format_toggle(config["expired_1d"].get("enabled", True))
|
||||
second_wave_status = _format_toggle(config["expired_second_wave"].get("enabled", True))
|
||||
third_wave_status = _format_toggle(config["expired_third_wave"].get("enabled", True))
|
||||
|
||||
summary_text = (
|
||||
"🔔 <b>Уведомления пользователям</b>\n\n"
|
||||
f"• 1 час после триала: {trial_1h_status}\n"
|
||||
f"• 24 часа после триала: {trial_24h_status}\n"
|
||||
f"• Отписка от канала: {trial_channel_status}\n"
|
||||
f"• 1 день после истечения: {expired_1d_status}\n"
|
||||
f"• 2-3 дня (скидка {second_percent}% / {second_hours} ч): {second_wave_status}\n"
|
||||
f"• {third_days} дней (скидка {third_percent}% / {third_hours} ч): {third_wave_status}"
|
||||
)
|
||||
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text=f"{trial_1h_status} • 1 час после триала", callback_data="admin_mon_notify_toggle_trial_1h")],
|
||||
[InlineKeyboardButton(text="🧪 Тест: 1 час после триала", callback_data="admin_mon_notify_preview_trial_1h")],
|
||||
[InlineKeyboardButton(text=f"{trial_24h_status} • 24 часа после триала", callback_data="admin_mon_notify_toggle_trial_24h")],
|
||||
[InlineKeyboardButton(text="🧪 Тест: 24 часа после триала", callback_data="admin_mon_notify_preview_trial_24h")],
|
||||
[InlineKeyboardButton(text=f"{trial_channel_status} • Отписка от канала", callback_data="admin_mon_notify_toggle_trial_channel")],
|
||||
[InlineKeyboardButton(text="🧪 Тест: отписка от канала", callback_data="admin_mon_notify_preview_trial_channel")],
|
||||
[InlineKeyboardButton(text=f"{expired_1d_status} • 1 день после истечения", callback_data="admin_mon_notify_toggle_expired_1d")],
|
||||
[InlineKeyboardButton(text="🧪 Тест: 1 день после истечения", callback_data="admin_mon_notify_preview_expired_1d")],
|
||||
[InlineKeyboardButton(text=f"{second_wave_status} • 2-3 дня со скидкой", callback_data="admin_mon_notify_toggle_expired_2d")],
|
||||
[InlineKeyboardButton(text="🧪 Тест: скидка 2-3 день", callback_data="admin_mon_notify_preview_expired_2d")],
|
||||
[InlineKeyboardButton(text=f"✏️ Скидка 2-3 дня: {second_percent}%", callback_data="admin_mon_notify_edit_2d_percent")],
|
||||
[InlineKeyboardButton(text=f"⏱️ Срок скидки 2-3 дня: {second_hours} ч", callback_data="admin_mon_notify_edit_2d_hours")],
|
||||
[InlineKeyboardButton(text=f"{third_wave_status} • {third_days} дней со скидкой", callback_data="admin_mon_notify_toggle_expired_nd")],
|
||||
[InlineKeyboardButton(text="🧪 Тест: скидка спустя дни", callback_data="admin_mon_notify_preview_expired_nd")],
|
||||
[InlineKeyboardButton(text=f"✏️ Скидка {third_days} дней: {third_percent}%", callback_data="admin_mon_notify_edit_nd_percent")],
|
||||
[InlineKeyboardButton(text=f"⏱️ Срок скидки {third_days} дней: {third_hours} ч", callback_data="admin_mon_notify_edit_nd_hours")],
|
||||
[InlineKeyboardButton(text=f"📆 Порог уведомления: {third_days} дн.", callback_data="admin_mon_notify_edit_nd_threshold")],
|
||||
[InlineKeyboardButton(text="🧪 Отправить все тесты", callback_data="admin_mon_notify_preview_all")],
|
||||
[InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_mon_settings")],
|
||||
])
|
||||
|
||||
return summary_text, keyboard
|
||||
|
||||
|
||||
def _build_notification_preview_message(language: str, notification_type: str):
|
||||
texts = get_texts(language)
|
||||
now = datetime.now()
|
||||
price_30_days = settings.format_price(settings.PRICE_30_DAYS)
|
||||
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
|
||||
header = "🧪 <b>Тестовое уведомление мониторинга</b>\n\n"
|
||||
|
||||
if notification_type == "trial_inactive_1h":
|
||||
template = texts.get(
|
||||
"TRIAL_INACTIVE_1H",
|
||||
(
|
||||
"⏳ <b>Прошёл час, а подключения нет</b>\n\n"
|
||||
"Если возникли сложности с запуском — воспользуйтесь инструкциями."
|
||||
),
|
||||
)
|
||||
message = template.format(
|
||||
price=price_30_days,
|
||||
end_date=(now + timedelta(days=settings.TRIAL_DURATION_DAYS)).strftime("%d.%m.%Y %H:%M"),
|
||||
)
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
|
||||
callback_data="subscription_connect",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("MY_SUBSCRIPTION_BUTTON", "📱 Моя подписка"),
|
||||
callback_data="menu_subscription",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("SUPPORT_BUTTON", "🆘 Поддержка"),
|
||||
callback_data="menu_support",
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
elif notification_type == "trial_inactive_24h":
|
||||
template = texts.get(
|
||||
"TRIAL_INACTIVE_24H",
|
||||
(
|
||||
"⏳ <b>Вы ещё не подключились к VPN</b>\n\n"
|
||||
"Прошли сутки с активации тестового периода, но трафик не зафиксирован."
|
||||
"\n\nНажмите кнопку ниже, чтобы подключиться."
|
||||
),
|
||||
)
|
||||
message = template.format(
|
||||
price=price_30_days,
|
||||
end_date=(now + timedelta(days=1)).strftime("%d.%m.%Y %H:%M"),
|
||||
)
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
|
||||
callback_data="subscription_connect",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("MY_SUBSCRIPTION_BUTTON", "📱 Моя подписка"),
|
||||
callback_data="menu_subscription",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("SUPPORT_BUTTON", "🆘 Поддержка"),
|
||||
callback_data="menu_support",
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
elif notification_type == "trial_channel_unsubscribed":
|
||||
template = texts.get(
|
||||
"TRIAL_CHANNEL_UNSUBSCRIBED",
|
||||
(
|
||||
"🚫 <b>Доступ приостановлен</b>\n\n"
|
||||
"Мы не нашли вашу подписку на наш канал, поэтому тестовая подписка отключена.\n\n"
|
||||
"Подпишитесь на канал и нажмите «{check_button}», чтобы вернуть доступ."
|
||||
),
|
||||
)
|
||||
check_button = texts.t("CHANNEL_CHECK_BUTTON", "✅ Я подписался")
|
||||
message = template.format(check_button=check_button)
|
||||
buttons: list[list[InlineKeyboardButton]] = []
|
||||
if settings.CHANNEL_LINK:
|
||||
buttons.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("CHANNEL_SUBSCRIBE_BUTTON", "🔗 Подписаться"),
|
||||
url=settings.CHANNEL_LINK,
|
||||
)
|
||||
]
|
||||
)
|
||||
buttons.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=check_button,
|
||||
callback_data="sub_channel_check",
|
||||
)
|
||||
]
|
||||
)
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
elif notification_type == "expired_1d":
|
||||
template = texts.get(
|
||||
"SUBSCRIPTION_EXPIRED_1D",
|
||||
(
|
||||
"⛔ <b>Подписка закончилась</b>\n\n"
|
||||
"Доступ был отключён {end_date}. Продлите подписку, чтобы вернуться в сервис."
|
||||
),
|
||||
)
|
||||
message = template.format(
|
||||
end_date=(now - timedelta(days=1)).strftime("%d.%m.%Y %H:%M"),
|
||||
price=price_30_days,
|
||||
)
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("SUBSCRIPTION_EXTEND", "💎 Продлить подписку"),
|
||||
callback_data="subscription_extend",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("BALANCE_TOPUP", "💳 Пополнить баланс"),
|
||||
callback_data="balance_topup",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("SUPPORT_BUTTON", "🆘 Поддержка"),
|
||||
callback_data="menu_support",
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
elif notification_type == "expired_2d":
|
||||
percent = NotificationSettingsService.get_second_wave_discount_percent()
|
||||
valid_hours = NotificationSettingsService.get_second_wave_valid_hours()
|
||||
template = texts.get(
|
||||
"SUBSCRIPTION_EXPIRED_SECOND_WAVE",
|
||||
(
|
||||
"🔥 <b>Скидка {percent}% на продление</b>\n\n"
|
||||
"Активируйте предложение, чтобы получить дополнительную скидку. "
|
||||
"Она суммируется с вашей промогруппой и действует до {expires_at}."
|
||||
),
|
||||
)
|
||||
message = template.format(
|
||||
percent=percent,
|
||||
expires_at=(now + timedelta(hours=valid_hours)).strftime("%d.%m.%Y %H:%M"),
|
||||
trigger_days=3,
|
||||
)
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="🎁 Получить скидку",
|
||||
callback_data="claim_discount_preview",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("SUBSCRIPTION_EXTEND", "💎 Продлить подписку"),
|
||||
callback_data="subscription_extend",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("BALANCE_TOPUP", "💳 Пополнить баланс"),
|
||||
callback_data="balance_topup",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("SUPPORT_BUTTON", "🆘 Поддержка"),
|
||||
callback_data="menu_support",
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
elif notification_type == "expired_nd":
|
||||
percent = NotificationSettingsService.get_third_wave_discount_percent()
|
||||
valid_hours = NotificationSettingsService.get_third_wave_valid_hours()
|
||||
trigger_days = NotificationSettingsService.get_third_wave_trigger_days()
|
||||
template = texts.get(
|
||||
"SUBSCRIPTION_EXPIRED_THIRD_WAVE",
|
||||
(
|
||||
"🎁 <b>Индивидуальная скидка {percent}%</b>\n\n"
|
||||
"Прошло {trigger_days} дней без подписки — возвращайтесь и активируйте дополнительную скидку. "
|
||||
"Она суммируется с промогруппой и действует до {expires_at}."
|
||||
),
|
||||
)
|
||||
message = template.format(
|
||||
percent=percent,
|
||||
trigger_days=trigger_days,
|
||||
expires_at=(now + timedelta(hours=valid_hours)).strftime("%d.%m.%Y %H:%M"),
|
||||
)
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="🎁 Получить скидку",
|
||||
callback_data="claim_discount_preview",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("SUBSCRIPTION_EXTEND", "💎 Продлить подписку"),
|
||||
callback_data="subscription_extend",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("BALANCE_TOPUP", "💳 Пополнить баланс"),
|
||||
callback_data="balance_topup",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("SUPPORT_BUTTON", "🆘 Поддержка"),
|
||||
callback_data="menu_support",
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"Unsupported notification type: {notification_type}")
|
||||
|
||||
footer = "\n\n<i>Сообщение отправлено только вам для проверки оформления.</i>"
|
||||
return header + message + footer, keyboard
|
||||
|
||||
|
||||
async def _send_notification_preview(bot, chat_id: int, language: str, notification_type: str) -> None:
|
||||
message, keyboard = _build_notification_preview_message(language, notification_type)
|
||||
await bot.send_message(
|
||||
chat_id,
|
||||
message,
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
|
||||
|
||||
async def _render_notification_settings(callback: CallbackQuery) -> None:
|
||||
language = (callback.from_user.language_code or settings.DEFAULT_LANGUAGE)
|
||||
text, keyboard = _build_notification_settings_view(language)
|
||||
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard)
|
||||
|
||||
|
||||
async def _render_notification_settings_for_state(
|
||||
bot,
|
||||
chat_id: int,
|
||||
message_id: int,
|
||||
language: str,
|
||||
business_connection_id: str | None = None,
|
||||
) -> None:
|
||||
text, keyboard = _build_notification_settings_view(language)
|
||||
|
||||
edit_kwargs = {
|
||||
"text": text,
|
||||
"chat_id": chat_id,
|
||||
"message_id": message_id,
|
||||
"parse_mode": "HTML",
|
||||
"reply_markup": keyboard,
|
||||
}
|
||||
|
||||
if business_connection_id:
|
||||
edit_kwargs["business_connection_id"] = business_connection_id
|
||||
|
||||
try:
|
||||
await bot.edit_message_text(**edit_kwargs)
|
||||
except TelegramBadRequest as exc:
|
||||
if "no text in the message to edit" in (exc.message or "").lower():
|
||||
caption_kwargs = {
|
||||
"chat_id": chat_id,
|
||||
"message_id": message_id,
|
||||
"caption": text,
|
||||
"parse_mode": "HTML",
|
||||
"reply_markup": keyboard,
|
||||
}
|
||||
|
||||
if business_connection_id:
|
||||
caption_kwargs["business_connection_id"] = business_connection_id
|
||||
|
||||
await bot.edit_message_caption(**caption_kwargs)
|
||||
else:
|
||||
raise
|
||||
|
||||
@router.callback_query(F.data == "admin_monitoring")
|
||||
@admin_required
|
||||
async def admin_monitoring_menu(callback: CallbackQuery):
|
||||
"""Главное меню мониторинга"""
|
||||
try:
|
||||
async for db in get_db():
|
||||
status = await monitoring_service.get_monitoring_status(db)
|
||||
@@ -43,7 +400,8 @@ async def admin_monitoring_menu(callback: CallbackQuery):
|
||||
🔧 Выберите действие:
|
||||
"""
|
||||
|
||||
keyboard = get_monitoring_keyboard()
|
||||
language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE
|
||||
keyboard = get_monitoring_keyboard(language)
|
||||
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard)
|
||||
break
|
||||
|
||||
@@ -52,6 +410,287 @@ async def admin_monitoring_menu(callback: CallbackQuery):
|
||||
await callback.answer("❌ Ошибка получения данных", show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_settings")
|
||||
@admin_required
|
||||
async def admin_monitoring_settings(callback: CallbackQuery):
|
||||
try:
|
||||
language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE
|
||||
global_status = "🟢 Включены" if NotificationSettingsService.are_notifications_globally_enabled() else "🔴 Отключены"
|
||||
second_percent = NotificationSettingsService.get_second_wave_discount_percent()
|
||||
third_percent = NotificationSettingsService.get_third_wave_discount_percent()
|
||||
third_days = NotificationSettingsService.get_third_wave_trigger_days()
|
||||
|
||||
text = (
|
||||
"⚙️ <b>Настройки мониторинга</b>\n\n"
|
||||
f"🔔 <b>Уведомления пользователям:</b> {global_status}\n"
|
||||
f"• Скидка 2-3 дня: {second_percent}%\n"
|
||||
f"• Скидка после {third_days} дней: {third_percent}%\n\n"
|
||||
"Выберите раздел для настройки."
|
||||
)
|
||||
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="🔔 Уведомления пользователям", callback_data="admin_mon_notify_settings")],
|
||||
[InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_submenu_settings")],
|
||||
])
|
||||
|
||||
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отображения настроек мониторинга: {e}")
|
||||
await callback.answer("❌ Не удалось открыть настройки", show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_notify_settings")
|
||||
@admin_required
|
||||
async def admin_notify_settings(callback: CallbackQuery):
|
||||
try:
|
||||
await _render_notification_settings(callback)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отображения настроек уведомлений: {e}")
|
||||
await callback.answer("❌ Не удалось загрузить настройки", show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_notify_toggle_trial_1h")
|
||||
@admin_required
|
||||
async def toggle_trial_1h_notification(callback: CallbackQuery):
|
||||
enabled = NotificationSettingsService.is_trial_inactive_1h_enabled()
|
||||
NotificationSettingsService.set_trial_inactive_1h_enabled(not enabled)
|
||||
await callback.answer("✅ Включено" if not enabled else "⏸️ Отключено")
|
||||
await _render_notification_settings(callback)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_notify_preview_trial_1h")
|
||||
@admin_required
|
||||
async def preview_trial_1h_notification(callback: CallbackQuery):
|
||||
try:
|
||||
language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE
|
||||
await _send_notification_preview(callback.bot, callback.from_user.id, language, "trial_inactive_1h")
|
||||
await callback.answer("✅ Пример отправлен")
|
||||
except Exception as exc:
|
||||
logger.error("Failed to send trial 1h preview: %s", exc)
|
||||
await callback.answer("❌ Не удалось отправить тест", show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_notify_toggle_trial_24h")
|
||||
@admin_required
|
||||
async def toggle_trial_24h_notification(callback: CallbackQuery):
|
||||
enabled = NotificationSettingsService.is_trial_inactive_24h_enabled()
|
||||
NotificationSettingsService.set_trial_inactive_24h_enabled(not enabled)
|
||||
await callback.answer("✅ Включено" if not enabled else "⏸️ Отключено")
|
||||
await _render_notification_settings(callback)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_notify_preview_trial_24h")
|
||||
@admin_required
|
||||
async def preview_trial_24h_notification(callback: CallbackQuery):
|
||||
try:
|
||||
language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE
|
||||
await _send_notification_preview(callback.bot, callback.from_user.id, language, "trial_inactive_24h")
|
||||
await callback.answer("✅ Пример отправлен")
|
||||
except Exception as exc:
|
||||
logger.error("Failed to send trial 24h preview: %s", exc)
|
||||
await callback.answer("❌ Не удалось отправить тест", show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_notify_toggle_trial_channel")
|
||||
@admin_required
|
||||
async def toggle_trial_channel_notification(callback: CallbackQuery):
|
||||
enabled = NotificationSettingsService.is_trial_channel_unsubscribed_enabled()
|
||||
NotificationSettingsService.set_trial_channel_unsubscribed_enabled(not enabled)
|
||||
await callback.answer("✅ Включено" if not enabled else "⏸️ Отключено")
|
||||
await _render_notification_settings(callback)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_notify_preview_trial_channel")
|
||||
@admin_required
|
||||
async def preview_trial_channel_notification(callback: CallbackQuery):
|
||||
try:
|
||||
language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE
|
||||
await _send_notification_preview(callback.bot, callback.from_user.id, language, "trial_channel_unsubscribed")
|
||||
await callback.answer("✅ Пример отправлен")
|
||||
except Exception as exc:
|
||||
logger.error("Failed to send trial channel preview: %s", exc)
|
||||
await callback.answer("❌ Не удалось отправить тест", show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_notify_toggle_expired_1d")
|
||||
@admin_required
|
||||
async def toggle_expired_1d_notification(callback: CallbackQuery):
|
||||
enabled = NotificationSettingsService.is_expired_1d_enabled()
|
||||
NotificationSettingsService.set_expired_1d_enabled(not enabled)
|
||||
await callback.answer("✅ Включено" if not enabled else "⏸️ Отключено")
|
||||
await _render_notification_settings(callback)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_notify_preview_expired_1d")
|
||||
@admin_required
|
||||
async def preview_expired_1d_notification(callback: CallbackQuery):
|
||||
try:
|
||||
language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE
|
||||
await _send_notification_preview(callback.bot, callback.from_user.id, language, "expired_1d")
|
||||
await callback.answer("✅ Пример отправлен")
|
||||
except Exception as exc:
|
||||
logger.error("Failed to send expired 1d preview: %s", exc)
|
||||
await callback.answer("❌ Не удалось отправить тест", show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_notify_toggle_expired_2d")
|
||||
@admin_required
|
||||
async def toggle_second_wave_notification(callback: CallbackQuery):
|
||||
enabled = NotificationSettingsService.is_second_wave_enabled()
|
||||
NotificationSettingsService.set_second_wave_enabled(not enabled)
|
||||
await callback.answer("✅ Включено" if not enabled else "⏸️ Отключено")
|
||||
await _render_notification_settings(callback)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_notify_preview_expired_2d")
|
||||
@admin_required
|
||||
async def preview_second_wave_notification(callback: CallbackQuery):
|
||||
try:
|
||||
language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE
|
||||
await _send_notification_preview(callback.bot, callback.from_user.id, language, "expired_2d")
|
||||
await callback.answer("✅ Пример отправлен")
|
||||
except Exception as exc:
|
||||
logger.error("Failed to send second wave preview: %s", exc)
|
||||
await callback.answer("❌ Не удалось отправить тест", show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_notify_toggle_expired_nd")
|
||||
@admin_required
|
||||
async def toggle_third_wave_notification(callback: CallbackQuery):
|
||||
enabled = NotificationSettingsService.is_third_wave_enabled()
|
||||
NotificationSettingsService.set_third_wave_enabled(not enabled)
|
||||
await callback.answer("✅ Включено" if not enabled else "⏸️ Отключено")
|
||||
await _render_notification_settings(callback)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_notify_preview_expired_nd")
|
||||
@admin_required
|
||||
async def preview_third_wave_notification(callback: CallbackQuery):
|
||||
try:
|
||||
language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE
|
||||
await _send_notification_preview(callback.bot, callback.from_user.id, language, "expired_nd")
|
||||
await callback.answer("✅ Пример отправлен")
|
||||
except Exception as exc:
|
||||
logger.error("Failed to send third wave preview: %s", exc)
|
||||
await callback.answer("❌ Не удалось отправить тест", show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_notify_preview_all")
|
||||
@admin_required
|
||||
async def preview_all_notifications(callback: CallbackQuery):
|
||||
try:
|
||||
language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE
|
||||
chat_id = callback.from_user.id
|
||||
for notification_type in [
|
||||
"trial_inactive_1h",
|
||||
"trial_inactive_24h",
|
||||
"trial_channel_unsubscribed",
|
||||
"expired_1d",
|
||||
"expired_2d",
|
||||
"expired_nd",
|
||||
]:
|
||||
await _send_notification_preview(callback.bot, chat_id, language, notification_type)
|
||||
await callback.answer("✅ Все тестовые уведомления отправлены")
|
||||
except Exception as exc:
|
||||
logger.error("Failed to send all notification previews: %s", exc)
|
||||
await callback.answer("❌ Не удалось отправить тесты", show_alert=True)
|
||||
|
||||
|
||||
async def _start_notification_value_edit(
|
||||
callback: CallbackQuery,
|
||||
state: FSMContext,
|
||||
setting_key: str,
|
||||
field: str,
|
||||
prompt_key: str,
|
||||
default_prompt: str,
|
||||
):
|
||||
language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE
|
||||
await state.set_state(AdminStates.editing_notification_value)
|
||||
await state.update_data(
|
||||
notification_setting_key=setting_key,
|
||||
notification_setting_field=field,
|
||||
settings_message_chat=callback.message.chat.id,
|
||||
settings_message_id=callback.message.message_id,
|
||||
settings_business_connection_id=(
|
||||
str(getattr(callback.message, "business_connection_id", None))
|
||||
if getattr(callback.message, "business_connection_id", None) is not None
|
||||
else None
|
||||
),
|
||||
settings_language=language,
|
||||
)
|
||||
texts = get_texts(language)
|
||||
await callback.answer()
|
||||
await callback.message.answer(texts.get(prompt_key, default_prompt))
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_notify_edit_2d_percent")
|
||||
@admin_required
|
||||
async def edit_second_wave_percent(callback: CallbackQuery, state: FSMContext):
|
||||
await _start_notification_value_edit(
|
||||
callback,
|
||||
state,
|
||||
"expired_second_wave",
|
||||
"percent",
|
||||
"NOTIFY_PROMPT_SECOND_PERCENT",
|
||||
"Введите новый процент скидки для уведомления через 2-3 дня (0-100):",
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_notify_edit_2d_hours")
|
||||
@admin_required
|
||||
async def edit_second_wave_hours(callback: CallbackQuery, state: FSMContext):
|
||||
await _start_notification_value_edit(
|
||||
callback,
|
||||
state,
|
||||
"expired_second_wave",
|
||||
"hours",
|
||||
"NOTIFY_PROMPT_SECOND_HOURS",
|
||||
"Введите количество часов действия скидки (1-168):",
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_notify_edit_nd_percent")
|
||||
@admin_required
|
||||
async def edit_third_wave_percent(callback: CallbackQuery, state: FSMContext):
|
||||
await _start_notification_value_edit(
|
||||
callback,
|
||||
state,
|
||||
"expired_third_wave",
|
||||
"percent",
|
||||
"NOTIFY_PROMPT_THIRD_PERCENT",
|
||||
"Введите новый процент скидки для позднего предложения (0-100):",
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_notify_edit_nd_hours")
|
||||
@admin_required
|
||||
async def edit_third_wave_hours(callback: CallbackQuery, state: FSMContext):
|
||||
await _start_notification_value_edit(
|
||||
callback,
|
||||
state,
|
||||
"expired_third_wave",
|
||||
"hours",
|
||||
"NOTIFY_PROMPT_THIRD_HOURS",
|
||||
"Введите количество часов действия скидки (1-168):",
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_notify_edit_nd_threshold")
|
||||
@admin_required
|
||||
async def edit_third_wave_threshold(callback: CallbackQuery, state: FSMContext):
|
||||
await _start_notification_value_edit(
|
||||
callback,
|
||||
state,
|
||||
"expired_third_wave",
|
||||
"trigger",
|
||||
"NOTIFY_PROMPT_THIRD_DAYS",
|
||||
"Через сколько дней после истечения отправлять предложение? (минимум 2):",
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_start")
|
||||
@admin_required
|
||||
async def start_monitoring_callback(callback: CallbackQuery):
|
||||
@@ -127,50 +766,57 @@ async def force_check_callback(callback: CallbackQuery):
|
||||
await callback.answer(f"❌ Ошибка проверки: {str(e)}", show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_logs")
|
||||
@router.callback_query(F.data.startswith("admin_mon_logs"))
|
||||
@admin_required
|
||||
async def monitoring_logs_callback(callback: CallbackQuery):
|
||||
try:
|
||||
page = 1
|
||||
if "_page_" in callback.data:
|
||||
page = int(callback.data.split("_page_")[1])
|
||||
|
||||
async for db in get_db():
|
||||
logs = await monitoring_service.get_monitoring_logs(db, limit=15)
|
||||
all_logs = await monitoring_service.get_monitoring_logs(db, limit=1000)
|
||||
|
||||
if not logs:
|
||||
text = "📝 <b>Логи мониторинга пусты</b>\n\nСистема еще не выполняла проверки."
|
||||
else:
|
||||
text = "📝 <b>Последние логи мониторинга:</b>\n\n"
|
||||
if not all_logs:
|
||||
text = "📋 <b>Логи мониторинга пусты</b>\n\nСистема еще не выполнила проверки."
|
||||
keyboard = get_monitoring_logs_back_keyboard()
|
||||
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard)
|
||||
return
|
||||
|
||||
per_page = 8
|
||||
paginated_logs = paginate_list(all_logs, page=page, per_page=per_page)
|
||||
|
||||
text = f"📋 <b>Логи мониторинга</b> (стр. {page}/{paginated_logs.total_pages})\n\n"
|
||||
|
||||
for log in paginated_logs.items:
|
||||
icon = "✅" if log['is_success'] else "❌"
|
||||
time_str = log['created_at'].strftime('%m-%d %H:%M')
|
||||
event_type = log['event_type'].replace('_', ' ').title()
|
||||
|
||||
for log in logs:
|
||||
icon = "✅" if log['is_success'] else "❌"
|
||||
time_str = log['created_at'].strftime('%m-%d %H:%M')
|
||||
event_type = log['event_type'].replace('_', ' ').title()
|
||||
|
||||
text += f"{icon} <code>{time_str}</code> {event_type}\n"
|
||||
|
||||
message = log['message']
|
||||
if len(message) > 60:
|
||||
message = message[:60] + "..."
|
||||
|
||||
text += f" 📄 {message}\n\n"
|
||||
|
||||
if len(text) > 3500:
|
||||
text += "...\n\n<i>Показаны последние записи. Для просмотра всех логов используйте файл логов.</i>"
|
||||
break
|
||||
message = log['message']
|
||||
if len(message) > 45:
|
||||
message = message[:45] + "..."
|
||||
|
||||
text += f"{icon} <code>{time_str}</code> {event_type}\n"
|
||||
text += f" 📄 {message}\n\n"
|
||||
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_mon_logs"),
|
||||
InlineKeyboardButton(text="🗑️ Очистить", callback_data="admin_mon_clear_logs")
|
||||
],
|
||||
[InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_monitoring")]
|
||||
])
|
||||
total_success = sum(1 for log in all_logs if log['is_success'])
|
||||
total_failed = len(all_logs) - total_success
|
||||
success_rate = round(total_success / len(all_logs) * 100, 1) if all_logs else 0
|
||||
|
||||
text += f"📊 <b>Общая статистика:</b>\n"
|
||||
text += f"• Всего событий: {len(all_logs)}\n"
|
||||
text += f"• Успешных: {total_success}\n"
|
||||
text += f"• Ошибок: {total_failed}\n"
|
||||
text += f"• Успешность: {success_rate}%"
|
||||
|
||||
keyboard = get_monitoring_logs_keyboard(page, paginated_logs.total_pages)
|
||||
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard)
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения логов: {e}")
|
||||
await callback.answer(f"❌ Ошибка получения логов: {str(e)}", show_alert=True)
|
||||
await callback.answer("❌ Ошибка получения логов", show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_clear_logs")
|
||||
@@ -178,12 +824,12 @@ async def monitoring_logs_callback(callback: CallbackQuery):
|
||||
async def clear_logs_callback(callback: CallbackQuery):
|
||||
try:
|
||||
async for db in get_db():
|
||||
deleted_count = await monitoring_service.cleanup_old_logs(db, days=7)
|
||||
deleted_count = await monitoring_service.cleanup_old_logs(db, days=0)
|
||||
|
||||
if deleted_count > 0:
|
||||
await callback.answer(f"🗑️ Удалено {deleted_count} старых записей логов")
|
||||
await callback.answer(f"🗑️ Удалено {deleted_count} записей логов")
|
||||
else:
|
||||
await callback.answer("ℹ️ Нет старых логов для удаления")
|
||||
await callback.answer("ℹ️ Логи уже пусты")
|
||||
|
||||
await monitoring_logs_callback(callback)
|
||||
break
|
||||
@@ -196,7 +842,6 @@ async def clear_logs_callback(callback: CallbackQuery):
|
||||
@router.callback_query(F.data == "admin_mon_test_notifications")
|
||||
@admin_required
|
||||
async def test_notifications_callback(callback: CallbackQuery):
|
||||
"""Тест системы уведомлений"""
|
||||
try:
|
||||
test_message = f"""
|
||||
🧪 <b>Тестовое уведомление системы мониторинга</b>
|
||||
@@ -280,10 +925,62 @@ async def monitoring_statistics_callback(callback: CallbackQuery):
|
||||
await callback.answer(f"❌ Ошибка получения статистики: {str(e)}", show_alert=True)
|
||||
|
||||
|
||||
def get_monitoring_logs_keyboard(current_page: int, total_pages: int):
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
|
||||
keyboard = []
|
||||
|
||||
if total_pages > 1:
|
||||
nav_row = []
|
||||
|
||||
if current_page > 1:
|
||||
nav_row.append(InlineKeyboardButton(
|
||||
text="⬅️",
|
||||
callback_data=f"admin_mon_logs_page_{current_page - 1}"
|
||||
))
|
||||
|
||||
nav_row.append(InlineKeyboardButton(
|
||||
text=f"{current_page}/{total_pages}",
|
||||
callback_data="current_page"
|
||||
))
|
||||
|
||||
if current_page < total_pages:
|
||||
nav_row.append(InlineKeyboardButton(
|
||||
text="➡️",
|
||||
callback_data=f"admin_mon_logs_page_{current_page + 1}"
|
||||
))
|
||||
|
||||
keyboard.append(nav_row)
|
||||
|
||||
keyboard.extend([
|
||||
[
|
||||
InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_mon_logs"),
|
||||
InlineKeyboardButton(text="🗑️ Очистить", callback_data="admin_mon_clear_logs")
|
||||
],
|
||||
[InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_monitoring")]
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
def get_monitoring_logs_back_keyboard():
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_mon_logs"),
|
||||
InlineKeyboardButton(text="🔍 Фильтры", callback_data="admin_mon_logs_filters")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="🗑️ Очистить логи", callback_data="admin_mon_clear_logs")
|
||||
],
|
||||
[InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_monitoring")]
|
||||
])
|
||||
|
||||
|
||||
@router.message(Command("monitoring"))
|
||||
@admin_required
|
||||
async def monitoring_command(message: Message):
|
||||
"""Команда /monitoring для быстрого доступа"""
|
||||
try:
|
||||
async for db in get_db():
|
||||
status = await monitoring_service.get_monitoring_status(db)
|
||||
@@ -308,6 +1005,88 @@ async def monitoring_command(message: Message):
|
||||
await message.answer(f"❌ Ошибка: {str(e)}")
|
||||
|
||||
|
||||
@router.message(AdminStates.editing_notification_value)
|
||||
async def process_notification_value_input(message: Message, state: FSMContext):
|
||||
data = await state.get_data()
|
||||
if not data:
|
||||
await state.clear()
|
||||
await message.answer("ℹ️ Контекст утерян, попробуйте снова из меню настроек.")
|
||||
return
|
||||
|
||||
raw_value = (message.text or "").strip()
|
||||
try:
|
||||
value = int(raw_value)
|
||||
except (TypeError, ValueError):
|
||||
language = data.get("settings_language") or message.from_user.language_code or settings.DEFAULT_LANGUAGE
|
||||
texts = get_texts(language)
|
||||
await message.answer(texts.get("NOTIFICATION_VALUE_INVALID", "❌ Введите целое число."))
|
||||
return
|
||||
|
||||
key = data.get("notification_setting_key")
|
||||
field = data.get("notification_setting_field")
|
||||
language = data.get("settings_language") or message.from_user.language_code or settings.DEFAULT_LANGUAGE
|
||||
texts = get_texts(language)
|
||||
|
||||
# Добавляем дополнительные проверки диапазона значений
|
||||
if (key == "expired_second_wave" and field == "percent") or (key == "expired_third_wave" and field == "percent"):
|
||||
if value < 0 or value > 100:
|
||||
await message.answer("❌ Процент скидки должен быть от 0 до 100.")
|
||||
return
|
||||
elif (key == "expired_second_wave" and field == "hours") or (key == "expired_third_wave" and field == "hours"):
|
||||
if value < 1 or value > 168: # Максимум 168 часов (7 дней)
|
||||
await message.answer("❌ Количество часов должно быть от 1 до 168.")
|
||||
return
|
||||
elif key == "expired_third_wave" and field == "trigger":
|
||||
if value < 2: # Минимум 2 дня
|
||||
await message.answer("❌ Количество дней должно быть не менее 2.")
|
||||
return
|
||||
|
||||
success = False
|
||||
if key == "expired_second_wave" and field == "percent":
|
||||
success = NotificationSettingsService.set_second_wave_discount_percent(value)
|
||||
elif key == "expired_second_wave" and field == "hours":
|
||||
success = NotificationSettingsService.set_second_wave_valid_hours(value)
|
||||
elif key == "expired_third_wave" and field == "percent":
|
||||
success = NotificationSettingsService.set_third_wave_discount_percent(value)
|
||||
elif key == "expired_third_wave" and field == "hours":
|
||||
success = NotificationSettingsService.set_third_wave_valid_hours(value)
|
||||
elif key == "expired_third_wave" and field == "trigger":
|
||||
success = NotificationSettingsService.set_third_wave_trigger_days(value)
|
||||
|
||||
if not success:
|
||||
await message.answer(texts.get("NOTIFICATION_VALUE_INVALID", "❌ Некорректное значение, попробуйте снова."))
|
||||
return
|
||||
|
||||
back_keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.get("BACK", "⬅️ Назад"),
|
||||
callback_data="admin_mon_notify_settings",
|
||||
)
|
||||
]
|
||||
]
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
texts.get("NOTIFICATION_VALUE_UPDATED", "✅ Настройки обновлены."),
|
||||
reply_markup=back_keyboard,
|
||||
)
|
||||
|
||||
chat_id = data.get("settings_message_chat")
|
||||
message_id = data.get("settings_message_id")
|
||||
business_connection_id = data.get("settings_business_connection_id")
|
||||
if chat_id and message_id:
|
||||
await _render_notification_settings_for_state(
|
||||
message.bot,
|
||||
chat_id,
|
||||
message_id,
|
||||
language,
|
||||
business_connection_id=business_connection_id,
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
|
||||
|
||||
def register_handlers(dp):
|
||||
"""Регистрация обработчиков мониторинга"""
|
||||
dp.include_router(router)
|
||||
dp.include_router(router)
|
||||
|
||||
@@ -0,0 +1,612 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import math
|
||||
from typing import Optional
|
||||
|
||||
from aiogram import Dispatcher, F, types
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import PaymentMethod, User
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.services.payment_verification_service import (
|
||||
PendingPayment,
|
||||
SUPPORTED_MANUAL_CHECK_METHODS,
|
||||
get_payment_record,
|
||||
list_recent_pending_payments,
|
||||
run_manual_check,
|
||||
)
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
from app.utils.formatters import format_datetime, format_time_ago, format_username
|
||||
|
||||
|
||||
PAGE_SIZE = 6
|
||||
|
||||
|
||||
def _method_display(method: PaymentMethod) -> str:
|
||||
if method == PaymentMethod.MULENPAY:
|
||||
return settings.get_mulenpay_display_name()
|
||||
if method == PaymentMethod.PAL24:
|
||||
return "PayPalych"
|
||||
if method == PaymentMethod.WATA:
|
||||
return "WATA"
|
||||
if method == PaymentMethod.HELEKET:
|
||||
return "Heleket"
|
||||
if method == PaymentMethod.YOOKASSA:
|
||||
return "YooKassa"
|
||||
if method == PaymentMethod.PLATEGA:
|
||||
return "Platega"
|
||||
if method == PaymentMethod.CRYPTOBOT:
|
||||
return "CryptoBot"
|
||||
if method == PaymentMethod.TELEGRAM_STARS:
|
||||
return "Telegram Stars"
|
||||
return method.value
|
||||
|
||||
|
||||
def _status_info(
|
||||
record: PendingPayment,
|
||||
*,
|
||||
texts,
|
||||
) -> tuple[str, str]:
|
||||
status = (record.status or "").lower()
|
||||
|
||||
if record.is_paid:
|
||||
return "✅", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")
|
||||
|
||||
if record.method == PaymentMethod.PAL24:
|
||||
mapping = {
|
||||
"new": ("⏳", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")),
|
||||
"process": ("⌛", texts.t("ADMIN_PAYMENT_STATUS_PROCESSING", "⌛ Processing")),
|
||||
"success": ("✅", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")),
|
||||
"fail": ("❌", texts.t("ADMIN_PAYMENT_STATUS_FAILED", "❌ Failed")),
|
||||
"canceled": ("❌", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")),
|
||||
"cancel": ("❌", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")),
|
||||
}
|
||||
return mapping.get(status, ("❓", texts.t("ADMIN_PAYMENT_STATUS_UNKNOWN", "❓ Unknown")))
|
||||
|
||||
if record.method == PaymentMethod.MULENPAY:
|
||||
mapping = {
|
||||
"created": ("⏳", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")),
|
||||
"processing": ("⌛", texts.t("ADMIN_PAYMENT_STATUS_PROCESSING", "⌛ Processing")),
|
||||
"hold": ("🔒", texts.t("ADMIN_PAYMENT_STATUS_ON_HOLD", "🔒 Hold")),
|
||||
"success": ("✅", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")),
|
||||
"canceled": ("❌", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")),
|
||||
"cancel": ("❌", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")),
|
||||
"error": ("⚠️", texts.t("ADMIN_PAYMENT_STATUS_FAILED", "❌ Failed")),
|
||||
}
|
||||
return mapping.get(status, ("❓", texts.t("ADMIN_PAYMENT_STATUS_UNKNOWN", "❓ Unknown")))
|
||||
|
||||
if record.method == PaymentMethod.WATA:
|
||||
mapping = {
|
||||
"opened": ("⏳", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")),
|
||||
"pending": ("⏳", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")),
|
||||
"processing": ("⌛", texts.t("ADMIN_PAYMENT_STATUS_PROCESSING", "⌛ Processing")),
|
||||
"paid": ("✅", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")),
|
||||
"closed": ("✅", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")),
|
||||
"declined": ("❌", texts.t("ADMIN_PAYMENT_STATUS_FAILED", "❌ Failed")),
|
||||
"canceled": ("❌", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")),
|
||||
"expired": ("⌛", texts.t("ADMIN_PAYMENT_STATUS_EXPIRED", "⌛ Expired")),
|
||||
}
|
||||
return mapping.get(status, ("❓", texts.t("ADMIN_PAYMENT_STATUS_UNKNOWN", "❓ Unknown")))
|
||||
|
||||
if record.method == PaymentMethod.PLATEGA:
|
||||
mapping = {
|
||||
"pending": ("⏳", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")),
|
||||
"inprogress": ("⌛", texts.t("ADMIN_PAYMENT_STATUS_PROCESSING", "⌛ Processing")),
|
||||
"confirmed": ("✅", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")),
|
||||
"failed": ("❌", texts.t("ADMIN_PAYMENT_STATUS_FAILED", "❌ Failed")),
|
||||
"canceled": ("❌", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")),
|
||||
"cancelled": ("❌", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")),
|
||||
"expired": ("⌛", texts.t("ADMIN_PAYMENT_STATUS_EXPIRED", "⌛ Expired")),
|
||||
}
|
||||
return mapping.get(status, ("❓", texts.t("ADMIN_PAYMENT_STATUS_UNKNOWN", "❓ Unknown")))
|
||||
|
||||
if record.method == PaymentMethod.HELEKET:
|
||||
if status in {"pending", "created", "waiting", "check", "processing"}:
|
||||
return "⏳", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")
|
||||
if status in {"paid", "paid_over"}:
|
||||
return "✅", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")
|
||||
if status in {"cancel", "canceled", "fail", "failed", "expired"}:
|
||||
return "❌", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")
|
||||
return "❓", texts.t("ADMIN_PAYMENT_STATUS_UNKNOWN", "❓ Unknown")
|
||||
|
||||
if record.method == PaymentMethod.YOOKASSA:
|
||||
mapping = {
|
||||
"pending": ("⏳", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")),
|
||||
"waiting_for_capture": ("⌛", texts.t("ADMIN_PAYMENT_STATUS_PROCESSING", "⌛ Processing")),
|
||||
"succeeded": ("✅", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")),
|
||||
"canceled": ("❌", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")),
|
||||
}
|
||||
return mapping.get(status, ("❓", texts.t("ADMIN_PAYMENT_STATUS_UNKNOWN", "❓ Unknown")))
|
||||
|
||||
if record.method == PaymentMethod.CRYPTOBOT:
|
||||
mapping = {
|
||||
"active": ("⏳", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")),
|
||||
"paid": ("✅", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")),
|
||||
"expired": ("⌛", texts.t("ADMIN_PAYMENT_STATUS_EXPIRED", "⌛ Expired")),
|
||||
}
|
||||
return mapping.get(status, ("❓", texts.t("ADMIN_PAYMENT_STATUS_UNKNOWN", "❓ Unknown")))
|
||||
|
||||
if record.method == PaymentMethod.TELEGRAM_STARS:
|
||||
if record.is_paid:
|
||||
return "✅", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")
|
||||
return "⏳", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")
|
||||
|
||||
return "❓", texts.t("ADMIN_PAYMENT_STATUS_UNKNOWN", "❓ Unknown")
|
||||
|
||||
|
||||
def _is_checkable(record: PendingPayment) -> bool:
|
||||
if record.method not in SUPPORTED_MANUAL_CHECK_METHODS:
|
||||
return False
|
||||
if not record.is_recent():
|
||||
return False
|
||||
status = (record.status or "").lower()
|
||||
if record.method == PaymentMethod.PAL24:
|
||||
return status in {"new", "process"}
|
||||
if record.method == PaymentMethod.MULENPAY:
|
||||
return status in {"created", "processing", "hold"}
|
||||
if record.method == PaymentMethod.WATA:
|
||||
return status in {"opened", "pending", "processing", "inprogress", "in_progress"}
|
||||
if record.method == PaymentMethod.PLATEGA:
|
||||
return status in {"pending", "inprogress", "in_progress"}
|
||||
if record.method == PaymentMethod.HELEKET:
|
||||
return status not in {"paid", "paid_over", "cancel", "canceled", "fail", "failed", "expired"}
|
||||
if record.method == PaymentMethod.YOOKASSA:
|
||||
return status in {"pending", "waiting_for_capture"}
|
||||
if record.method == PaymentMethod.CRYPTOBOT:
|
||||
return status in {"active"}
|
||||
return False
|
||||
|
||||
|
||||
def _record_display_number(record: PendingPayment) -> str:
|
||||
if record.identifier:
|
||||
return str(record.identifier)
|
||||
return str(record.local_id)
|
||||
|
||||
|
||||
def _build_list_keyboard(
|
||||
records: list[PendingPayment],
|
||||
*,
|
||||
page: int,
|
||||
total_pages: int,
|
||||
language: str,
|
||||
) -> InlineKeyboardMarkup:
|
||||
buttons: list[list[InlineKeyboardButton]] = []
|
||||
texts = get_texts(language)
|
||||
|
||||
for record in records:
|
||||
number = _record_display_number(record)
|
||||
details_template = texts.t("ADMIN_PAYMENTS_ITEM_DETAILS", "📄 #{number}")
|
||||
try:
|
||||
button_text = details_template.format(number=number)
|
||||
except Exception: # pragma: no cover - fallback for broken localization
|
||||
button_text = f"📄 {number}"
|
||||
buttons.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=button_text,
|
||||
callback_data=f"admin_payment_{record.method.value}_{record.local_id}",
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
if total_pages > 1:
|
||||
navigation_row: list[InlineKeyboardButton] = []
|
||||
if page > 1:
|
||||
navigation_row.append(
|
||||
InlineKeyboardButton(
|
||||
text="⬅️",
|
||||
callback_data=f"admin_payments_page_{page - 1}",
|
||||
)
|
||||
)
|
||||
|
||||
navigation_row.append(
|
||||
InlineKeyboardButton(
|
||||
text=f"{page}/{total_pages}",
|
||||
callback_data="admin_payments_page_current",
|
||||
)
|
||||
)
|
||||
|
||||
if page < total_pages:
|
||||
navigation_row.append(
|
||||
InlineKeyboardButton(
|
||||
text="➡️",
|
||||
callback_data=f"admin_payments_page_{page + 1}",
|
||||
)
|
||||
)
|
||||
|
||||
buttons.append(navigation_row)
|
||||
|
||||
buttons.append([InlineKeyboardButton(text=texts.BACK, callback_data="admin_panel")])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
|
||||
def _build_detail_keyboard(
|
||||
record: PendingPayment,
|
||||
*,
|
||||
language: str,
|
||||
) -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
rows: list[list[InlineKeyboardButton]] = []
|
||||
|
||||
payment = record.payment
|
||||
payment_url = getattr(payment, "payment_url", None)
|
||||
if record.method == PaymentMethod.PAL24:
|
||||
payment_url = payment.link_url or payment.link_page_url or payment_url
|
||||
elif record.method == PaymentMethod.WATA:
|
||||
payment_url = payment.url or payment_url
|
||||
elif record.method == PaymentMethod.YOOKASSA:
|
||||
payment_url = getattr(payment, "confirmation_url", None) or payment_url
|
||||
elif record.method == PaymentMethod.CRYPTOBOT:
|
||||
payment_url = (
|
||||
payment.bot_invoice_url
|
||||
or payment.mini_app_invoice_url
|
||||
or payment.web_app_invoice_url
|
||||
or payment_url
|
||||
)
|
||||
|
||||
if payment_url:
|
||||
rows.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("ADMIN_PAYMENT_OPEN_LINK", "🔗 Open link"),
|
||||
url=payment_url,
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
if _is_checkable(record):
|
||||
rows.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("ADMIN_PAYMENT_CHECK_BUTTON", "🔁 Check status"),
|
||||
callback_data=f"admin_payment_check_{record.method.value}_{record.local_id}",
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
rows.append([InlineKeyboardButton(text=texts.BACK, callback_data="admin_payments")])
|
||||
return InlineKeyboardMarkup(inline_keyboard=rows)
|
||||
|
||||
|
||||
def _format_user_line(user: User) -> str:
|
||||
username = format_username(user.username, user.telegram_id, user.full_name)
|
||||
return f"👤 {html.escape(username)} (<code>{user.telegram_id}</code>)"
|
||||
|
||||
|
||||
def _build_record_lines(
|
||||
record: PendingPayment,
|
||||
*,
|
||||
index: int,
|
||||
texts,
|
||||
language: str,
|
||||
) -> list[str]:
|
||||
amount = settings.format_price(record.amount_kopeks)
|
||||
if record.method == PaymentMethod.CRYPTOBOT:
|
||||
crypto_amount = getattr(record.payment, "amount", None)
|
||||
crypto_asset = getattr(record.payment, "asset", None)
|
||||
if crypto_amount and crypto_asset:
|
||||
amount = f"{crypto_amount} {crypto_asset}"
|
||||
method_name = _method_display(record.method)
|
||||
emoji, status_text = _status_info(record, texts=texts)
|
||||
created = format_datetime(record.created_at)
|
||||
age = format_time_ago(record.created_at, language)
|
||||
identifier = (
|
||||
html.escape(str(record.identifier)) if record.identifier else ""
|
||||
)
|
||||
display_number = html.escape(_record_display_number(record))
|
||||
|
||||
lines = [
|
||||
f"{index}. <b>{html.escape(method_name)}</b> — {amount}",
|
||||
f" {emoji} {status_text}",
|
||||
f" 🕒 {created} ({age})",
|
||||
_format_user_line(record.user),
|
||||
]
|
||||
|
||||
if identifier:
|
||||
lines.append(f" 🆔 ID: <code>{identifier}</code>")
|
||||
else:
|
||||
lines.append(f" 🆔 ID: <code>{display_number}</code>")
|
||||
|
||||
return lines
|
||||
|
||||
|
||||
def _build_payment_details_text(record: PendingPayment, *, texts, language: str) -> str:
|
||||
method_name = _method_display(record.method)
|
||||
emoji, status_text = _status_info(record, texts=texts)
|
||||
amount = settings.format_price(record.amount_kopeks)
|
||||
if record.method == PaymentMethod.CRYPTOBOT:
|
||||
crypto_amount = getattr(record.payment, "amount", None)
|
||||
crypto_asset = getattr(record.payment, "asset", None)
|
||||
if crypto_amount and crypto_asset:
|
||||
amount = f"{crypto_amount} {crypto_asset}"
|
||||
created = format_datetime(record.created_at)
|
||||
age = format_time_ago(record.created_at, language)
|
||||
raw_identifier = record.identifier if record.identifier else record.local_id
|
||||
identifier = html.escape(str(raw_identifier)) if raw_identifier is not None else "—"
|
||||
lines = [
|
||||
texts.t("ADMIN_PAYMENT_DETAILS_TITLE", "💳 <b>Payment details</b>"),
|
||||
"",
|
||||
f"<b>{html.escape(method_name)}</b>",
|
||||
f"{emoji} {status_text}",
|
||||
"",
|
||||
f"💰 {texts.t('ADMIN_PAYMENT_AMOUNT', 'Amount')}: {amount}",
|
||||
f"🕒 {texts.t('ADMIN_PAYMENT_CREATED', 'Created')}: {created} ({age})",
|
||||
f"🆔 ID: <code>{identifier}</code>",
|
||||
_format_user_line(record.user),
|
||||
]
|
||||
|
||||
if record.expires_at:
|
||||
expires_at = format_datetime(record.expires_at)
|
||||
lines.append(f"⏳ {texts.t('ADMIN_PAYMENT_EXPIRES', 'Expires')}: {expires_at}")
|
||||
|
||||
payment = record.payment
|
||||
|
||||
if record.method == PaymentMethod.PAL24:
|
||||
if getattr(payment, "payment_status", None):
|
||||
lines.append(
|
||||
f"💳 {texts.t('ADMIN_PAYMENT_GATEWAY_STATUS', 'Gateway status')}: "
|
||||
f"{html.escape(str(payment.payment_status))}"
|
||||
)
|
||||
if getattr(payment, "payment_method", None):
|
||||
lines.append(
|
||||
f"🏦 {texts.t('ADMIN_PAYMENT_GATEWAY_METHOD', 'Method')}: "
|
||||
f"{html.escape(str(payment.payment_method))}"
|
||||
)
|
||||
if getattr(payment, "balance_amount", None):
|
||||
lines.append(
|
||||
f"💱 {texts.t('ADMIN_PAYMENT_GATEWAY_AMOUNT', 'Gateway amount')}: "
|
||||
f"{html.escape(str(payment.balance_amount))}"
|
||||
)
|
||||
if getattr(payment, "payer_account", None):
|
||||
lines.append(
|
||||
f"👛 {texts.t('ADMIN_PAYMENT_GATEWAY_ACCOUNT', 'Payer account')}: "
|
||||
f"{html.escape(str(payment.payer_account))}"
|
||||
)
|
||||
|
||||
if record.method == PaymentMethod.MULENPAY:
|
||||
if getattr(payment, "mulen_payment_id", None):
|
||||
lines.append(
|
||||
f"🧾 {texts.t('ADMIN_PAYMENT_GATEWAY_ID', 'Gateway ID')}: "
|
||||
f"{html.escape(str(payment.mulen_payment_id))}"
|
||||
)
|
||||
|
||||
if record.method == PaymentMethod.WATA:
|
||||
if getattr(payment, "order_id", None):
|
||||
lines.append(
|
||||
f"🧾 {texts.t('ADMIN_PAYMENT_GATEWAY_ID', 'Gateway ID')}: "
|
||||
f"{html.escape(str(payment.order_id))}"
|
||||
)
|
||||
if getattr(payment, "terminal_public_id", None):
|
||||
lines.append(
|
||||
f"🏦 Terminal: {html.escape(str(payment.terminal_public_id))}"
|
||||
)
|
||||
|
||||
if record.method == PaymentMethod.HELEKET:
|
||||
if getattr(payment, "order_id", None):
|
||||
lines.append(
|
||||
f"🧾 {texts.t('ADMIN_PAYMENT_GATEWAY_ID', 'Gateway ID')}: "
|
||||
f"{html.escape(str(payment.order_id))}"
|
||||
)
|
||||
if getattr(payment, "payer_amount", None) and getattr(payment, "payer_currency", None):
|
||||
lines.append(
|
||||
f"🪙 {texts.t('ADMIN_PAYMENT_PAYER_AMOUNT', 'Paid amount')}: "
|
||||
f"{html.escape(str(payment.payer_amount))} {html.escape(str(payment.payer_currency))}"
|
||||
)
|
||||
|
||||
if record.method == PaymentMethod.YOOKASSA:
|
||||
if getattr(payment, "payment_method_type", None):
|
||||
lines.append(
|
||||
f"💳 {texts.t('ADMIN_PAYMENT_GATEWAY_METHOD', 'Method')}: "
|
||||
f"{html.escape(str(payment.payment_method_type))}"
|
||||
)
|
||||
if getattr(payment, "confirmation_url", None):
|
||||
lines.append(texts.t("ADMIN_PAYMENT_HAS_LINK", "🔗 Payment link is available above."))
|
||||
|
||||
if record.method == PaymentMethod.CRYPTOBOT:
|
||||
if getattr(payment, "amount", None) and getattr(payment, "asset", None):
|
||||
lines.append(
|
||||
f"🪙 {texts.t('ADMIN_PAYMENT_CRYPTO_AMOUNT', 'Crypto amount')}: "
|
||||
f"{html.escape(str(payment.amount))} {html.escape(str(payment.asset))}"
|
||||
)
|
||||
if getattr(payment, "bot_invoice_url", None) or getattr(payment, "mini_app_invoice_url", None):
|
||||
lines.append(
|
||||
texts.t("ADMIN_PAYMENT_HAS_LINK", "🔗 Payment link is available above.")
|
||||
)
|
||||
if getattr(payment, "status", None):
|
||||
lines.append(
|
||||
f"📊 {texts.t('ADMIN_PAYMENT_GATEWAY_STATUS', 'Gateway status')}: "
|
||||
f"{html.escape(str(payment.status))}"
|
||||
)
|
||||
|
||||
if record.method == PaymentMethod.TELEGRAM_STARS:
|
||||
description = getattr(payment, "description", "") or ""
|
||||
if description:
|
||||
lines.append(f"📝 {html.escape(description)}")
|
||||
if getattr(payment, "external_id", None):
|
||||
lines.append(
|
||||
f"🧾 {texts.t('ADMIN_PAYMENT_GATEWAY_ID', 'Gateway ID')}: "
|
||||
f"{html.escape(str(payment.external_id))}"
|
||||
)
|
||||
|
||||
if _is_checkable(record):
|
||||
lines.append("")
|
||||
lines.append(texts.t("ADMIN_PAYMENT_CHECK_HINT", "ℹ️ You can trigger a manual status check."))
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _parse_method_and_id(payload: str, *, prefix: str) -> Optional[tuple[PaymentMethod, int]]:
|
||||
suffix = payload[len(prefix) :]
|
||||
try:
|
||||
method_str, identifier = suffix.rsplit("_", 1)
|
||||
method = PaymentMethod(method_str)
|
||||
payment_id = int(identifier)
|
||||
return method, payment_id
|
||||
except (ValueError, KeyError):
|
||||
return None
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_payments_overview(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
) -> None:
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
page = 1
|
||||
if callback.data.startswith("admin_payments_page_"):
|
||||
try:
|
||||
page = int(callback.data.split("_")[-1])
|
||||
except ValueError:
|
||||
page = 1
|
||||
|
||||
records = await list_recent_pending_payments(db)
|
||||
total = len(records)
|
||||
total_pages = max(1, math.ceil(total / PAGE_SIZE))
|
||||
if page < 1:
|
||||
page = 1
|
||||
if page > total_pages:
|
||||
page = total_pages
|
||||
|
||||
start_index = (page - 1) * PAGE_SIZE
|
||||
page_records = records[start_index : start_index + PAGE_SIZE]
|
||||
|
||||
header = texts.t("ADMIN_PAYMENTS_TITLE", "💳 <b>Top-up verification</b>")
|
||||
description = texts.t(
|
||||
"ADMIN_PAYMENTS_DESCRIPTION",
|
||||
"Pending invoices created during the last 24 hours.",
|
||||
)
|
||||
notice = texts.t(
|
||||
"ADMIN_PAYMENTS_NOTICE",
|
||||
"Only invoices younger than 24 hours and waiting for payment can be checked.",
|
||||
)
|
||||
|
||||
lines = [header, "", description]
|
||||
|
||||
if page_records:
|
||||
for idx, record in enumerate(page_records, start=start_index + 1):
|
||||
lines.extend(_build_record_lines(record, index=idx, texts=texts, language=db_user.language))
|
||||
lines.append("")
|
||||
lines.append(notice)
|
||||
else:
|
||||
empty_text = texts.t("ADMIN_PAYMENTS_EMPTY", "No pending top-ups in the last 24 hours.")
|
||||
lines.append("")
|
||||
lines.append(empty_text)
|
||||
|
||||
keyboard = _build_list_keyboard(
|
||||
page_records,
|
||||
page=page,
|
||||
total_pages=total_pages,
|
||||
language=db_user.language,
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
"\n".join(line for line in lines if line is not None),
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def _render_payment_details(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
record: PendingPayment,
|
||||
) -> None:
|
||||
texts = get_texts(db_user.language)
|
||||
text = _build_payment_details_text(record, texts=texts, language=db_user.language)
|
||||
keyboard = _build_detail_keyboard(record, language=db_user.language)
|
||||
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_payment_details(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
) -> None:
|
||||
parsed = _parse_method_and_id(callback.data, prefix="admin_payment_")
|
||||
if not parsed:
|
||||
await callback.answer("❌ Invalid payment reference", show_alert=True)
|
||||
return
|
||||
|
||||
method, payment_id = parsed
|
||||
record = await get_payment_record(db, method, payment_id)
|
||||
if not record:
|
||||
await callback.answer("❌ Платеж не найден", show_alert=True)
|
||||
return
|
||||
|
||||
await _render_payment_details(callback, db_user, record)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def manual_check_payment(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
) -> None:
|
||||
parsed = _parse_method_and_id(callback.data, prefix="admin_payment_check_")
|
||||
if not parsed:
|
||||
await callback.answer("❌ Invalid payment reference", show_alert=True)
|
||||
return
|
||||
|
||||
method, payment_id = parsed
|
||||
record = await get_payment_record(db, method, payment_id)
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not record:
|
||||
await callback.answer(texts.t("ADMIN_PAYMENT_NOT_FOUND", "Payment not found."), show_alert=True)
|
||||
return
|
||||
|
||||
if not _is_checkable(record):
|
||||
await callback.answer(
|
||||
texts.t("ADMIN_PAYMENT_CHECK_NOT_AVAILABLE", "Manual check is not available for this invoice."),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
payment_service = PaymentService(callback.bot)
|
||||
updated = await run_manual_check(db, method, payment_id, payment_service)
|
||||
|
||||
if not updated:
|
||||
await callback.answer(
|
||||
texts.t("ADMIN_PAYMENT_CHECK_FAILED", "Failed to refresh the payment status."),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
await _render_payment_details(callback, db_user, updated)
|
||||
|
||||
if updated.status != record.status or updated.is_paid != record.is_paid:
|
||||
emoji, status_text = _status_info(updated, texts=texts)
|
||||
message = texts.t(
|
||||
"ADMIN_PAYMENT_CHECK_SUCCESS",
|
||||
"Status updated: {status}",
|
||||
).format(status=f"{emoji} {status_text}")
|
||||
else:
|
||||
message = texts.t(
|
||||
"ADMIN_PAYMENT_CHECK_NO_CHANGES",
|
||||
"Status is unchanged after the check.",
|
||||
)
|
||||
|
||||
await callback.answer(message, show_alert=True)
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher) -> None:
|
||||
dp.callback_query.register(manual_check_payment, F.data.startswith("admin_payment_check_"))
|
||||
dp.callback_query.register(
|
||||
show_payment_details,
|
||||
F.data.startswith("admin_payment_") & ~F.data.startswith("admin_payment_check_"),
|
||||
)
|
||||
dp.callback_query.register(show_payments_overview, F.data.startswith("admin_payments_page_"))
|
||||
dp.callback_query.register(show_payments_overview, F.data == "admin_payments")
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,512 @@
|
||||
import html
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import User
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.privacy_policy_service import PrivacyPolicyService
|
||||
from app.states import AdminStates
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
from app.utils.validators import validate_html_tags, get_html_help_text
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _format_timestamp(value: datetime | None) -> str:
|
||||
if not value:
|
||||
return ""
|
||||
try:
|
||||
return value.strftime("%d.%m.%Y %H:%M")
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
async def _build_overview(
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
policy = await PrivacyPolicyService.get_policy(
|
||||
db,
|
||||
db_user.language,
|
||||
fallback=False,
|
||||
)
|
||||
|
||||
normalized_language = PrivacyPolicyService.normalize_language(db_user.language)
|
||||
has_content = bool(policy and policy.content and policy.content.strip())
|
||||
|
||||
description = texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_DESCRIPTION",
|
||||
"Политика конфиденциальности отображается в разделе «Инфо».",
|
||||
)
|
||||
|
||||
status_text = texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_STATUS_DISABLED",
|
||||
"⚠️ Показ политики выключен или текст отсутствует.",
|
||||
)
|
||||
if policy and policy.is_enabled and has_content:
|
||||
status_text = texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_STATUS_ENABLED",
|
||||
"✅ Политика активна и показывается пользователям.",
|
||||
)
|
||||
elif policy and policy.is_enabled:
|
||||
status_text = texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_STATUS_ENABLED_EMPTY",
|
||||
"⚠️ Политика включена, но текст пуст — пользователи её не увидят.",
|
||||
)
|
||||
|
||||
updated_at = _format_timestamp(getattr(policy, "updated_at", None))
|
||||
updated_block = ""
|
||||
if updated_at:
|
||||
updated_block = texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_UPDATED_AT",
|
||||
"Последнее обновление: {timestamp}",
|
||||
).format(timestamp=updated_at)
|
||||
|
||||
preview_block = texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_PREVIEW_EMPTY",
|
||||
"Текст ещё не задан.",
|
||||
)
|
||||
if has_content:
|
||||
preview_title = texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_PREVIEW_TITLE",
|
||||
"<b>Превью текста:</b>",
|
||||
)
|
||||
preview_raw = policy.content.strip()
|
||||
preview_trimmed = preview_raw[:400]
|
||||
if len(preview_raw) > 400:
|
||||
preview_trimmed += "..."
|
||||
preview_block = (
|
||||
f"{preview_title}\n"
|
||||
f"<code>{html.escape(preview_trimmed)}</code>"
|
||||
)
|
||||
|
||||
language_block = texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_LANGUAGE",
|
||||
"Язык: <code>{lang}</code>",
|
||||
).format(lang=normalized_language)
|
||||
|
||||
header = texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_HEADER",
|
||||
"🛡️ <b>Политика конфиденциальности</b>",
|
||||
)
|
||||
actions_prompt = texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_ACTION_PROMPT",
|
||||
"Выберите действие:",
|
||||
)
|
||||
|
||||
message_parts = [
|
||||
header,
|
||||
description,
|
||||
language_block,
|
||||
status_text,
|
||||
]
|
||||
|
||||
if updated_block:
|
||||
message_parts.append(updated_block)
|
||||
|
||||
message_parts.append(preview_block)
|
||||
message_parts.append(actions_prompt)
|
||||
|
||||
overview_text = "\n\n".join(part for part in message_parts if part)
|
||||
|
||||
buttons: list[list[types.InlineKeyboardButton]] = []
|
||||
|
||||
buttons.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_EDIT_BUTTON",
|
||||
"✏️ Изменить текст",
|
||||
),
|
||||
callback_data="admin_privacy_policy_edit",
|
||||
)
|
||||
])
|
||||
|
||||
if has_content:
|
||||
buttons.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_VIEW_BUTTON",
|
||||
"👀 Просмотреть текущий текст",
|
||||
),
|
||||
callback_data="admin_privacy_policy_view",
|
||||
)
|
||||
])
|
||||
|
||||
toggle_text = texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_ENABLE_BUTTON",
|
||||
"✅ Включить показ",
|
||||
)
|
||||
if policy and policy.is_enabled:
|
||||
toggle_text = texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_DISABLE_BUTTON",
|
||||
"🚫 Отключить показ",
|
||||
)
|
||||
|
||||
buttons.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=toggle_text,
|
||||
callback_data="admin_privacy_policy_toggle",
|
||||
)
|
||||
])
|
||||
|
||||
buttons.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_HTML_HELP",
|
||||
"ℹ️ HTML помощь",
|
||||
),
|
||||
callback_data="admin_privacy_policy_help",
|
||||
)
|
||||
])
|
||||
|
||||
buttons.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.BACK,
|
||||
callback_data="admin_submenu_settings",
|
||||
)
|
||||
])
|
||||
|
||||
return overview_text, types.InlineKeyboardMarkup(inline_keyboard=buttons), policy
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_privacy_policy_management(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
overview_text, markup, _ = await _build_overview(db_user, db)
|
||||
|
||||
await callback.message.edit_text(
|
||||
overview_text,
|
||||
reply_markup=markup,
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def toggle_privacy_policy(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
updated_policy = await PrivacyPolicyService.toggle_enabled(db, db_user.language)
|
||||
logger.info(
|
||||
"Админ %s переключил показ политики конфиденциальности: %s",
|
||||
db_user.telegram_id,
|
||||
"enabled" if updated_policy.is_enabled else "disabled",
|
||||
)
|
||||
status_message = (
|
||||
texts.t("ADMIN_PRIVACY_POLICY_ENABLED", "✅ Политика включена")
|
||||
if updated_policy.is_enabled
|
||||
else texts.t("ADMIN_PRIVACY_POLICY_DISABLED", "🚫 Политика отключена")
|
||||
)
|
||||
|
||||
overview_text, markup, _ = await _build_overview(db_user, db)
|
||||
await callback.message.edit_text(
|
||||
overview_text,
|
||||
reply_markup=markup,
|
||||
)
|
||||
await callback.answer(status_message)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_edit_privacy_policy(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
policy = await PrivacyPolicyService.get_policy(
|
||||
db,
|
||||
db_user.language,
|
||||
fallback=False,
|
||||
)
|
||||
|
||||
current_preview = ""
|
||||
if policy and policy.content:
|
||||
preview = policy.content.strip()[:400]
|
||||
if len(policy.content.strip()) > 400:
|
||||
preview += "..."
|
||||
current_preview = (
|
||||
texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_CURRENT_PREVIEW",
|
||||
"Текущий текст (превью):",
|
||||
)
|
||||
+ f"\n<code>{html.escape(preview)}</code>\n\n"
|
||||
)
|
||||
|
||||
prompt = texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_EDIT_PROMPT",
|
||||
"Отправьте новый текст политики конфиденциальности. Допускается HTML-разметка.",
|
||||
)
|
||||
|
||||
hint = texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_EDIT_HINT",
|
||||
"Используйте /html_help для справки по тегам.",
|
||||
)
|
||||
|
||||
message_text = (
|
||||
f"📝 <b>{texts.t('ADMIN_PRIVACY_POLICY_EDIT_TITLE', 'Редактирование политики')}</b>\n\n"
|
||||
f"{current_preview}{prompt}\n\n{hint}"
|
||||
)
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_HTML_HELP",
|
||||
"ℹ️ HTML помощь",
|
||||
),
|
||||
callback_data="admin_privacy_policy_help",
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("ADMIN_PRIVACY_POLICY_CANCEL", "❌ Отмена"),
|
||||
callback_data="admin_privacy_policy_cancel",
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
await callback.message.edit_text(message_text, reply_markup=keyboard)
|
||||
await state.set_state(AdminStates.editing_privacy_policy)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def cancel_edit_privacy_policy(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession,
|
||||
):
|
||||
await state.clear()
|
||||
overview_text, markup, _ = await _build_overview(db_user, db)
|
||||
await callback.message.edit_text(
|
||||
overview_text,
|
||||
reply_markup=markup,
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_privacy_policy_edit(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
new_text = message.text or ""
|
||||
|
||||
if len(new_text) > 4000:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_TOO_LONG",
|
||||
"❌ Текст политики слишком длинный. Максимум 4000 символов.",
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
is_valid, error_message = validate_html_tags(new_text)
|
||||
if not is_valid:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_HTML_ERROR",
|
||||
"❌ Ошибка в HTML: {error}",
|
||||
).format(error=error_message)
|
||||
)
|
||||
return
|
||||
|
||||
await PrivacyPolicyService.save_policy(db, db_user.language, new_text)
|
||||
logger.info(
|
||||
"Админ %s обновил текст политики конфиденциальности (%d символов)",
|
||||
db_user.telegram_id,
|
||||
len(new_text),
|
||||
)
|
||||
await state.clear()
|
||||
|
||||
success_text = texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_SAVED",
|
||||
"✅ Политика конфиденциальности обновлена.",
|
||||
)
|
||||
|
||||
reply_markup = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_BACK_BUTTON",
|
||||
"⬅️ К настройкам политики",
|
||||
),
|
||||
callback_data="admin_privacy_policy",
|
||||
)
|
||||
]
|
||||
]
|
||||
)
|
||||
|
||||
await message.answer(success_text, reply_markup=reply_markup)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def view_privacy_policy(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
policy = await PrivacyPolicyService.get_policy(
|
||||
db,
|
||||
db_user.language,
|
||||
fallback=False,
|
||||
)
|
||||
|
||||
if not policy or not policy.content or not policy.content.strip():
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_PREVIEW_EMPTY_ALERT",
|
||||
"Текст политики пока не задан.",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
content = policy.content.strip()
|
||||
truncated = False
|
||||
max_length = 3800
|
||||
if len(content) > max_length:
|
||||
content = content[: max_length - 3] + "..."
|
||||
truncated = True
|
||||
|
||||
header = texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_VIEW_TITLE",
|
||||
"👀 <b>Текущий текст политики</b>",
|
||||
)
|
||||
|
||||
note = ""
|
||||
if truncated:
|
||||
note = texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_VIEW_TRUNCATED",
|
||||
"\n\n⚠️ Текст сокращён для отображения. Полную версию увидят пользователи в меню.",
|
||||
)
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_BACK_BUTTON",
|
||||
"⬅️ К настройкам политики",
|
||||
),
|
||||
callback_data="admin_privacy_policy",
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_EDIT_BUTTON",
|
||||
"✏️ Изменить текст",
|
||||
),
|
||||
callback_data="admin_privacy_policy_edit",
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"{header}\n\n{content}{note}",
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_privacy_policy_html_help(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
help_text = get_html_help_text()
|
||||
|
||||
current_state = await state.get_state()
|
||||
|
||||
buttons: list[list[types.InlineKeyboardButton]] = []
|
||||
|
||||
if current_state == AdminStates.editing_privacy_policy.state:
|
||||
buttons.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_RETURN_TO_EDIT",
|
||||
"⬅️ Назад к редактированию",
|
||||
),
|
||||
callback_data="admin_privacy_policy_edit",
|
||||
)
|
||||
])
|
||||
|
||||
buttons.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_PRIVACY_POLICY_BACK_BUTTON",
|
||||
"⬅️ К настройкам политики",
|
||||
),
|
||||
callback_data="admin_privacy_policy",
|
||||
)
|
||||
])
|
||||
|
||||
await callback.message.edit_text(
|
||||
help_text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=buttons),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher) -> None:
|
||||
dp.callback_query.register(
|
||||
show_privacy_policy_management,
|
||||
F.data == "admin_privacy_policy",
|
||||
)
|
||||
dp.callback_query.register(
|
||||
toggle_privacy_policy,
|
||||
F.data == "admin_privacy_policy_toggle",
|
||||
)
|
||||
dp.callback_query.register(
|
||||
start_edit_privacy_policy,
|
||||
F.data == "admin_privacy_policy_edit",
|
||||
)
|
||||
dp.callback_query.register(
|
||||
cancel_edit_privacy_policy,
|
||||
F.data == "admin_privacy_policy_cancel",
|
||||
)
|
||||
dp.callback_query.register(
|
||||
view_privacy_policy,
|
||||
F.data == "admin_privacy_policy_view",
|
||||
)
|
||||
dp.callback_query.register(
|
||||
show_privacy_policy_html_help,
|
||||
F.data == "admin_privacy_policy_help",
|
||||
)
|
||||
|
||||
dp.message.register(
|
||||
process_privacy_policy_edit,
|
||||
AdminStates.editing_privacy_policy,
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -15,8 +15,9 @@ from app.localization.texts import get_texts
|
||||
from app.database.crud.promocode import (
|
||||
get_promocodes_list, get_promocodes_count, create_promocode,
|
||||
get_promocode_statistics, get_promocode_by_code, update_promocode,
|
||||
delete_promocode
|
||||
delete_promocode, get_promocode_by_id
|
||||
)
|
||||
from app.database.crud.promo_group import get_promo_group_by_id, get_promo_groups_with_counts
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
from app.utils.formatters import format_datetime
|
||||
|
||||
@@ -81,16 +82,24 @@ async def show_promocodes_list(
|
||||
|
||||
for promo in promocodes:
|
||||
status_emoji = "✅" if promo.is_active else "❌"
|
||||
type_emoji = {"balance": "💰", "subscription_days": "📅", "trial_subscription": "🎁"}.get(promo.type, "🎫")
|
||||
|
||||
type_emoji = {
|
||||
"balance": "💰",
|
||||
"subscription_days": "📅",
|
||||
"trial_subscription": "🎁",
|
||||
"promo_group": "🏷️"
|
||||
}.get(promo.type, "🎫")
|
||||
|
||||
text += f"{status_emoji} {type_emoji} <code>{promo.code}</code>\n"
|
||||
text += f"📊 Использований: {promo.current_uses}/{promo.max_uses}\n"
|
||||
|
||||
|
||||
if promo.type == PromoCodeType.BALANCE.value:
|
||||
text += f"💰 Бонус: {settings.format_price(promo.balance_bonus_kopeks)}\n"
|
||||
elif promo.type == PromoCodeType.SUBSCRIPTION_DAYS.value:
|
||||
text += f"📅 Дней: {promo.subscription_days}\n"
|
||||
|
||||
elif promo.type == PromoCodeType.PROMO_GROUP.value:
|
||||
if promo.promo_group:
|
||||
text += f"🏷️ Промогруппа: {promo.promo_group.name}\n"
|
||||
|
||||
if promo.valid_until:
|
||||
text += f"⏰ До: {format_datetime(promo.valid_until)}\n"
|
||||
|
||||
@@ -129,15 +138,20 @@ async def show_promocode_management(
|
||||
db: AsyncSession
|
||||
):
|
||||
promo_id = int(callback.data.split('_')[-1])
|
||||
|
||||
promo = await db.get(PromoCode, promo_id)
|
||||
|
||||
promo = await get_promocode_by_id(db, promo_id)
|
||||
if not promo:
|
||||
await callback.answer("❌ Промокод не найден", show_alert=True)
|
||||
return
|
||||
|
||||
status_emoji = "✅" if promo.is_active else "❌"
|
||||
type_emoji = {"balance": "💰", "subscription_days": "📅", "trial_subscription": "🎁"}.get(promo.type, "🎫")
|
||||
|
||||
type_emoji = {
|
||||
"balance": "💰",
|
||||
"subscription_days": "📅",
|
||||
"trial_subscription": "🎁",
|
||||
"promo_group": "🏷️"
|
||||
}.get(promo.type, "🎫")
|
||||
|
||||
text = f"""
|
||||
🎫 <b>Управление промокодом</b>
|
||||
|
||||
@@ -145,12 +159,17 @@ async def show_promocode_management(
|
||||
{status_emoji} <b>Статус:</b> {'Активен' if promo.is_active else 'Неактивен'}
|
||||
📊 <b>Использований:</b> {promo.current_uses}/{promo.max_uses}
|
||||
"""
|
||||
|
||||
|
||||
if promo.type == PromoCodeType.BALANCE.value:
|
||||
text += f"💰 <b>Бонус:</b> {settings.format_price(promo.balance_bonus_kopeks)}\n"
|
||||
elif promo.type == PromoCodeType.SUBSCRIPTION_DAYS.value:
|
||||
text += f"📅 <b>Дней:</b> {promo.subscription_days}\n"
|
||||
|
||||
elif promo.type == PromoCodeType.PROMO_GROUP.value:
|
||||
if promo.promo_group:
|
||||
text += f"🏷️ <b>Промогруппа:</b> {promo.promo_group.name} (приоритет: {promo.promo_group.priority})\n"
|
||||
elif promo.promo_group_id:
|
||||
text += f"🏷️ <b>Промогруппа ID:</b> {promo.promo_group_id} (не найдена)\n"
|
||||
|
||||
if promo.valid_until:
|
||||
text += f"⏰ <b>Действует до:</b> {format_datetime(promo.valid_until)}\n"
|
||||
|
||||
@@ -200,8 +219,8 @@ async def show_promocode_edit_menu(
|
||||
except (ValueError, IndexError):
|
||||
await callback.answer("❌ Ошибка получения ID промокода", show_alert=True)
|
||||
return
|
||||
|
||||
promo = await db.get(PromoCode, promo_id)
|
||||
|
||||
promo = await get_promocode_by_id(db, promo_id)
|
||||
if not promo:
|
||||
await callback.answer("❌ Промокод не найден", show_alert=True)
|
||||
return
|
||||
@@ -445,13 +464,14 @@ async def select_promocode_type(
|
||||
state: FSMContext
|
||||
):
|
||||
promo_type = callback.data.split('_')[-1]
|
||||
|
||||
|
||||
type_names = {
|
||||
"balance": "💰 Пополнение баланса",
|
||||
"days": "📅 Дни подписки",
|
||||
"trial": "🎁 Тестовая подписка"
|
||||
"days": "📅 Дни подписки",
|
||||
"trial": "🎁 Тестовая подписка",
|
||||
"group": "🏷️ Промогруппа"
|
||||
}
|
||||
|
||||
|
||||
await state.update_data(promocode_type=promo_type)
|
||||
|
||||
await callback.message.edit_text(
|
||||
@@ -509,6 +529,77 @@ async def process_promocode_code(
|
||||
f"Введите количество дней тестовой подписки:"
|
||||
)
|
||||
await state.set_state(AdminStates.setting_promocode_value)
|
||||
elif promo_type == "group":
|
||||
# Show promo group selection
|
||||
groups_with_counts = await get_promo_groups_with_counts(db, limit=50)
|
||||
|
||||
if not groups_with_counts:
|
||||
await message.answer(
|
||||
"❌ Промогруппы не найдены. Создайте хотя бы одну промогруппу.",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_promocodes")]
|
||||
])
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
keyboard = []
|
||||
text = f"🏷️ <b>Промокод:</b> <code>{code}</code>\n\nВыберите промогруппу для назначения:\n\n"
|
||||
|
||||
for promo_group, user_count in groups_with_counts:
|
||||
text += f"• {promo_group.name} (приоритет: {promo_group.priority}, пользователей: {user_count})\n"
|
||||
keyboard.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=f"{promo_group.name} (↑{promo_group.priority})",
|
||||
callback_data=f"promo_select_group_{promo_group.id}"
|
||||
)
|
||||
])
|
||||
|
||||
keyboard.append([
|
||||
types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_promocodes")
|
||||
])
|
||||
|
||||
await message.answer(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
)
|
||||
await state.set_state(AdminStates.selecting_promo_group)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_promo_group_selection(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession
|
||||
):
|
||||
"""Handle promo group selection for promocode"""
|
||||
try:
|
||||
promo_group_id = int(callback.data.split('_')[-1])
|
||||
except (ValueError, IndexError):
|
||||
await callback.answer("❌ Ошибка получения ID промогруппы", show_alert=True)
|
||||
return
|
||||
|
||||
promo_group = await get_promo_group_by_id(db, promo_group_id)
|
||||
if not promo_group:
|
||||
await callback.answer("❌ Промогруппа не найдена", show_alert=True)
|
||||
return
|
||||
|
||||
await state.update_data(
|
||||
promo_group_id=promo_group_id,
|
||||
promo_group_name=promo_group.name
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"🏷️ <b>Промокод для промогруппы</b>\n\n"
|
||||
f"Промогруппа: {promo_group.name}\n"
|
||||
f"Приоритет: {promo_group.priority}\n\n"
|
||||
f"📊 Введите количество использований промокода (или 0 для безлимита):"
|
||||
)
|
||||
|
||||
await state.set_state(AdminStates.setting_promocode_uses)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@@ -557,8 +648,8 @@ async def handle_edit_value(
|
||||
data = await state.get_data()
|
||||
promo_id = data.get('editing_promo_id')
|
||||
edit_action = data.get('edit_action')
|
||||
|
||||
promo = await db.get(PromoCode, promo_id)
|
||||
|
||||
promo = await get_promocode_by_id(db, promo_id)
|
||||
if not promo:
|
||||
await message.answer("❌ Промокод не найден")
|
||||
await state.clear()
|
||||
@@ -643,8 +734,8 @@ async def handle_edit_uses(
|
||||
):
|
||||
data = await state.get_data()
|
||||
promo_id = data.get('editing_promo_id')
|
||||
|
||||
promo = await db.get(PromoCode, promo_id)
|
||||
|
||||
promo = await get_promocode_by_id(db, promo_id)
|
||||
if not promo:
|
||||
await message.answer("❌ Промокод не найден")
|
||||
await state.clear()
|
||||
@@ -708,17 +799,20 @@ async def process_promocode_expiry(
|
||||
promo_type = data.get('promocode_type')
|
||||
value = data.get('promocode_value', 0)
|
||||
max_uses = data.get('promocode_max_uses', 1)
|
||||
|
||||
promo_group_id = data.get('promo_group_id')
|
||||
promo_group_name = data.get('promo_group_name')
|
||||
|
||||
valid_until = None
|
||||
if expiry_days > 0:
|
||||
valid_until = datetime.utcnow() + timedelta(days=expiry_days)
|
||||
|
||||
|
||||
type_map = {
|
||||
"balance": PromoCodeType.BALANCE,
|
||||
"days": PromoCodeType.SUBSCRIPTION_DAYS,
|
||||
"trial": PromoCodeType.TRIAL_SUBSCRIPTION
|
||||
"trial": PromoCodeType.TRIAL_SUBSCRIPTION,
|
||||
"group": PromoCodeType.PROMO_GROUP
|
||||
}
|
||||
|
||||
|
||||
promocode = await create_promocode(
|
||||
db=db,
|
||||
code=code,
|
||||
@@ -727,27 +821,31 @@ async def process_promocode_expiry(
|
||||
subscription_days=value if promo_type in ["days", "trial"] else 0,
|
||||
max_uses=max_uses,
|
||||
valid_until=valid_until,
|
||||
created_by=db_user.id
|
||||
created_by=db_user.id,
|
||||
promo_group_id=promo_group_id if promo_type == "group" else None
|
||||
)
|
||||
|
||||
type_names = {
|
||||
"balance": "Пополнение баланса",
|
||||
"days": "Дни подписки",
|
||||
"trial": "Тестовая подписка"
|
||||
"balance": "Пополнение баланса",
|
||||
"days": "Дни подписки",
|
||||
"trial": "Тестовая подписка",
|
||||
"group": "Промогруппа"
|
||||
}
|
||||
|
||||
|
||||
summary_text = f"""
|
||||
✅ <b>Промокод создан!</b>
|
||||
|
||||
🎫 <b>Код:</b> <code>{promocode.code}</code>
|
||||
📝 <b>Тип:</b> {type_names.get(promo_type)}
|
||||
"""
|
||||
|
||||
|
||||
if promo_type == "balance":
|
||||
summary_text += f"💰 <b>Сумма:</b> {settings.format_price(promocode.balance_bonus_kopeks)}\n"
|
||||
elif promo_type in ["days", "trial"]:
|
||||
summary_text += f"📅 <b>Дней:</b> {promocode.subscription_days}\n"
|
||||
|
||||
elif promo_type == "group" and promo_group_name:
|
||||
summary_text += f"🏷️ <b>Промогруппа:</b> {promo_group_name}\n"
|
||||
|
||||
summary_text += f"📊 <b>Использований:</b> {promocode.max_uses}\n"
|
||||
|
||||
if promocode.valid_until:
|
||||
@@ -775,8 +873,8 @@ async def handle_edit_expiry(
|
||||
):
|
||||
data = await state.get_data()
|
||||
promo_id = data.get('editing_promo_id')
|
||||
|
||||
promo = await db.get(PromoCode, promo_id)
|
||||
|
||||
promo = await get_promocode_by_id(db, promo_id)
|
||||
if not promo:
|
||||
await message.answer("❌ Промокод не найден")
|
||||
await state.clear()
|
||||
@@ -822,8 +920,8 @@ async def toggle_promocode_status(
|
||||
db: AsyncSession
|
||||
):
|
||||
promo_id = int(callback.data.split('_')[-1])
|
||||
|
||||
promo = await db.get(PromoCode, promo_id)
|
||||
|
||||
promo = await get_promocode_by_id(db, promo_id)
|
||||
if not promo:
|
||||
await callback.answer("❌ Промокод не найден", show_alert=True)
|
||||
return
|
||||
@@ -849,8 +947,8 @@ async def confirm_delete_promocode(
|
||||
except (ValueError, IndexError):
|
||||
await callback.answer("❌ Ошибка получения ID промокода", show_alert=True)
|
||||
return
|
||||
|
||||
promo = await db.get(PromoCode, promo_id)
|
||||
|
||||
promo = await get_promocode_by_id(db, promo_id)
|
||||
if not promo:
|
||||
await callback.answer("❌ Промокод не найден", show_alert=True)
|
||||
return
|
||||
@@ -897,8 +995,8 @@ async def delete_promocode_confirmed(
|
||||
except (ValueError, IndexError):
|
||||
await callback.answer("❌ Ошибка получения ID промокода", show_alert=True)
|
||||
return
|
||||
|
||||
promo = await db.get(PromoCode, promo_id)
|
||||
|
||||
promo = await get_promocode_by_id(db, promo_id)
|
||||
if not promo:
|
||||
await callback.answer("❌ Промокод не найден", show_alert=True)
|
||||
return
|
||||
@@ -921,8 +1019,8 @@ async def show_promocode_stats(
|
||||
db: AsyncSession
|
||||
):
|
||||
promo_id = int(callback.data.split('_')[-1])
|
||||
|
||||
promo = await db.get(PromoCode, promo_id)
|
||||
|
||||
promo = await get_promocode_by_id(db, promo_id)
|
||||
if not promo:
|
||||
await callback.answer("❌ Промокод не найден", show_alert=True)
|
||||
return
|
||||
@@ -1007,6 +1105,7 @@ def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(show_promocodes_list, F.data == "admin_promo_list")
|
||||
dp.callback_query.register(start_promocode_creation, F.data == "admin_promo_create")
|
||||
dp.callback_query.register(select_promocode_type, F.data.startswith("promo_type_"))
|
||||
dp.callback_query.register(process_promo_group_selection, F.data.startswith("promo_select_group_"))
|
||||
|
||||
dp.callback_query.register(show_promocode_management, F.data.startswith("promo_manage_"))
|
||||
dp.callback_query.register(toggle_promocode_status, F.data.startswith("promo_toggle_"))
|
||||
|
||||
@@ -0,0 +1,530 @@
|
||||
import html
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import User
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.public_offer_service import PublicOfferService
|
||||
from app.states import AdminStates
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
from app.utils.validators import validate_html_tags, get_html_help_text
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _format_timestamp(value: datetime | None) -> str:
|
||||
if not value:
|
||||
return ""
|
||||
try:
|
||||
return value.strftime("%d.%m.%Y %H:%M")
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
async def _build_overview(
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
offer = await PublicOfferService.get_offer(
|
||||
db,
|
||||
db_user.language,
|
||||
fallback=False,
|
||||
)
|
||||
|
||||
normalized_language = PublicOfferService.normalize_language(db_user.language)
|
||||
has_content = bool(offer and offer.content and offer.content.strip())
|
||||
|
||||
description = texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_DESCRIPTION",
|
||||
"Публичная оферта отображается в разделе «Инфо».",
|
||||
)
|
||||
|
||||
status_text = texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_STATUS_DISABLED",
|
||||
"⚠️ Показ оферты выключен или текст отсутствует.",
|
||||
)
|
||||
if offer and offer.is_enabled and has_content:
|
||||
status_text = texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_STATUS_ENABLED",
|
||||
"✅ Оферта активна и показывается пользователям.",
|
||||
)
|
||||
elif offer and offer.is_enabled:
|
||||
status_text = texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_STATUS_ENABLED_EMPTY",
|
||||
"⚠️ Оферта включена, но текст пуст — пользователи её не увидят.",
|
||||
)
|
||||
|
||||
updated_at = _format_timestamp(getattr(offer, "updated_at", None))
|
||||
updated_block = ""
|
||||
if updated_at:
|
||||
updated_block = texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_UPDATED_AT",
|
||||
"Последнее обновление: {timestamp}",
|
||||
).format(timestamp=updated_at)
|
||||
|
||||
preview_block = texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_PREVIEW_EMPTY",
|
||||
"Текст ещё не задан.",
|
||||
)
|
||||
if has_content:
|
||||
preview_title = texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_PREVIEW_TITLE",
|
||||
"<b>Превью текста:</b>",
|
||||
)
|
||||
preview_raw = offer.content.strip()
|
||||
preview_trimmed = preview_raw[:400]
|
||||
if len(preview_raw) > 400:
|
||||
preview_trimmed += "..."
|
||||
preview_block = (
|
||||
f"{preview_title}\n"
|
||||
f"<code>{html.escape(preview_trimmed)}</code>"
|
||||
)
|
||||
|
||||
language_block = texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_LANGUAGE",
|
||||
"Язык: <code>{lang}</code>",
|
||||
).format(lang=normalized_language)
|
||||
|
||||
header = texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_HEADER",
|
||||
"📄 <b>Публичная оферта</b>",
|
||||
)
|
||||
actions_prompt = texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_ACTION_PROMPT",
|
||||
"Выберите действие:",
|
||||
)
|
||||
|
||||
message_parts = [
|
||||
header,
|
||||
description,
|
||||
language_block,
|
||||
status_text,
|
||||
]
|
||||
|
||||
if updated_block:
|
||||
message_parts.append(updated_block)
|
||||
|
||||
message_parts.append(preview_block)
|
||||
message_parts.append(actions_prompt)
|
||||
|
||||
overview_text = "\n\n".join(part for part in message_parts if part)
|
||||
|
||||
buttons: list[list[types.InlineKeyboardButton]] = []
|
||||
|
||||
buttons.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_EDIT_BUTTON",
|
||||
"✏️ Изменить текст",
|
||||
),
|
||||
callback_data="admin_public_offer_edit",
|
||||
)
|
||||
])
|
||||
|
||||
if has_content:
|
||||
buttons.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_VIEW_BUTTON",
|
||||
"👀 Просмотреть текущий текст",
|
||||
),
|
||||
callback_data="admin_public_offer_view",
|
||||
)
|
||||
])
|
||||
|
||||
toggle_text = texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_ENABLE_BUTTON",
|
||||
"✅ Включить показ",
|
||||
)
|
||||
if offer and offer.is_enabled:
|
||||
toggle_text = texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_DISABLE_BUTTON",
|
||||
"🚫 Отключить показ",
|
||||
)
|
||||
|
||||
buttons.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=toggle_text,
|
||||
callback_data="admin_public_offer_toggle",
|
||||
)
|
||||
])
|
||||
|
||||
buttons.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_HTML_HELP",
|
||||
"ℹ️ HTML помощь",
|
||||
),
|
||||
callback_data="admin_public_offer_help",
|
||||
)
|
||||
])
|
||||
|
||||
buttons.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.BACK,
|
||||
callback_data="admin_submenu_settings",
|
||||
)
|
||||
])
|
||||
|
||||
return overview_text, types.InlineKeyboardMarkup(inline_keyboard=buttons), offer
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_public_offer_management(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
overview_text, markup, _ = await _build_overview(db_user, db)
|
||||
|
||||
await callback.message.edit_text(
|
||||
overview_text,
|
||||
reply_markup=markup,
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def toggle_public_offer(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
updated_offer = await PublicOfferService.toggle_enabled(db, db_user.language)
|
||||
logger.info(
|
||||
"Админ %s переключил показ публичной оферты: %s",
|
||||
db_user.telegram_id,
|
||||
"enabled" if updated_offer.is_enabled else "disabled",
|
||||
)
|
||||
status_message = (
|
||||
texts.t("ADMIN_PUBLIC_OFFER_ENABLED", "✅ Оферта включена")
|
||||
if updated_offer.is_enabled
|
||||
else texts.t("ADMIN_PUBLIC_OFFER_DISABLED", "🚫 Оферта отключена")
|
||||
)
|
||||
|
||||
overview_text, markup, _ = await _build_overview(db_user, db)
|
||||
await callback.message.edit_text(
|
||||
overview_text,
|
||||
reply_markup=markup,
|
||||
)
|
||||
await callback.answer(status_message)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_edit_public_offer(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
offer = await PublicOfferService.get_offer(
|
||||
db,
|
||||
db_user.language,
|
||||
fallback=False,
|
||||
)
|
||||
|
||||
current_preview = ""
|
||||
if offer and offer.content:
|
||||
preview = offer.content.strip()[:400]
|
||||
if len(offer.content.strip()) > 400:
|
||||
preview += "..."
|
||||
current_preview = (
|
||||
texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_CURRENT_PREVIEW",
|
||||
"Текущий текст (превью):",
|
||||
)
|
||||
+ f"\n<code>{html.escape(preview)}</code>\n\n"
|
||||
)
|
||||
|
||||
prompt = texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_EDIT_PROMPT",
|
||||
"Отправьте новый текст публичной оферты. Допускается HTML-разметка.",
|
||||
)
|
||||
|
||||
hint = texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_EDIT_HINT",
|
||||
"Используйте /html_help для справки по тегам.",
|
||||
)
|
||||
|
||||
message_text = (
|
||||
f"📝 <b>{texts.t('ADMIN_PUBLIC_OFFER_EDIT_TITLE', 'Редактирование оферты')}</b>\n\n"
|
||||
f"{current_preview}{prompt}\n\n{hint}"
|
||||
)
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_HTML_HELP",
|
||||
"ℹ️ HTML помощь",
|
||||
),
|
||||
callback_data="admin_public_offer_help",
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("ADMIN_PUBLIC_OFFER_CANCEL", "❌ Отмена"),
|
||||
callback_data="admin_public_offer_cancel",
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
await callback.message.edit_text(message_text, reply_markup=keyboard)
|
||||
await state.set_state(AdminStates.editing_public_offer)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def cancel_edit_public_offer(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession,
|
||||
):
|
||||
await state.clear()
|
||||
overview_text, markup, _ = await _build_overview(db_user, db)
|
||||
await callback.message.edit_text(
|
||||
overview_text,
|
||||
reply_markup=markup,
|
||||
)
|
||||
await callback.answer(
|
||||
get_texts(db_user.language).t(
|
||||
"ADMIN_PUBLIC_OFFER_EDIT_CANCELLED",
|
||||
"Редактирование оферты отменено.",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_public_offer_edit(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
new_text = message.text or ""
|
||||
|
||||
if len(new_text) > 4000:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_TOO_LONG",
|
||||
"❌ Текст оферты слишком длинный. Максимум 4000 символов.",
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
is_valid, error_message = validate_html_tags(new_text)
|
||||
if not is_valid:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_HTML_ERROR",
|
||||
"❌ Ошибка в HTML: {error}",
|
||||
).format(error=error_message)
|
||||
)
|
||||
return
|
||||
|
||||
await PublicOfferService.save_offer(db, db_user.language, new_text)
|
||||
logger.info(
|
||||
"Админ %s обновил текст публичной оферты (%d символов)",
|
||||
db_user.telegram_id,
|
||||
len(new_text),
|
||||
)
|
||||
await state.clear()
|
||||
|
||||
success_text = texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_SAVED",
|
||||
"✅ Публичная оферта обновлена.",
|
||||
)
|
||||
|
||||
reply_markup = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_BACK_BUTTON",
|
||||
"⬅️ К настройкам оферты",
|
||||
),
|
||||
callback_data="admin_public_offer",
|
||||
)
|
||||
]
|
||||
]
|
||||
)
|
||||
|
||||
await message.answer(success_text, reply_markup=reply_markup)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def view_public_offer(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
offer = await PublicOfferService.get_offer(
|
||||
db,
|
||||
db_user.language,
|
||||
fallback=False,
|
||||
)
|
||||
|
||||
if not offer or not offer.content or not offer.content.strip():
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_PREVIEW_EMPTY_ALERT",
|
||||
"Текст оферты пока не задан.",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
content = offer.content.strip()
|
||||
max_length = 3800
|
||||
pages = PublicOfferService.split_content_into_pages(
|
||||
content,
|
||||
max_length=max_length,
|
||||
)
|
||||
|
||||
if not pages:
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_PREVIEW_EMPTY_ALERT",
|
||||
"Текст оферты пока не задан.",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
preview = pages[0]
|
||||
truncated = len(pages) > 1
|
||||
|
||||
header = texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_VIEW_TITLE",
|
||||
"👀 <b>Текущий текст оферты</b>",
|
||||
)
|
||||
|
||||
note = ""
|
||||
if truncated:
|
||||
note = texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_VIEW_TRUNCATED",
|
||||
"\n\n⚠️ Текст сокращён для отображения. Полную версию увидят пользователи в меню.",
|
||||
)
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_BACK_BUTTON",
|
||||
"⬅️ К настройкам оферты",
|
||||
),
|
||||
callback_data="admin_public_offer",
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_EDIT_BUTTON",
|
||||
"✏️ Изменить текст",
|
||||
),
|
||||
callback_data="admin_public_offer_edit",
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"{header}\n\n{preview}{note}",
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_public_offer_html_help(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
help_text = get_html_help_text()
|
||||
|
||||
current_state = await state.get_state()
|
||||
|
||||
buttons: list[list[types.InlineKeyboardButton]] = []
|
||||
|
||||
if current_state == AdminStates.editing_public_offer.state:
|
||||
buttons.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_RETURN_TO_EDIT",
|
||||
"⬅️ Назад к редактированию",
|
||||
),
|
||||
callback_data="admin_public_offer_edit",
|
||||
)
|
||||
])
|
||||
|
||||
buttons.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_PUBLIC_OFFER_BACK_BUTTON",
|
||||
"⬅️ К настройкам оферты",
|
||||
),
|
||||
callback_data="admin_public_offer",
|
||||
)
|
||||
])
|
||||
|
||||
await callback.message.edit_text(
|
||||
help_text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=buttons),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher) -> None:
|
||||
dp.callback_query.register(
|
||||
show_public_offer_management,
|
||||
F.data == "admin_public_offer",
|
||||
)
|
||||
dp.callback_query.register(
|
||||
toggle_public_offer,
|
||||
F.data == "admin_public_offer_toggle",
|
||||
)
|
||||
dp.callback_query.register(
|
||||
start_edit_public_offer,
|
||||
F.data == "admin_public_offer_edit",
|
||||
)
|
||||
dp.callback_query.register(
|
||||
cancel_edit_public_offer,
|
||||
F.data == "admin_public_offer_cancel",
|
||||
)
|
||||
dp.callback_query.register(
|
||||
view_public_offer,
|
||||
F.data == "admin_public_offer_view",
|
||||
)
|
||||
dp.callback_query.register(
|
||||
show_public_offer_html_help,
|
||||
F.data == "admin_public_offer_help",
|
||||
)
|
||||
|
||||
dp.message.register(
|
||||
process_public_offer_edit,
|
||||
AdminStates.editing_public_offer,
|
||||
)
|
||||
@@ -68,7 +68,6 @@ async def show_referral_statistics(
|
||||
- Минимальное пополнение: {settings.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS)}
|
||||
- Бонус за первое пополнение: {settings.format_price(settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS)}
|
||||
- Бонус пригласившему: {settings.format_price(settings.REFERRAL_INVITER_BONUS_KOPEKS)}
|
||||
- Бонус новому пользователю: {settings.format_price(settings.REFERRED_USER_REWARD)}
|
||||
- Комиссия с покупок: {settings.REFERRAL_COMMISSION_PERCENT}%
|
||||
- Уведомления: {'✅ Включены' if settings.REFERRAL_NOTIFICATIONS_ENABLED else '❌ Отключены'}
|
||||
|
||||
@@ -105,7 +104,6 @@ async def show_referral_statistics(
|
||||
- Минимальное пополнение: {settings.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS)}
|
||||
- Бонус за первое пополнение: {settings.format_price(settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS)}
|
||||
- Бонус пригласившему: {settings.format_price(settings.REFERRAL_INVITER_BONUS_KOPEKS)}
|
||||
- Бонус новому пользователю: {settings.format_price(settings.REFERRED_USER_REWARD)}
|
||||
- Комиссия с покупок: {settings.REFERRAL_COMMISSION_PERCENT}%
|
||||
|
||||
<i>🕐 Время: {current_time}</i>
|
||||
@@ -190,7 +188,6 @@ async def show_referral_settings(
|
||||
• Минимальная сумма пополнения для участия: {settings.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS)}
|
||||
• Бонус за первое пополнение реферала: {settings.format_price(settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS)}
|
||||
• Бонус пригласившему за первое пополнение: {settings.format_price(settings.REFERRAL_INVITER_BONUS_KOPEKS)}
|
||||
• Бонус новому пользователю при регистрации: {settings.format_price(settings.REFERRED_USER_REWARD)}
|
||||
|
||||
<b>Комиссионные:</b>
|
||||
• Процент с каждой покупки реферала: {settings.REFERRAL_COMMISSION_PERCENT}%
|
||||
|
||||
+1273
-44
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,117 @@
|
||||
import logging
|
||||
from aiogram import Dispatcher, F, types
|
||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import User
|
||||
from app.keyboards.admin import (
|
||||
get_admin_report_result_keyboard,
|
||||
get_admin_reports_keyboard,
|
||||
)
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.reporting_service import (
|
||||
ReportPeriod,
|
||||
ReportingServiceError,
|
||||
reporting_service,
|
||||
)
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_reports_menu(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
) -> None:
|
||||
await callback.message.edit_text(
|
||||
"📊 <b>Отчеты</b>\n\n"
|
||||
"Выберите период, чтобы отправить отчет в админский топик.",
|
||||
reply_markup=get_admin_reports_keyboard(db_user.language),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def send_daily_report(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
) -> None:
|
||||
await _send_report(callback, ReportPeriod.DAILY, db_user.language)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def send_weekly_report(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
) -> None:
|
||||
await _send_report(callback, ReportPeriod.WEEKLY, db_user.language)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def send_monthly_report(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
) -> None:
|
||||
await _send_report(callback, ReportPeriod.MONTHLY, db_user.language)
|
||||
|
||||
|
||||
async def _send_report(
|
||||
callback: types.CallbackQuery,
|
||||
period: ReportPeriod,
|
||||
language: str,
|
||||
) -> None:
|
||||
try:
|
||||
report_text = await reporting_service.send_report(period, send_to_topic=True)
|
||||
except ReportingServiceError as exc:
|
||||
logger.warning("Не удалось отправить отчет: %s", exc)
|
||||
await callback.answer(str(exc), show_alert=True)
|
||||
return
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("Непредвиденная ошибка при отправке отчета: %s", exc)
|
||||
await callback.answer("Не удалось отправить отчет. Попробуйте позже.", show_alert=True)
|
||||
return
|
||||
|
||||
await callback.message.answer(
|
||||
report_text,
|
||||
reply_markup=get_admin_report_result_keyboard(language),
|
||||
)
|
||||
await callback.answer("Отчет отправлен в топик")
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def close_report_message(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
) -> None:
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
try:
|
||||
await callback.message.delete()
|
||||
except (TelegramBadRequest, TelegramForbiddenError) as exc:
|
||||
logger.warning("Не удалось закрыть сообщение отчета: %s", exc)
|
||||
await callback.answer(texts.t("REPORT_CLOSE_ERROR", "Не удалось закрыть отчет."), show_alert=True)
|
||||
return
|
||||
|
||||
await callback.answer(texts.t("REPORT_CLOSED", "Отчет закрыт."))
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher) -> None:
|
||||
dp.callback_query.register(show_reports_menu, F.data == "admin_reports")
|
||||
dp.callback_query.register(send_daily_report, F.data == "admin_reports_daily")
|
||||
dp.callback_query.register(send_weekly_report, F.data == "admin_reports_weekly")
|
||||
dp.callback_query.register(send_monthly_report, F.data == "admin_reports_monthly")
|
||||
dp.callback_query.register(close_report_message, F.data == "admin_close_report")
|
||||
|
||||
+223
-42
@@ -8,7 +8,8 @@ from app.states import AdminStates
|
||||
from app.database.models import User
|
||||
from app.localization.texts import get_texts
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
from app.database.crud.rules import get_current_rules_content, create_or_update_rules
|
||||
from app.utils.validators import validate_html_tags, get_html_help_text
|
||||
from app.database.crud.rules import get_current_rules_content, create_or_update_rules, clear_all_rules
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -31,7 +32,9 @@ async def show_rules_management(
|
||||
keyboard = [
|
||||
[types.InlineKeyboardButton(text="📝 Редактировать правила", callback_data="admin_edit_rules")],
|
||||
[types.InlineKeyboardButton(text="👀 Просмотр правил", callback_data="admin_view_rules")],
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")]
|
||||
[types.InlineKeyboardButton(text="🗑️ Очистить правила", callback_data="admin_clear_rules")],
|
||||
[types.InlineKeyboardButton(text="ℹ️ Помощь по HTML", callback_data="admin_rules_help")],
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_submenu_settings")]
|
||||
]
|
||||
|
||||
await callback.message.edit_text(
|
||||
@@ -48,16 +51,33 @@ async def view_current_rules(
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
current_rules = await get_current_rules_content(db, db_user.language)
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"📋 <b>Текущие правила сервиса</b>\n\n{current_rules}",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="✏️ Редактировать", callback_data="admin_edit_rules")],
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_rules")]
|
||||
])
|
||||
)
|
||||
await callback.answer()
|
||||
try:
|
||||
current_rules = await get_current_rules_content(db, db_user.language)
|
||||
|
||||
is_valid, error_msg = validate_html_tags(current_rules)
|
||||
warning = ""
|
||||
if not is_valid:
|
||||
warning = f"\n\n⚠️ <b>Внимание:</b> В правилах найдена ошибка HTML: {error_msg}"
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"📋 <b>Текущие правила сервиса</b>\n\n{current_rules}{warning}",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="✏️ Редактировать", callback_data="admin_edit_rules")],
|
||||
[types.InlineKeyboardButton(text="🗑️ Очистить", callback_data="admin_clear_rules")],
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_rules")]
|
||||
])
|
||||
)
|
||||
await callback.answer()
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при показе правил: {e}")
|
||||
await callback.message.edit_text(
|
||||
"❌ Ошибка при загрузке правил. Возможно, в тексте есть некорректные HTML теги.",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="🗑️ Очистить правила", callback_data="admin_clear_rules")],
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_rules")]
|
||||
])
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@@ -68,20 +88,33 @@ async def start_edit_rules(
|
||||
state: FSMContext,
|
||||
db: AsyncSession
|
||||
):
|
||||
current_rules = await get_current_rules_content(db, db_user.language)
|
||||
|
||||
await callback.message.edit_text(
|
||||
"✏️ <b>Редактирование правил</b>\n\n"
|
||||
f"<b>Текущие правила:</b>\n{current_rules[:500]}{'...' if len(current_rules) > 500 else ''}\n\n"
|
||||
"Отправьте новый текст правил сервиса.\n\n"
|
||||
"<i>Поддерживается HTML разметка</i>",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_rules")]
|
||||
])
|
||||
)
|
||||
|
||||
await state.set_state(AdminStates.editing_rules_page)
|
||||
await callback.answer()
|
||||
try:
|
||||
current_rules = await get_current_rules_content(db, db_user.language)
|
||||
|
||||
preview = current_rules[:500] + ('...' if len(current_rules) > 500 else '')
|
||||
|
||||
text = (
|
||||
"✏️ <b>Редактирование правил</b>\n\n"
|
||||
f"<b>Текущие правила:</b>\n<code>{preview}</code>\n\n"
|
||||
"Отправьте новый текст правил сервиса.\n\n"
|
||||
"<i>Поддерживается HTML разметка. Все теги будут проверены перед сохранением.</i>\n\n"
|
||||
"💡 <b>Совет:</b> Нажмите /html_help для просмотра поддерживаемых тегов"
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="ℹ️ HTML помощь", callback_data="admin_rules_help")],
|
||||
[types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_rules")]
|
||||
])
|
||||
)
|
||||
|
||||
await state.set_state(AdminStates.editing_rules_page)
|
||||
await callback.answer()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при начале редактирования правил: {e}")
|
||||
await callback.answer("❌ Ошибка при загрузке правил для редактирования", show_alert=True)
|
||||
|
||||
|
||||
@admin_required
|
||||
@@ -98,19 +131,61 @@ async def process_rules_edit(
|
||||
await message.answer("❌ Текст правил слишком длинный (максимум 4000 символов)")
|
||||
return
|
||||
|
||||
await message.answer(
|
||||
f"📋 <b>Предварительный просмотр новых правил:</b>\n\n{new_rules}\n\n"
|
||||
f"⚠️ <b>Внимание!</b> Новые правила будут показываться всем пользователям.\n\n"
|
||||
f"Сохранить изменения?",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(text="✅ Сохранить", callback_data="admin_save_rules"),
|
||||
types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_rules")
|
||||
]
|
||||
])
|
||||
)
|
||||
is_valid, error_msg = validate_html_tags(new_rules)
|
||||
if not is_valid:
|
||||
await message.answer(
|
||||
f"❌ <b>Ошибка в HTML разметке:</b>\n{error_msg}\n\n"
|
||||
f"Пожалуйста, исправьте ошибки и отправьте текст заново.\n\n"
|
||||
f"💡 Используйте /html_help для просмотра правильного синтаксиса",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="ℹ️ HTML помощь", callback_data="admin_rules_help")],
|
||||
[types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_rules")]
|
||||
])
|
||||
)
|
||||
return
|
||||
|
||||
await state.update_data(new_rules=new_rules)
|
||||
try:
|
||||
preview_text = f"📋 <b>Предварительный просмотр новых правил:</b>\n\n{new_rules}\n\n"
|
||||
preview_text += f"⚠️ <b>Внимание!</b> Новые правила будут показываться всем пользователям.\n\n"
|
||||
preview_text += f"Сохранить изменения?"
|
||||
|
||||
if len(preview_text) > 4000:
|
||||
preview_text = (
|
||||
"📋 <b>Предварительный просмотр новых правил:</b>\n\n"
|
||||
f"{new_rules[:500]}...\n\n"
|
||||
f"⚠️ <b>Внимание!</b> Новые правила будут показываться всем пользователям.\n\n"
|
||||
f"Текст правил: {len(new_rules)} символов\n"
|
||||
f"Сохранить изменения?"
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
preview_text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(text="✅ Сохранить", callback_data="admin_save_rules"),
|
||||
types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_rules")
|
||||
]
|
||||
])
|
||||
)
|
||||
|
||||
await state.update_data(new_rules=new_rules)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при показе превью правил: {e}")
|
||||
await message.answer(
|
||||
"⚠️ <b>Подтверждение сохранения правил</b>\n\n"
|
||||
f"Новые правила готовы к сохранению ({len(new_rules)} символов).\n"
|
||||
f"HTML теги проверены и корректны.\n\n"
|
||||
f"Сохранить изменения?",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(text="✅ Сохранить", callback_data="admin_save_rules"),
|
||||
types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_rules")
|
||||
]
|
||||
])
|
||||
)
|
||||
|
||||
await state.update_data(new_rules=new_rules)
|
||||
|
||||
|
||||
@admin_required
|
||||
@@ -128,6 +203,20 @@ async def save_rules(
|
||||
await callback.answer("❌ Ошибка: текст правил не найден", show_alert=True)
|
||||
return
|
||||
|
||||
is_valid, error_msg = validate_html_tags(new_rules)
|
||||
if not is_valid:
|
||||
await callback.message.edit_text(
|
||||
f"❌ <b>Ошибка при сохранении:</b>\n{error_msg}\n\n"
|
||||
f"Правила не были сохранены из-за ошибок в HTML разметке.",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="🔄 Попробовать снова", callback_data="admin_edit_rules")],
|
||||
[types.InlineKeyboardButton(text="📋 К правилам", callback_data="admin_rules")]
|
||||
])
|
||||
)
|
||||
await state.clear()
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
try:
|
||||
await create_or_update_rules(
|
||||
db=db,
|
||||
@@ -142,10 +231,14 @@ async def save_rules(
|
||||
await refresh_rules_cache(db_user.language)
|
||||
|
||||
await callback.message.edit_text(
|
||||
"✅ <b>Правила сервиса обновлены!</b>\n\n"
|
||||
"Новые правила сохранены в базе данных и будут показываться пользователям.\n\n"
|
||||
"Кеш правил очищен и обновлен.",
|
||||
"✅ <b>Правила сервиса успешно обновлены!</b>\n\n"
|
||||
"✓ Новые правила сохранены в базе данных\n"
|
||||
"✓ HTML теги проверены и корректны\n"
|
||||
"✓ Кеш правил очищен и обновлен\n"
|
||||
"✓ Правила будут показываться пользователям\n\n"
|
||||
f"📊 Размер текста: {len(new_rules)} символов",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="👀 Просмотреть", callback_data="admin_view_rules")],
|
||||
[types.InlineKeyboardButton(text="📋 К правилам", callback_data="admin_rules")]
|
||||
])
|
||||
)
|
||||
@@ -156,7 +249,90 @@ async def save_rules(
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка сохранения правил: {e}")
|
||||
await callback.answer("❌ Ошибка сохранения правил", show_alert=True)
|
||||
await callback.message.edit_text(
|
||||
"❌ <b>Ошибка при сохранении правил</b>\n\n"
|
||||
"Произошла ошибка при записи в базу данных. Попробуйте еще раз.",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="🔄 Попробовать снова", callback_data="admin_save_rules")],
|
||||
[types.InlineKeyboardButton(text="📋 К правилам", callback_data="admin_rules")]
|
||||
])
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def clear_rules_confirmation(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
await callback.message.edit_text(
|
||||
"🗑️ <b>Очистка правил сервиса</b>\n\n"
|
||||
"⚠️ <b>ВНИМАНИЕ!</b> Вы собираетесь полностью удалить все правила сервиса.\n\n"
|
||||
"После очистки пользователи будут видеть стандартные правила по умолчанию.\n\n"
|
||||
"Это действие нельзя отменить. Продолжить?",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(text="✅ Да, очистить", callback_data="admin_confirm_clear_rules"),
|
||||
types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_rules")
|
||||
]
|
||||
])
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def confirm_clear_rules(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
try:
|
||||
await clear_all_rules(db, db_user.language)
|
||||
|
||||
from app.localization.texts import clear_rules_cache
|
||||
clear_rules_cache()
|
||||
|
||||
await callback.message.edit_text(
|
||||
"✅ <b>Правила успешно очищены!</b>\n\n"
|
||||
"✓ Все пользовательские правила удалены\n"
|
||||
"✓ Теперь используются стандартные правила\n"
|
||||
"✓ Кеш правил очищен\n\n"
|
||||
"Пользователи будут видеть правила по умолчанию.",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="📝 Создать новые", callback_data="admin_edit_rules")],
|
||||
[types.InlineKeyboardButton(text="👀 Посмотреть текущие", callback_data="admin_view_rules")],
|
||||
[types.InlineKeyboardButton(text="📋 К правилам", callback_data="admin_rules")]
|
||||
])
|
||||
)
|
||||
|
||||
logger.info(f"Правила очищены администратором {db_user.telegram_id}")
|
||||
await callback.answer()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при очистке правил: {e}")
|
||||
await callback.answer("❌ Ошибка при очистке правил", show_alert=True)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_html_help(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
help_text = get_html_help_text()
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"ℹ️ <b>Справка по HTML форматированию</b>\n\n{help_text}",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="📝 Редактировать правила", callback_data="admin_edit_rules")],
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_rules")]
|
||||
])
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
@@ -165,4 +341,9 @@ def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(start_edit_rules, F.data == "admin_edit_rules")
|
||||
dp.callback_query.register(save_rules, F.data == "admin_save_rules")
|
||||
|
||||
dp.callback_query.register(clear_rules_confirmation, F.data == "admin_clear_rules")
|
||||
dp.callback_query.register(confirm_clear_rules, F.data == "admin_confirm_clear_rules")
|
||||
|
||||
dp.callback_query.register(show_html_help, F.data == "admin_rules_help")
|
||||
|
||||
dp.message.register(process_rules_edit, AdminStates.editing_rules_page)
|
||||
+490
-124
@@ -1,3 +1,4 @@
|
||||
import html
|
||||
import logging
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.fsm.context import FSMContext
|
||||
@@ -6,10 +7,18 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.states import AdminStates
|
||||
from app.database.models import User
|
||||
from app.database.crud.server_squad import (
|
||||
get_all_server_squads, get_server_squad_by_id, update_server_squad,
|
||||
delete_server_squad, sync_with_remnawave, get_server_statistics,
|
||||
create_server_squad, get_available_server_squads
|
||||
get_all_server_squads,
|
||||
get_server_squad_by_id,
|
||||
update_server_squad,
|
||||
delete_server_squad,
|
||||
sync_with_remnawave,
|
||||
get_server_statistics,
|
||||
create_server_squad,
|
||||
get_available_server_squads,
|
||||
update_server_squad_promo_groups,
|
||||
get_server_connected_users,
|
||||
)
|
||||
from app.database.crud.promo_group import get_promo_groups_with_counts
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
from app.utils.cache import cache
|
||||
@@ -17,6 +26,125 @@ from app.utils.cache import cache
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _build_server_edit_view(server):
|
||||
status_emoji = "✅ Доступен" if server.is_available else "❌ Недоступен"
|
||||
price_text = f"{int(server.price_rubles)} ₽" if server.price_kopeks > 0 else "Бесплатно"
|
||||
promo_groups_text = (
|
||||
", ".join(sorted(pg.name for pg in server.allowed_promo_groups))
|
||||
if server.allowed_promo_groups
|
||||
else "Не выбраны"
|
||||
)
|
||||
|
||||
trial_status = "✅ Да" if server.is_trial_eligible else "⚪️ Нет"
|
||||
|
||||
text = f"""
|
||||
🌐 <b>Редактирование сервера</b>
|
||||
|
||||
<b>Информация:</b>
|
||||
• ID: {server.id}
|
||||
• UUID: <code>{server.squad_uuid}</code>
|
||||
• Название: {server.display_name}
|
||||
• Оригинальное: {server.original_name or 'Не указано'}
|
||||
• Статус: {status_emoji}
|
||||
|
||||
<b>Настройки:</b>
|
||||
• Цена: {price_text}
|
||||
• Код страны: {server.country_code or 'Не указан'}
|
||||
• Лимит пользователей: {server.max_users or 'Без лимита'}
|
||||
• Текущих пользователей: {server.current_users}
|
||||
• Промогруппы: {promo_groups_text}
|
||||
• Выдача триала: {trial_status}
|
||||
|
||||
<b>Описание:</b>
|
||||
{server.description or 'Не указано'}
|
||||
|
||||
Выберите что изменить:
|
||||
"""
|
||||
|
||||
keyboard = [
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="✏️ Название", callback_data=f"admin_server_edit_name_{server.id}"
|
||||
),
|
||||
types.InlineKeyboardButton(
|
||||
text="💰 Цена", callback_data=f"admin_server_edit_price_{server.id}"
|
||||
),
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="🌍 Страна", callback_data=f"admin_server_edit_country_{server.id}"
|
||||
),
|
||||
types.InlineKeyboardButton(
|
||||
text="👥 Лимит", callback_data=f"admin_server_edit_limit_{server.id}"
|
||||
),
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="👥 Юзеры", callback_data=f"admin_server_users_{server.id}"
|
||||
),
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="🎁 Выдавать сквад" if not server.is_trial_eligible else "🚫 Не выдавать сквад",
|
||||
callback_data=f"admin_server_trial_{server.id}",
|
||||
),
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="🎯 Промогруппы", callback_data=f"admin_server_edit_promo_{server.id}"
|
||||
),
|
||||
types.InlineKeyboardButton(
|
||||
text="📝 Описание", callback_data=f"admin_server_edit_desc_{server.id}"
|
||||
),
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="❌ Отключить" if server.is_available else "✅ Включить",
|
||||
callback_data=f"admin_server_toggle_{server.id}",
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="🗑️ Удалить", callback_data=f"admin_server_delete_{server.id}"
|
||||
),
|
||||
types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_servers_list"),
|
||||
],
|
||||
]
|
||||
|
||||
return text, types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
def _build_server_promo_groups_keyboard(server_id: int, promo_groups, selected_ids):
|
||||
keyboard = []
|
||||
for group in promo_groups:
|
||||
emoji = "✅" if group["id"] in selected_ids else "⚪"
|
||||
keyboard.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=f"{emoji} {group['name']}",
|
||||
callback_data=f"admin_server_promo_toggle_{server_id}_{group['id']}",
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
keyboard.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="💾 Сохранить", callback_data=f"admin_server_promo_save_{server_id}"
|
||||
)
|
||||
]
|
||||
)
|
||||
keyboard.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="⬅️ Назад", callback_data=f"admin_server_edit_{server_id}"
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
return types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_servers_menu(
|
||||
@@ -37,7 +165,7 @@ async def show_servers_menu(
|
||||
• С подключениями: {stats['servers_with_connections']}
|
||||
|
||||
💰 <b>Выручка от серверов:</b>
|
||||
• Общая: {stats['total_revenue_rubles']:.2f} ₽
|
||||
• Общая: {int(stats['total_revenue_rubles'])} ₽
|
||||
|
||||
Выберите действие:
|
||||
"""
|
||||
@@ -52,7 +180,7 @@ async def show_servers_menu(
|
||||
types.InlineKeyboardButton(text="📈 Подробная статистика", callback_data="admin_servers_stats")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_subscriptions")
|
||||
types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")
|
||||
]
|
||||
]
|
||||
|
||||
@@ -83,7 +211,7 @@ async def show_servers_list(
|
||||
|
||||
for i, server in enumerate(servers, 1 + (page - 1) * 10):
|
||||
status_emoji = "✅" if server.is_available else "❌"
|
||||
price_text = f"{server.price_rubles:.2f} ₽" if server.price_kopeks > 0 else "Бесплатно"
|
||||
price_text = f"{int(server.price_rubles)} ₽" if server.price_kopeks > 0 else "Бесплатно"
|
||||
|
||||
text += f"{i}. {status_emoji} {server.display_name}\n"
|
||||
text += f" 💰 Цена: {price_text}"
|
||||
@@ -147,7 +275,7 @@ async def sync_servers_with_remnawave(
|
||||
):
|
||||
|
||||
await callback.message.edit_text(
|
||||
"🔄 Синхронизация с RemnaWave...\n\nПодождите, это может занять время.",
|
||||
"🔄 Синхронизация с Remnawave...\n\nПодождите, это может занять время.",
|
||||
reply_markup=None
|
||||
)
|
||||
|
||||
@@ -157,16 +285,16 @@ async def sync_servers_with_remnawave(
|
||||
|
||||
if not squads:
|
||||
await callback.message.edit_text(
|
||||
"❌ Не удалось получить данные о сквадах из RemnaWave.\n\nПроверьте настройки API.",
|
||||
"❌ Не удалось получить данные о сквадах из Remnawave.\n\nПроверьте настройки API.",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_servers")]
|
||||
])
|
||||
)
|
||||
return
|
||||
|
||||
created, updated, disabled = await sync_with_remnawave(db, squads)
|
||||
created, updated, removed = await sync_with_remnawave(db, squads)
|
||||
|
||||
await cache.delete("available_countries")
|
||||
await cache.delete_pattern("available_countries*")
|
||||
|
||||
text = f"""
|
||||
✅ <b>Синхронизация завершена</b>
|
||||
@@ -174,7 +302,7 @@ async def sync_servers_with_remnawave(
|
||||
📊 <b>Результаты:</b>
|
||||
• Создано новых серверов: {created}
|
||||
• Обновлено существующих: {updated}
|
||||
• Отключено неактивных: {disabled}
|
||||
• Удалено отсутствующих: {removed}
|
||||
• Всего обработано: {len(squads)}
|
||||
|
||||
ℹ️ Новые серверы созданы как недоступные.
|
||||
@@ -202,7 +330,7 @@ async def sync_servers_with_remnawave(
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_servers")]
|
||||
])
|
||||
)
|
||||
|
||||
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@@ -216,65 +344,162 @@ async def show_server_edit_menu(
|
||||
|
||||
server_id = int(callback.data.split('_')[-1])
|
||||
server = await get_server_squad_by_id(db, server_id)
|
||||
|
||||
|
||||
if not server:
|
||||
await callback.answer("❌ Сервер не найден!", show_alert=True)
|
||||
return
|
||||
|
||||
status_emoji = "✅ Доступен" if server.is_available else "❌ Недоступен"
|
||||
price_text = f"{server.price_rubles:.2f} ₽" if server.price_kopeks > 0 else "Бесплатно"
|
||||
|
||||
text = f"""
|
||||
🌐 <b>Редактирование сервера</b>
|
||||
|
||||
<b>Информация:</b>
|
||||
• ID: {server.id}
|
||||
• UUID: <code>{server.squad_uuid}</code>
|
||||
• Название: {server.display_name}
|
||||
• Оригинальное: {server.original_name or 'Не указано'}
|
||||
• Статус: {status_emoji}
|
||||
text, keyboard = _build_server_edit_view(server)
|
||||
|
||||
<b>Настройки:</b>
|
||||
• Цена: {price_text}
|
||||
• Код страны: {server.country_code or 'Не указан'}
|
||||
• Лимит пользователей: {server.max_users or 'Без лимита'}
|
||||
• Текущих пользователей: {server.current_users}
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
<b>Описание:</b>
|
||||
{server.description or 'Не указано'}
|
||||
|
||||
Выберите что изменить:
|
||||
"""
|
||||
|
||||
keyboard = [
|
||||
[
|
||||
types.InlineKeyboardButton(text="✏️ Название", callback_data=f"admin_server_edit_name_{server.id}"),
|
||||
types.InlineKeyboardButton(text="💰 Цена", callback_data=f"admin_server_edit_price_{server.id}")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="🌍 Страна", callback_data=f"admin_server_edit_country_{server.id}"),
|
||||
types.InlineKeyboardButton(text="👥 Лимит", callback_data=f"admin_server_edit_limit_{server.id}")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="📝 Описание", callback_data=f"admin_server_edit_desc_{server.id}")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="❌ Отключить" if server.is_available else "✅ Включить",
|
||||
callback_data=f"admin_server_toggle_{server.id}"
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="🗑️ Удалить", callback_data=f"admin_server_delete_{server.id}"),
|
||||
types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_servers_list")
|
||||
]
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_server_users(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
payload = callback.data.split("admin_server_users_", 1)[-1]
|
||||
payload_parts = payload.split("_")
|
||||
|
||||
server_id = int(payload_parts[0])
|
||||
page = int(payload_parts[1]) if len(payload_parts) > 1 else 1
|
||||
page = max(page, 1)
|
||||
server = await get_server_squad_by_id(db, server_id)
|
||||
|
||||
if not server:
|
||||
await callback.answer("❌ Сервер не найден!", show_alert=True)
|
||||
return
|
||||
|
||||
users = await get_server_connected_users(db, server_id)
|
||||
total_users = len(users)
|
||||
|
||||
page_size = 10
|
||||
total_pages = max((total_users + page_size - 1) // page_size, 1)
|
||||
|
||||
if page > total_pages:
|
||||
page = total_pages
|
||||
|
||||
start_index = (page - 1) * page_size
|
||||
end_index = start_index + page_size
|
||||
page_users = users[start_index:end_index]
|
||||
|
||||
safe_name = html.escape(server.display_name or "—")
|
||||
safe_uuid = html.escape(server.squad_uuid or "—")
|
||||
|
||||
header = [
|
||||
"🌐 <b>Пользователи сервера</b>",
|
||||
"",
|
||||
f"• Сервер: {safe_name}",
|
||||
f"• UUID: <code>{safe_uuid}</code>",
|
||||
f"• Подключений: {total_users}",
|
||||
]
|
||||
|
||||
|
||||
if total_pages > 1:
|
||||
header.append(f"• Страница: {page}/{total_pages}")
|
||||
|
||||
header.append("")
|
||||
|
||||
text = "\n".join(header)
|
||||
|
||||
def _get_status_icon(status_text: str) -> str:
|
||||
if not status_text:
|
||||
return ""
|
||||
|
||||
parts = status_text.split(" ", 1)
|
||||
return parts[0] if parts else status_text
|
||||
|
||||
if users:
|
||||
lines = []
|
||||
for index, user in enumerate(page_users, start=start_index + 1):
|
||||
safe_user_name = html.escape(user.full_name)
|
||||
user_link = f'<a href="tg://user?id={user.telegram_id}">{safe_user_name}</a>'
|
||||
lines.append(f"{index}. {user_link}")
|
||||
|
||||
text += "\n" + "\n".join(lines)
|
||||
else:
|
||||
text += "Пользователи не найдены."
|
||||
|
||||
keyboard: list[list[types.InlineKeyboardButton]] = []
|
||||
|
||||
for user in page_users:
|
||||
display_name = user.full_name
|
||||
if len(display_name) > 30:
|
||||
display_name = display_name[:27] + "..."
|
||||
|
||||
subscription_status = (
|
||||
user.subscription.status_display
|
||||
if user.subscription
|
||||
else "❌ Нет подписки"
|
||||
)
|
||||
status_icon = _get_status_icon(subscription_status)
|
||||
|
||||
if status_icon:
|
||||
button_text = f"{status_icon} {display_name}"
|
||||
else:
|
||||
button_text = display_name
|
||||
|
||||
keyboard.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=button_text,
|
||||
callback_data=f"admin_user_manage_{user.id}",
|
||||
)
|
||||
])
|
||||
|
||||
if total_pages > 1:
|
||||
navigation_buttons: list[types.InlineKeyboardButton] = []
|
||||
|
||||
if page > 1:
|
||||
navigation_buttons.append(
|
||||
types.InlineKeyboardButton(
|
||||
text="⬅️ Предыдущая",
|
||||
callback_data=f"admin_server_users_{server_id}_{page - 1}",
|
||||
)
|
||||
)
|
||||
|
||||
navigation_buttons.append(
|
||||
types.InlineKeyboardButton(
|
||||
text=f"Стр. {page}/{total_pages}",
|
||||
callback_data=f"admin_server_users_{server_id}_{page}",
|
||||
)
|
||||
)
|
||||
|
||||
if page < total_pages:
|
||||
navigation_buttons.append(
|
||||
types.InlineKeyboardButton(
|
||||
text="Следующая ➡️",
|
||||
callback_data=f"admin_server_users_{server_id}_{page + 1}",
|
||||
)
|
||||
)
|
||||
|
||||
keyboard.append(navigation_buttons)
|
||||
|
||||
keyboard.append([
|
||||
types.InlineKeyboardButton(
|
||||
text="⬅️ К серверу", callback_data=f"admin_server_edit_{server_id}"
|
||||
)
|
||||
])
|
||||
|
||||
keyboard.append([
|
||||
types.InlineKeyboardButton(
|
||||
text="⬅️ К списку", callback_data="admin_servers_list"
|
||||
)
|
||||
])
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
parse_mode="HTML"
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@@ -296,65 +521,50 @@ async def toggle_server_availability(
|
||||
new_status = not server.is_available
|
||||
await update_server_squad(db, server_id, is_available=new_status)
|
||||
|
||||
await cache.delete("available_countries")
|
||||
await cache.delete_pattern("available_countries*")
|
||||
|
||||
status_text = "включен" if new_status else "отключен"
|
||||
await callback.answer(f"✅ Сервер {status_text}!")
|
||||
|
||||
server = await get_server_squad_by_id(db, server_id)
|
||||
|
||||
status_emoji = "✅ Доступен" if server.is_available else "❌ Недоступен"
|
||||
price_text = f"{server.price_rubles:.2f} ₽" if server.price_kopeks > 0 else "Бесплатно"
|
||||
|
||||
text = f"""
|
||||
🌐 <b>Редактирование сервера</b>
|
||||
text, keyboard = _build_server_edit_view(server)
|
||||
|
||||
<b>Информация:</b>
|
||||
• ID: {server.id}
|
||||
• UUID: <code>{server.squad_uuid}</code>
|
||||
• Название: {server.display_name}
|
||||
• Оригинальное: {server.original_name or 'Не указано'}
|
||||
• Статус: {status_emoji}
|
||||
|
||||
<b>Настройки:</b>
|
||||
• Цена: {price_text}
|
||||
• Код страны: {server.country_code or 'Не указан'}
|
||||
• Лимит пользователей: {server.max_users or 'Без лимита'}
|
||||
• Текущих пользователей: {server.current_users}
|
||||
|
||||
<b>Описание:</b>
|
||||
{server.description or 'Не указано'}
|
||||
|
||||
Выберите что изменить:
|
||||
"""
|
||||
|
||||
keyboard = [
|
||||
[
|
||||
types.InlineKeyboardButton(text="✏️ Название", callback_data=f"admin_server_edit_name_{server.id}"),
|
||||
types.InlineKeyboardButton(text="💰 Цена", callback_data=f"admin_server_edit_price_{server.id}")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="🌍 Страна", callback_data=f"admin_server_edit_country_{server.id}"),
|
||||
types.InlineKeyboardButton(text="👥 Лимит", callback_data=f"admin_server_edit_limit_{server.id}")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="📝 Описание", callback_data=f"admin_server_edit_desc_{server.id}")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="❌ Отключить" if server.is_available else "✅ Включить",
|
||||
callback_data=f"admin_server_toggle_{server.id}"
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="🗑️ Удалить", callback_data=f"admin_server_delete_{server.id}"),
|
||||
types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_servers_list")
|
||||
]
|
||||
]
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def toggle_server_trial_assignment(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
server_id = int(callback.data.split('_')[-1])
|
||||
server = await get_server_squad_by_id(db, server_id)
|
||||
|
||||
if not server:
|
||||
await callback.answer("❌ Сервер не найден!", show_alert=True)
|
||||
return
|
||||
|
||||
new_status = not server.is_trial_eligible
|
||||
await update_server_squad(db, server_id, is_trial_eligible=new_status)
|
||||
|
||||
status_text = "будет выдаваться" if new_status else "перестанет выдаваться"
|
||||
await callback.answer(f"✅ Сквад {status_text} в триал")
|
||||
|
||||
server = await get_server_squad_by_id(db, server_id)
|
||||
|
||||
text, keyboard = _build_server_edit_view(server)
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
@@ -378,7 +588,7 @@ async def start_server_edit_price(
|
||||
await state.set_data({'server_id': server_id})
|
||||
await state.set_state(AdminStates.editing_server_price)
|
||||
|
||||
current_price = f"{server.price_rubles:.2f} ₽" if server.price_kopeks > 0 else "Бесплатно"
|
||||
current_price = f"{int(server.price_rubles)} ₽" if server.price_kopeks > 0 else "Бесплатно"
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"💰 <b>Редактирование цены</b>\n\n"
|
||||
@@ -422,9 +632,9 @@ async def process_server_price_edit(
|
||||
if server:
|
||||
await state.clear()
|
||||
|
||||
await cache.delete("available_countries")
|
||||
await cache.delete_pattern("available_countries*")
|
||||
|
||||
price_text = f"{price_rubles:.2f} ₽" if price_kopeks > 0 else "Бесплатно"
|
||||
price_text = f"{int(price_rubles)} ₽" if price_kopeks > 0 else "Бесплатно"
|
||||
await message.answer(
|
||||
f"✅ Цена сервера изменена на: <b>{price_text}</b>",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
@@ -497,7 +707,7 @@ async def process_server_name_edit(
|
||||
if server:
|
||||
await state.clear()
|
||||
|
||||
await cache.delete("available_countries")
|
||||
await cache.delete_pattern("available_countries*")
|
||||
|
||||
await message.answer(
|
||||
f"✅ Название сервера изменено на: <b>{new_name}</b>",
|
||||
@@ -570,7 +780,7 @@ async def delete_server_execute(
|
||||
success = await delete_server_squad(db, server_id)
|
||||
|
||||
if success:
|
||||
await cache.delete("available_countries")
|
||||
await cache.delete_pattern("available_countries*")
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"✅ Сервер <b>{server.display_name}</b> успешно удален!",
|
||||
@@ -613,8 +823,8 @@ async def show_server_detailed_stats(
|
||||
• С активными подключениями: {stats['servers_with_connections']}
|
||||
|
||||
<b>💰 Финансовая статистика:</b>
|
||||
• Общая выручка: {stats['total_revenue_rubles']:.2f} ₽
|
||||
• Средняя цена за сервер: {(stats['total_revenue_rubles'] / max(stats['servers_with_connections'], 1)):.2f} ₽
|
||||
• Общая выручка: {int(stats['total_revenue_rubles'])} ₽
|
||||
• Средняя цена за сервер: {int(stats['total_revenue_rubles'] / max(stats['servers_with_connections'], 1))} ₽
|
||||
|
||||
<b>🔥 Топ серверов по цене:</b>
|
||||
"""
|
||||
@@ -622,7 +832,7 @@ async def show_server_detailed_stats(
|
||||
sorted_servers = sorted(available_servers, key=lambda x: x.price_kopeks, reverse=True)
|
||||
|
||||
for i, server in enumerate(sorted_servers[:5], 1):
|
||||
price_text = f"{server.price_rubles:.2f} ₽" if server.price_kopeks > 0 else "Бесплатно"
|
||||
price_text = f"{int(server.price_rubles)} ₽" if server.price_kopeks > 0 else "Бесплатно"
|
||||
text += f"{i}. {server.display_name} - {price_text}\n"
|
||||
|
||||
if not sorted_servers:
|
||||
@@ -701,7 +911,7 @@ async def process_server_country_edit(
|
||||
if server:
|
||||
await state.clear()
|
||||
|
||||
await cache.delete("available_countries")
|
||||
await cache.delete_pattern("available_countries*")
|
||||
|
||||
country_text = new_country or "Удален"
|
||||
await message.answer(
|
||||
@@ -847,11 +1057,12 @@ async def process_server_description_edit(
|
||||
return
|
||||
|
||||
server = await update_server_squad(db, server_id, description=new_description)
|
||||
|
||||
|
||||
if server:
|
||||
await state.clear()
|
||||
|
||||
|
||||
desc_text = new_description or "Удалено"
|
||||
await cache.delete_pattern("available_countries*")
|
||||
await message.answer(
|
||||
f"✅ Описание сервера изменено:\n\n<i>{desc_text}</i>",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
@@ -862,6 +1073,147 @@ async def process_server_description_edit(
|
||||
else:
|
||||
await message.answer("❌ Ошибка при обновлении сервера")
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_server_edit_promo_groups(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
|
||||
server_id = int(callback.data.split('_')[-1])
|
||||
server = await get_server_squad_by_id(db, server_id)
|
||||
|
||||
if not server:
|
||||
await callback.answer("❌ Сервер не найден!", show_alert=True)
|
||||
return
|
||||
|
||||
promo_groups_data = await get_promo_groups_with_counts(db)
|
||||
promo_groups = [
|
||||
{"id": group.id, "name": group.name, "is_default": group.is_default}
|
||||
for group, _ in promo_groups_data
|
||||
]
|
||||
|
||||
if not promo_groups:
|
||||
await callback.answer("❌ Не найдены промогруппы", show_alert=True)
|
||||
return
|
||||
|
||||
selected_ids = {pg.id for pg in server.allowed_promo_groups}
|
||||
if not selected_ids:
|
||||
default_group = next((pg for pg in promo_groups if pg["is_default"]), None)
|
||||
if default_group:
|
||||
selected_ids.add(default_group["id"])
|
||||
|
||||
await state.set_state(AdminStates.editing_server_promo_groups)
|
||||
await state.set_data(
|
||||
{
|
||||
"server_id": server_id,
|
||||
"promo_groups": promo_groups,
|
||||
"selected_promo_groups": list(selected_ids),
|
||||
"server_name": server.display_name,
|
||||
}
|
||||
)
|
||||
|
||||
text = (
|
||||
"🎯 <b>Настройка промогрупп</b>\n\n"
|
||||
f"Сервер: <b>{server.display_name}</b>\n\n"
|
||||
"Выберите промогруппы, которым будет доступен этот сервер.\n"
|
||||
"Должна быть выбрана минимум одна промогруппа."
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=_build_server_promo_groups_keyboard(server_id, promo_groups, selected_ids),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def toggle_server_promo_group(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
|
||||
parts = callback.data.split('_')
|
||||
server_id = int(parts[4])
|
||||
group_id = int(parts[5])
|
||||
|
||||
data = await state.get_data()
|
||||
if not data or data.get("server_id") != server_id:
|
||||
await callback.answer("⚠️ Сессия редактирования устарела", show_alert=True)
|
||||
return
|
||||
|
||||
selected = set(int(pg_id) for pg_id in data.get("selected_promo_groups", []))
|
||||
promo_groups = data.get("promo_groups", [])
|
||||
|
||||
if group_id in selected:
|
||||
if len(selected) == 1:
|
||||
await callback.answer("⚠️ Нельзя отключить последнюю промогруппу", show_alert=True)
|
||||
return
|
||||
selected.remove(group_id)
|
||||
message = "Промогруппа отключена"
|
||||
else:
|
||||
selected.add(group_id)
|
||||
message = "Промогруппа добавлена"
|
||||
|
||||
await state.update_data(selected_promo_groups=list(selected))
|
||||
|
||||
await callback.message.edit_reply_markup(
|
||||
reply_markup=_build_server_promo_groups_keyboard(server_id, promo_groups, selected)
|
||||
)
|
||||
await callback.answer(message)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def save_server_promo_groups(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
|
||||
data = await state.get_data()
|
||||
if not data:
|
||||
await callback.answer("⚠️ Нет данных для сохранения", show_alert=True)
|
||||
return
|
||||
|
||||
server_id = data.get("server_id")
|
||||
selected = data.get("selected_promo_groups", [])
|
||||
|
||||
if not selected:
|
||||
await callback.answer("❌ Выберите хотя бы одну промогруппу", show_alert=True)
|
||||
return
|
||||
|
||||
try:
|
||||
server = await update_server_squad_promo_groups(db, server_id, selected)
|
||||
except ValueError as exc:
|
||||
await callback.answer(f"❌ {exc}", show_alert=True)
|
||||
return
|
||||
|
||||
if not server:
|
||||
await callback.answer("❌ Сервер не найден", show_alert=True)
|
||||
return
|
||||
|
||||
await cache.delete_pattern("available_countries*")
|
||||
await state.clear()
|
||||
|
||||
text, keyboard = _build_server_edit_view(server)
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await callback.answer("✅ Промогруппы обновлены!")
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def sync_server_user_counts_handler(
|
||||
@@ -934,12 +1286,24 @@ def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(sync_server_user_counts_handler, F.data == "admin_servers_sync_counts")
|
||||
dp.callback_query.register(show_server_detailed_stats, F.data == "admin_servers_stats")
|
||||
|
||||
dp.callback_query.register(show_server_edit_menu, F.data.startswith("admin_server_edit_") & ~F.data.contains("name") & ~F.data.contains("price") & ~F.data.contains("country") & ~F.data.contains("limit") & ~F.data.contains("desc"))
|
||||
dp.callback_query.register(
|
||||
show_server_edit_menu,
|
||||
F.data.startswith("admin_server_edit_")
|
||||
& ~F.data.contains("name")
|
||||
& ~F.data.contains("price")
|
||||
& ~F.data.contains("country")
|
||||
& ~F.data.contains("limit")
|
||||
& ~F.data.contains("desc")
|
||||
& ~F.data.contains("promo"),
|
||||
)
|
||||
dp.callback_query.register(toggle_server_availability, F.data.startswith("admin_server_toggle_"))
|
||||
|
||||
dp.callback_query.register(toggle_server_trial_assignment, F.data.startswith("admin_server_trial_"))
|
||||
dp.callback_query.register(show_server_users, F.data.startswith("admin_server_users_"))
|
||||
|
||||
dp.callback_query.register(start_server_edit_name, F.data.startswith("admin_server_edit_name_"))
|
||||
dp.callback_query.register(start_server_edit_price, F.data.startswith("admin_server_edit_price_"))
|
||||
dp.callback_query.register(start_server_edit_country, F.data.startswith("admin_server_edit_country_"))
|
||||
dp.callback_query.register(start_server_edit_country, F.data.startswith("admin_server_edit_country_"))
|
||||
dp.callback_query.register(start_server_edit_promo_groups, F.data.startswith("admin_server_edit_promo_"))
|
||||
dp.callback_query.register(start_server_edit_limit, F.data.startswith("admin_server_edit_limit_"))
|
||||
dp.callback_query.register(start_server_edit_description, F.data.startswith("admin_server_edit_desc_"))
|
||||
|
||||
@@ -947,7 +1311,9 @@ def register_handlers(dp: Dispatcher):
|
||||
dp.message.register(process_server_price_edit, AdminStates.editing_server_price)
|
||||
dp.message.register(process_server_country_edit, AdminStates.editing_server_country)
|
||||
dp.message.register(process_server_limit_edit, AdminStates.editing_server_limit)
|
||||
dp.message.register(process_server_description_edit, AdminStates.editing_server_description)
|
||||
dp.message.register(process_server_description_edit, AdminStates.editing_server_description)
|
||||
dp.callback_query.register(toggle_server_promo_group, F.data.startswith("admin_server_promo_toggle_"))
|
||||
dp.callback_query.register(save_server_promo_groups, F.data.startswith("admin_server_promo_save_"))
|
||||
|
||||
dp.callback_query.register(delete_server_confirm, F.data.startswith("admin_server_delete_") & ~F.data.contains("confirm"))
|
||||
dp.callback_query.register(delete_server_execute, F.data.startswith("admin_server_delete_confirm_"))
|
||||
|
||||
@@ -4,7 +4,6 @@ from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
|
||||
from app.config import settings
|
||||
from app.states import AdminStates
|
||||
from app.database.models import User
|
||||
from app.keyboards.admin import get_admin_subscriptions_keyboard
|
||||
@@ -87,10 +86,6 @@ async def show_subscriptions_menu(
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="📊 Статистика", callback_data="admin_subs_stats"),
|
||||
types.InlineKeyboardButton(text="💰 Настройки цен", callback_data="admin_subs_pricing")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="🌐 Управление серверами", callback_data="admin_servers"),
|
||||
types.InlineKeyboardButton(text="🌍 География", callback_data="admin_subs_countries")
|
||||
],
|
||||
[
|
||||
@@ -277,56 +272,6 @@ async def show_subscriptions_stats(
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_pricing_settings(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
text = f"""
|
||||
⚙️ <b>Настройки цен</b>
|
||||
|
||||
<b>Периоды подписки:</b>
|
||||
- 14 дней: {settings.format_price(settings.PRICE_14_DAYS)}
|
||||
- 30 дней: {settings.format_price(settings.PRICE_30_DAYS)}
|
||||
- 60 дней: {settings.format_price(settings.PRICE_60_DAYS)}
|
||||
- 90 дней: {settings.format_price(settings.PRICE_90_DAYS)}
|
||||
- 180 дней: {settings.format_price(settings.PRICE_180_DAYS)}
|
||||
- 360 дней: {settings.format_price(settings.PRICE_360_DAYS)}
|
||||
|
||||
<b>Трафик-пакеты:</b>
|
||||
- 5 ГБ: {settings.format_price(settings.PRICE_TRAFFIC_5GB)}
|
||||
- 10 ГБ: {settings.format_price(settings.PRICE_TRAFFIC_10GB)}
|
||||
- 25 ГБ: {settings.format_price(settings.PRICE_TRAFFIC_25GB)}
|
||||
- 50 ГБ: {settings.format_price(settings.PRICE_TRAFFIC_50GB)}
|
||||
- 100 ГБ: {settings.format_price(settings.PRICE_TRAFFIC_100GB)}
|
||||
- 250 ГБ: {settings.format_price(settings.PRICE_TRAFFIC_250GB)}
|
||||
|
||||
<b>Дополнительно:</b>
|
||||
- За устройство: {settings.format_price(settings.PRICE_PER_DEVICE)}
|
||||
"""
|
||||
|
||||
keyboard = [
|
||||
# [
|
||||
# types.InlineKeyboardButton(text="📅 Периоды", callback_data="admin_edit_period_prices"),
|
||||
# types.InlineKeyboardButton(text="📈 Трафик", callback_data="admin_edit_traffic_prices")
|
||||
# ],
|
||||
# [
|
||||
# types.InlineKeyboardButton(text="📱 Устройства", callback_data="admin_edit_device_price")
|
||||
# ],
|
||||
[
|
||||
types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_subscriptions")
|
||||
]
|
||||
]
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_countries_management(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
@@ -487,7 +432,6 @@ def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(show_subscriptions_list, F.data == "admin_subs_list")
|
||||
dp.callback_query.register(show_expiring_subscriptions, F.data == "admin_subs_expiring")
|
||||
dp.callback_query.register(show_subscriptions_stats, F.data == "admin_subs_stats")
|
||||
dp.callback_query.register(show_pricing_settings, F.data == "admin_subs_pricing")
|
||||
dp.callback_query.register(show_countries_management, F.data == "admin_subs_countries")
|
||||
dp.callback_query.register(send_expiry_reminders, F.data == "admin_send_expiry_reminders")
|
||||
|
||||
|
||||
@@ -0,0 +1,468 @@
|
||||
import logging
|
||||
import re
|
||||
import html
|
||||
import contextlib
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import User
|
||||
from app.config import settings
|
||||
from app.localization.texts import get_texts
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
from app.services.support_settings_service import SupportSettingsService
|
||||
from app.states import SupportSettingsStates
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_support_settings_keyboard(language: str) -> types.InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
mode = SupportSettingsService.get_system_mode()
|
||||
menu_enabled = SupportSettingsService.is_support_menu_enabled()
|
||||
admin_notif = SupportSettingsService.get_admin_ticket_notifications_enabled()
|
||||
user_notif = SupportSettingsService.get_user_ticket_notifications_enabled()
|
||||
sla_enabled = SupportSettingsService.get_sla_enabled()
|
||||
sla_minutes = SupportSettingsService.get_sla_minutes()
|
||||
|
||||
rows: list[list[types.InlineKeyboardButton]] = []
|
||||
|
||||
status_enabled = texts.t("ADMIN_SUPPORT_SETTINGS_STATUS_ENABLED", "Включены")
|
||||
status_disabled = texts.t("ADMIN_SUPPORT_SETTINGS_STATUS_DISABLED", "Отключены")
|
||||
|
||||
def mode_button(label_key: str, default: str, active: bool) -> str:
|
||||
prefix = "🔘" if active else "⚪"
|
||||
return f"{prefix} {texts.t(label_key, default)}"
|
||||
|
||||
rows.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=(
|
||||
f"{'✅' if menu_enabled else '🚫'} "
|
||||
f"{texts.t('ADMIN_SUPPORT_SETTINGS_MENU_LABEL', 'Пункт «Техподдержка» в меню')}"
|
||||
),
|
||||
callback_data="admin_support_toggle_menu"
|
||||
)
|
||||
])
|
||||
|
||||
rows.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=mode_button("ADMIN_SUPPORT_SETTINGS_MODE_TICKETS", "Тикеты", mode == "tickets"),
|
||||
callback_data="admin_support_mode_tickets"
|
||||
),
|
||||
types.InlineKeyboardButton(
|
||||
text=mode_button("ADMIN_SUPPORT_SETTINGS_MODE_CONTACT", "Контакт", mode == "contact"),
|
||||
callback_data="admin_support_mode_contact"
|
||||
),
|
||||
types.InlineKeyboardButton(
|
||||
text=mode_button("ADMIN_SUPPORT_SETTINGS_MODE_BOTH", "Оба", mode == "both"),
|
||||
callback_data="admin_support_mode_both"
|
||||
),
|
||||
])
|
||||
|
||||
rows.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("ADMIN_SUPPORT_SETTINGS_EDIT_DESCRIPTION", "📝 Изменить описание"),
|
||||
callback_data="admin_support_edit_desc"
|
||||
)
|
||||
])
|
||||
|
||||
# Notifications block
|
||||
rows.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=(
|
||||
f"{'🔔' if admin_notif else '🔕'} "
|
||||
f"{texts.t('ADMIN_SUPPORT_SETTINGS_ADMIN_NOTIFICATIONS', 'Админ-уведомления')}: "
|
||||
f"{status_enabled if admin_notif else status_disabled}"
|
||||
),
|
||||
callback_data="admin_support_toggle_admin_notifications"
|
||||
)
|
||||
])
|
||||
rows.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=(
|
||||
f"{'🔔' if user_notif else '🔕'} "
|
||||
f"{texts.t('ADMIN_SUPPORT_SETTINGS_USER_NOTIFICATIONS', 'Пользовательские уведомления')}: "
|
||||
f"{status_enabled if user_notif else status_disabled}"
|
||||
),
|
||||
callback_data="admin_support_toggle_user_notifications"
|
||||
)
|
||||
])
|
||||
|
||||
# SLA block
|
||||
rows.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=(
|
||||
f"{'⏰' if sla_enabled else '⏹️'} "
|
||||
f"{texts.t('ADMIN_SUPPORT_SETTINGS_SLA_LABEL', 'SLA')}: "
|
||||
f"{status_enabled if sla_enabled else status_disabled}"
|
||||
),
|
||||
callback_data="admin_support_toggle_sla"
|
||||
)
|
||||
])
|
||||
rows.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("ADMIN_SUPPORT_SETTINGS_SLA_TIME", "⏳ Время SLA: {minutes} мин").format(minutes=sla_minutes),
|
||||
callback_data="admin_support_set_sla_minutes"
|
||||
)
|
||||
])
|
||||
|
||||
# Moderators
|
||||
moderators = SupportSettingsService.get_moderators()
|
||||
mod_count = len(moderators)
|
||||
rows.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("ADMIN_SUPPORT_SETTINGS_MODERATORS_COUNT", "🧑⚖️ Модераторы: {count}").format(count=mod_count),
|
||||
callback_data="admin_support_list_moderators"
|
||||
)
|
||||
])
|
||||
rows.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("ADMIN_SUPPORT_SETTINGS_ADD_MODERATOR", "➕ Назначить модератора"),
|
||||
callback_data="admin_support_add_moderator"
|
||||
),
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("ADMIN_SUPPORT_SETTINGS_REMOVE_MODERATOR", "➖ Удалить модератора"),
|
||||
callback_data="admin_support_remove_moderator"
|
||||
)
|
||||
])
|
||||
|
||||
rows.append([
|
||||
types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_submenu_support")
|
||||
])
|
||||
|
||||
return types.InlineKeyboardMarkup(inline_keyboard=rows)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_support_settings(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
desc = SupportSettingsService.get_support_info_text(db_user.language)
|
||||
await callback.message.edit_text(
|
||||
texts.t("ADMIN_SUPPORT_SETTINGS_TITLE", "🛟 <b>Настройки поддержки</b>") + "\n\n" +
|
||||
texts.t("ADMIN_SUPPORT_SETTINGS_DESCRIPTION", "Режим работы и видимость в меню. Ниже текущее описание меню поддержки:") + "\n\n" +
|
||||
desc,
|
||||
reply_markup=_get_support_settings_keyboard(db_user.language),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def toggle_support_menu(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
current = SupportSettingsService.is_support_menu_enabled()
|
||||
SupportSettingsService.set_support_menu_enabled(not current)
|
||||
await show_support_settings(callback, db_user, db)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def toggle_admin_notifications(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||||
current = SupportSettingsService.get_admin_ticket_notifications_enabled()
|
||||
SupportSettingsService.set_admin_ticket_notifications_enabled(not current)
|
||||
await show_support_settings(callback, db_user, db)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def toggle_user_notifications(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||||
current = SupportSettingsService.get_user_ticket_notifications_enabled()
|
||||
SupportSettingsService.set_user_ticket_notifications_enabled(not current)
|
||||
await show_support_settings(callback, db_user, db)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def toggle_sla(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||||
current = SupportSettingsService.get_sla_enabled()
|
||||
SupportSettingsService.set_sla_enabled(not current)
|
||||
await show_support_settings(callback, db_user, db)
|
||||
|
||||
|
||||
class SupportAdvancedStates(StatesGroup):
|
||||
waiting_for_sla_minutes = State()
|
||||
waiting_for_moderator_id = State()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_set_sla_minutes(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
|
||||
texts = get_texts(db_user.language)
|
||||
await callback.message.edit_text(
|
||||
texts.t(
|
||||
"ADMIN_SUPPORT_SLA_SETUP_PROMPT",
|
||||
"⏳ <b>Настройка SLA</b>\n\nВведите количество минут ожидания ответа (целое число > 0):"
|
||||
),
|
||||
parse_mode="HTML",
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[[types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_support_settings")]]
|
||||
)
|
||||
)
|
||||
await state.set_state(SupportAdvancedStates.waiting_for_sla_minutes)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def handle_sla_minutes(message: types.Message, db_user: User, db: AsyncSession, state: FSMContext):
|
||||
texts = get_texts(db_user.language)
|
||||
text = (message.text or "").strip()
|
||||
try:
|
||||
minutes = int(text)
|
||||
if minutes <= 0 or minutes > 1440:
|
||||
raise ValueError()
|
||||
except Exception:
|
||||
await message.answer(texts.t("ADMIN_SUPPORT_SLA_INVALID", "❌ Введите корректное число минут (1-1440)"))
|
||||
return
|
||||
SupportSettingsService.set_sla_minutes(minutes)
|
||||
await state.clear()
|
||||
markup = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[[types.InlineKeyboardButton(text=texts.t("DELETE_MESSAGE", "🗑 Удалить"), callback_data="admin_support_delete_msg")]]
|
||||
)
|
||||
await message.answer(texts.t("ADMIN_SUPPORT_SLA_SAVED", "✅ Значение SLA сохранено"), reply_markup=markup)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_add_moderator(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
|
||||
texts = get_texts(db_user.language)
|
||||
await callback.message.edit_text(
|
||||
texts.t(
|
||||
"ADMIN_SUPPORT_ASSIGN_MODERATOR_PROMPT",
|
||||
"🧑⚖️ <b>Назначение модератора</b>\n\nОтправьте Telegram ID пользователя (число)"
|
||||
),
|
||||
parse_mode="HTML",
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[[types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_support_settings")]]
|
||||
)
|
||||
)
|
||||
await state.set_state(SupportAdvancedStates.waiting_for_moderator_id)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_remove_moderator(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
|
||||
texts = get_texts(db_user.language)
|
||||
await callback.message.edit_text(
|
||||
texts.t(
|
||||
"ADMIN_SUPPORT_REMOVE_MODERATOR_PROMPT",
|
||||
"🧑⚖️ <b>Удаление модератора</b>\n\nОтправьте Telegram ID пользователя (число)"
|
||||
),
|
||||
parse_mode="HTML",
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[[types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_support_settings")]]
|
||||
)
|
||||
)
|
||||
await state.set_state(SupportAdvancedStates.waiting_for_moderator_id)
|
||||
# We'll reuse the same state; next message will decide action via flag
|
||||
await state.update_data(action="remove_moderator")
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def handle_moderator_id(message: types.Message, db_user: User, db: AsyncSession, state: FSMContext):
|
||||
texts = get_texts(db_user.language)
|
||||
data = await state.get_data()
|
||||
action = data.get("action", "add")
|
||||
text = (message.text or "").strip()
|
||||
try:
|
||||
tid = int(text)
|
||||
except Exception:
|
||||
await message.answer(texts.t("ADMIN_SUPPORT_INVALID_TELEGRAM_ID", "❌ Введите корректный Telegram ID (число)"))
|
||||
return
|
||||
if action == "remove_moderator":
|
||||
ok = SupportSettingsService.remove_moderator(tid)
|
||||
msg = (
|
||||
texts.t("ADMIN_SUPPORT_MODERATOR_REMOVED_SUCCESS", "✅ Модератор {tid} удалён").format(tid=tid)
|
||||
if ok
|
||||
else texts.t("ADMIN_SUPPORT_MODERATOR_REMOVED_FAIL", "❌ Не удалось удалить модератора")
|
||||
)
|
||||
else:
|
||||
ok = SupportSettingsService.add_moderator(tid)
|
||||
msg = (
|
||||
texts.t("ADMIN_SUPPORT_MODERATOR_ADDED_SUCCESS", "✅ Пользователь {tid} назначен модератором").format(tid=tid)
|
||||
if ok
|
||||
else texts.t("ADMIN_SUPPORT_MODERATOR_ADDED_FAIL", "❌ Не удалось назначить модератора")
|
||||
)
|
||||
await state.clear()
|
||||
markup = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[[types.InlineKeyboardButton(text=texts.t("DELETE_MESSAGE", "🗑 Удалить"), callback_data="admin_support_delete_msg")]]
|
||||
)
|
||||
await message.answer(msg, reply_markup=markup)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def list_moderators(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||||
texts = get_texts(db_user.language)
|
||||
moderators = SupportSettingsService.get_moderators()
|
||||
if not moderators:
|
||||
await callback.answer(texts.t("ADMIN_SUPPORT_MODERATORS_EMPTY", "Список пуст"), show_alert=True)
|
||||
return
|
||||
text = (
|
||||
texts.t("ADMIN_SUPPORT_MODERATORS_TITLE", "🧑⚖️ <b>Модераторы</b>") +
|
||||
"\n\n" + "\n".join([f"• <code>{tid}</code>" for tid in moderators])
|
||||
)
|
||||
markup = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[[types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_support_settings")]]
|
||||
)
|
||||
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=markup)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def set_mode_tickets(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||||
SupportSettingsService.set_system_mode("tickets")
|
||||
await show_support_settings(callback, db_user, db)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def set_mode_contact(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||||
SupportSettingsService.set_system_mode("contact")
|
||||
await show_support_settings(callback, db_user, db)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def set_mode_both(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||||
SupportSettingsService.set_system_mode("both")
|
||||
await show_support_settings(callback, db_user, db)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_edit_desc(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
|
||||
texts = get_texts(db_user.language)
|
||||
current_desc_html = SupportSettingsService.get_support_info_text(db_user.language)
|
||||
# plain text for display-only code block
|
||||
current_desc_plain = re.sub(r"<[^>]+>", "", current_desc_html)
|
||||
|
||||
kb_rows: list[list[types.InlineKeyboardButton]] = []
|
||||
kb_rows.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("ADMIN_SUPPORT_SEND_DESCRIPTION", "📨 Прислать текст"),
|
||||
callback_data="admin_support_send_desc"
|
||||
)
|
||||
])
|
||||
# Подготовим блок контакта (отдельным инлайном)
|
||||
from app.config import settings
|
||||
support_contact_display = settings.get_support_contact_display()
|
||||
kb_rows.append([
|
||||
types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_support_settings")
|
||||
])
|
||||
|
||||
text_parts = [
|
||||
texts.t("ADMIN_SUPPORT_EDIT_DESCRIPTION_TITLE", "📝 <b>Редактирование описания поддержки</b>"),
|
||||
"",
|
||||
texts.t("ADMIN_SUPPORT_EDIT_DESCRIPTION_CURRENT", "Текущее описание:"),
|
||||
"",
|
||||
f"<code>{html.escape(current_desc_plain)}</code>",
|
||||
]
|
||||
if support_contact_display:
|
||||
text_parts += [
|
||||
"",
|
||||
texts.t("ADMIN_SUPPORT_EDIT_DESCRIPTION_CONTACT_TITLE", "<b>Контакт для режима «Контакт»</b>"),
|
||||
f"<code>{html.escape(support_contact_display)}</code>",
|
||||
"",
|
||||
texts.t("ADMIN_SUPPORT_EDIT_DESCRIPTION_CONTACT_HINT", "Добавьте в описание при необходимости."),
|
||||
]
|
||||
await callback.message.edit_text(
|
||||
"\n".join(text_parts),
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=kb_rows),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await state.set_state(SupportSettingsStates.waiting_for_desc)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def handle_new_desc(message: types.Message, db_user: User, db: AsyncSession, state: FSMContext):
|
||||
texts = get_texts(db_user.language)
|
||||
new_text = message.html_text or message.text
|
||||
SupportSettingsService.set_support_info_text(db_user.language, new_text)
|
||||
await state.clear()
|
||||
markup = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[[types.InlineKeyboardButton(text=texts.t("DELETE_MESSAGE", "🗑 Удалить"), callback_data="admin_support_delete_msg")]]
|
||||
)
|
||||
await message.answer(texts.t("ADMIN_SUPPORT_DESCRIPTION_UPDATED", "✅ Описание обновлено."), reply_markup=markup)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def send_desc_copy(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||||
# send plain text for easy copying
|
||||
texts = get_texts(db_user.language)
|
||||
current_desc_html = SupportSettingsService.get_support_info_text(db_user.language)
|
||||
current_desc_plain = re.sub(r"<[^>]+>", "", current_desc_html)
|
||||
# attach delete button to the sent message
|
||||
markup = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[[types.InlineKeyboardButton(text=texts.t("DELETE_MESSAGE", "🗑 Удалить"), callback_data="admin_support_delete_msg")]]
|
||||
)
|
||||
if len(current_desc_plain) <= 4000:
|
||||
await callback.message.answer(current_desc_plain, reply_markup=markup)
|
||||
else:
|
||||
# split long messages (attach delete only to the last chunk)
|
||||
chunk = 0
|
||||
while chunk < len(current_desc_plain):
|
||||
next_chunk = current_desc_plain[chunk:chunk+4000]
|
||||
is_last = (chunk + 4000) >= len(current_desc_plain)
|
||||
await callback.message.answer(next_chunk, reply_markup=(markup if is_last else None))
|
||||
chunk += 4000
|
||||
await callback.answer(texts.t("ADMIN_SUPPORT_DESCRIPTION_SENT", "Текст отправлен ниже"))
|
||||
|
||||
|
||||
@error_handler
|
||||
async def delete_sent_message(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||||
# Allow admins and moderators to delete informational notifications
|
||||
try:
|
||||
may_delete = (settings.is_admin(callback.from_user.id) or SupportSettingsService.is_moderator(callback.from_user.id))
|
||||
except Exception:
|
||||
may_delete = False
|
||||
texts = get_texts(db_user.language if db_user else 'ru')
|
||||
if not may_delete:
|
||||
await callback.answer(texts.ACCESS_DENIED, show_alert=True)
|
||||
return
|
||||
try:
|
||||
await callback.message.delete()
|
||||
finally:
|
||||
with contextlib.suppress(Exception):
|
||||
await callback.answer(texts.t("ADMIN_SUPPORT_MESSAGE_DELETED", "Сообщение удалено"))
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(show_support_settings, F.data == "admin_support_settings")
|
||||
dp.callback_query.register(toggle_support_menu, F.data == "admin_support_toggle_menu")
|
||||
dp.callback_query.register(set_mode_tickets, F.data == "admin_support_mode_tickets")
|
||||
dp.callback_query.register(set_mode_contact, F.data == "admin_support_mode_contact")
|
||||
dp.callback_query.register(set_mode_both, F.data == "admin_support_mode_both")
|
||||
dp.callback_query.register(start_edit_desc, F.data == "admin_support_edit_desc")
|
||||
dp.callback_query.register(send_desc_copy, F.data == "admin_support_send_desc")
|
||||
dp.callback_query.register(delete_sent_message, F.data == "admin_support_delete_msg")
|
||||
dp.callback_query.register(toggle_admin_notifications, F.data == "admin_support_toggle_admin_notifications")
|
||||
dp.callback_query.register(toggle_user_notifications, F.data == "admin_support_toggle_user_notifications")
|
||||
dp.callback_query.register(toggle_sla, F.data == "admin_support_toggle_sla")
|
||||
dp.callback_query.register(start_set_sla_minutes, F.data == "admin_support_set_sla_minutes")
|
||||
dp.callback_query.register(start_add_moderator, F.data == "admin_support_add_moderator")
|
||||
dp.callback_query.register(start_remove_moderator, F.data == "admin_support_remove_moderator")
|
||||
dp.callback_query.register(list_moderators, F.data == "admin_support_list_moderators")
|
||||
dp.message.register(handle_new_desc, SupportSettingsStates.waiting_for_desc)
|
||||
dp.message.register(handle_sla_minutes, SupportAdvancedStates.waiting_for_sla_minutes)
|
||||
dp.message.register(handle_moderator_id, SupportAdvancedStates.waiting_for_moderator_id)
|
||||
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from html import escape
|
||||
from pathlib import Path
|
||||
|
||||
from aiogram import Dispatcher, F, types
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup, FSInputFile
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
LOG_PREVIEW_LIMIT = 2300
|
||||
|
||||
|
||||
def _resolve_log_path() -> Path:
|
||||
log_path = Path(settings.LOG_FILE)
|
||||
if not log_path.is_absolute():
|
||||
log_path = Path.cwd() / log_path
|
||||
return log_path
|
||||
|
||||
|
||||
def _format_preview_block(text: str) -> str:
|
||||
escaped_text = escape(text) if text else ""
|
||||
return f"<blockquote expandable><pre><code>{escaped_text}</code></pre></blockquote>"
|
||||
|
||||
|
||||
def _build_logs_message(log_path: Path) -> str:
|
||||
if not log_path.exists():
|
||||
message = (
|
||||
"🧾 <b>Системные логи</b>\n\n"
|
||||
f"Файл <code>{log_path}</code> пока не создан.\n"
|
||||
"Логи появятся автоматически после первой записи."
|
||||
)
|
||||
return message
|
||||
|
||||
try:
|
||||
content = log_path.read_text(encoding="utf-8", errors="ignore")
|
||||
except Exception as error: # pragma: no cover - защита от проблем чтения
|
||||
logger.error("Ошибка чтения лог-файла %s: %s", log_path, error)
|
||||
message = (
|
||||
"❌ <b>Ошибка чтения логов</b>\n\n"
|
||||
f"Не удалось прочитать файл <code>{log_path}</code>."
|
||||
)
|
||||
return message
|
||||
|
||||
total_length = len(content)
|
||||
stats = log_path.stat()
|
||||
updated_at = datetime.fromtimestamp(stats.st_mtime)
|
||||
|
||||
if not content:
|
||||
preview_text = "Лог-файл пуст."
|
||||
truncated = False
|
||||
else:
|
||||
preview_text = content[-LOG_PREVIEW_LIMIT:]
|
||||
truncated = total_length > LOG_PREVIEW_LIMIT
|
||||
|
||||
details_lines = [
|
||||
"🧾 <b>Системные логи</b>",
|
||||
"",
|
||||
f"📁 <b>Файл:</b> <code>{log_path}</code>",
|
||||
f"🕒 <b>Обновлен:</b> {updated_at.strftime('%d.%m.%Y %H:%M:%S')}",
|
||||
f"🧮 <b>Размер:</b> {total_length} символов",
|
||||
(
|
||||
f"👇 Показаны последние {LOG_PREVIEW_LIMIT} символов."
|
||||
if truncated
|
||||
else "📄 Показано все содержимое файла."
|
||||
),
|
||||
"",
|
||||
_format_preview_block(preview_text),
|
||||
]
|
||||
|
||||
return "\n".join(details_lines)
|
||||
|
||||
|
||||
def _get_logs_keyboard() -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_system_logs_refresh")],
|
||||
[InlineKeyboardButton(text="⬇️ Скачать лог", callback_data="admin_system_logs_download")],
|
||||
[InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_submenu_system")],
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_system_logs(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
log_path = _resolve_log_path()
|
||||
message = _build_logs_message(log_path)
|
||||
|
||||
reply_markup = _get_logs_keyboard()
|
||||
await callback.message.edit_text(message, reply_markup=reply_markup, parse_mode="HTML")
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def refresh_system_logs(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
log_path = _resolve_log_path()
|
||||
message = _build_logs_message(log_path)
|
||||
|
||||
reply_markup = _get_logs_keyboard()
|
||||
await callback.message.edit_text(message, reply_markup=reply_markup, parse_mode="HTML")
|
||||
await callback.answer("🔄 Обновлено")
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def download_system_logs(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
):
|
||||
log_path = _resolve_log_path()
|
||||
|
||||
if not log_path.exists() or not log_path.is_file():
|
||||
await callback.answer("❌ Лог-файл не найден", show_alert=True)
|
||||
return
|
||||
|
||||
try:
|
||||
await callback.answer("⬇️ Отправляю лог...")
|
||||
|
||||
document = FSInputFile(log_path)
|
||||
stats = log_path.stat()
|
||||
updated_at = datetime.fromtimestamp(stats.st_mtime).strftime("%d.%m.%Y %H:%M:%S")
|
||||
caption = (
|
||||
f"🧾 Лог-файл <code>{log_path.name}</code>\n"
|
||||
f"📁 Путь: <code>{log_path}</code>\n"
|
||||
f"🕒 Обновлен: {updated_at}"
|
||||
)
|
||||
await callback.message.answer_document(document=document, caption=caption, parse_mode="HTML")
|
||||
except Exception as error: # pragma: no cover - защита от ошибок отправки
|
||||
logger.error("Ошибка отправки лог-файла %s: %s", log_path, error)
|
||||
await callback.message.answer(
|
||||
"❌ <b>Не удалось отправить лог-файл</b>\n\n"
|
||||
"Проверьте журналы приложения или повторите попытку позже.",
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(
|
||||
show_system_logs,
|
||||
F.data == "admin_system_logs",
|
||||
)
|
||||
dp.callback_query.register(
|
||||
refresh_system_logs,
|
||||
F.data == "admin_system_logs_refresh",
|
||||
)
|
||||
dp.callback_query.register(
|
||||
download_system_logs,
|
||||
F.data == "admin_system_logs_download",
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,274 @@
|
||||
import logging
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.services.version_service import version_service
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_updates_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
buttons = [
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="🔄 Проверить обновления",
|
||||
callback_data="admin_updates_check"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="📋 Информация о версии",
|
||||
callback_data="admin_updates_info"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="🔗 Открыть репозиторий",
|
||||
url=f"https://github.com/{version_service.repo}/releases"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="◀️ Назад",
|
||||
callback_data="admin_panel"
|
||||
)
|
||||
]
|
||||
]
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
|
||||
def get_version_info_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
buttons = [
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="🔄 Обновить",
|
||||
callback_data="admin_updates_info"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="◀️ К обновлениям",
|
||||
callback_data="admin_updates"
|
||||
)
|
||||
]
|
||||
]
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_updates_menu(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
try:
|
||||
version_info = await version_service.get_version_info()
|
||||
|
||||
current_version = version_info['current_version']
|
||||
has_updates = version_info['has_updates']
|
||||
total_newer = version_info['total_newer']
|
||||
last_check = version_info['last_check']
|
||||
|
||||
status_icon = "🆕" if has_updates else "✅"
|
||||
status_text = f"Доступно {total_newer} обновлений" if has_updates else "Актуальная версия"
|
||||
|
||||
last_check_text = ""
|
||||
if last_check:
|
||||
last_check_text = f"\n🕐 Последняя проверка: {last_check.strftime('%d.%m.%Y %H:%M')}"
|
||||
|
||||
message = f"""🔄 <b>СИСТЕМА ОБНОВЛЕНИЙ</b>
|
||||
|
||||
📦 <b>Текущая версия:</b> <code>{current_version}</code>
|
||||
{status_icon} <b>Статус:</b> {status_text}
|
||||
|
||||
🔗 <b>Репозиторий:</b> {version_service.repo}{last_check_text}
|
||||
|
||||
ℹ️ Система автоматически проверяет обновления каждый час и отправляет уведомления о новых версиях."""
|
||||
|
||||
await callback.message.edit_text(
|
||||
message,
|
||||
reply_markup=get_updates_keyboard(db_user.language),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка показа меню обновлений: {e}")
|
||||
await callback.answer("❌ Ошибка загрузки меню обновлений", show_alert=True)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def check_updates(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
await callback.answer("🔄 Проверяю обновления...")
|
||||
|
||||
try:
|
||||
has_updates, newer_releases = await version_service.check_for_updates(force=True)
|
||||
|
||||
if not has_updates:
|
||||
message = f"""✅ <b>ОБНОВЛЕНИЯ НЕ НАЙДЕНЫ</b>
|
||||
|
||||
📦 <b>Текущая версия:</b> <code>{version_service.current_version}</code>
|
||||
🎯 <b>Статус:</b> У вас установлена последняя версия
|
||||
|
||||
🔗 <b>Репозиторий:</b> {version_service.repo}"""
|
||||
|
||||
else:
|
||||
updates_list = []
|
||||
for i, release in enumerate(newer_releases[:5]):
|
||||
icon = version_service.format_version_display(release).split()[0]
|
||||
updates_list.append(
|
||||
f"{i+1}. {icon} <code>{release.tag_name}</code> • {release.formatted_date}"
|
||||
)
|
||||
|
||||
updates_text = "\n".join(updates_list)
|
||||
more_text = f"\n\n📋 И еще {len(newer_releases) - 5} обновлений..." if len(newer_releases) > 5 else ""
|
||||
|
||||
message = f"""🆕 <b>НАЙДЕНЫ ОБНОВЛЕНИЯ</b>
|
||||
|
||||
📦 <b>Текущая версия:</b> <code>{version_service.current_version}</code>
|
||||
🎯 <b>Доступно обновлений:</b> {len(newer_releases)}
|
||||
|
||||
📋 <b>Последние версии:</b>
|
||||
{updates_text}{more_text}
|
||||
|
||||
🔗 <b>Репозиторий:</b> {version_service.repo}"""
|
||||
|
||||
keyboard = get_updates_keyboard(db_user.language)
|
||||
|
||||
if has_updates:
|
||||
keyboard.inline_keyboard.insert(-2, [
|
||||
InlineKeyboardButton(
|
||||
text="📋 Подробнее о версиях",
|
||||
callback_data="admin_updates_info"
|
||||
)
|
||||
])
|
||||
|
||||
await callback.message.edit_text(
|
||||
message,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка проверки обновлений: {e}")
|
||||
await callback.message.edit_text(
|
||||
f"❌ <b>ОШИБКА ПРОВЕРКИ ОБНОВЛЕНИЙ</b>\n\n"
|
||||
f"Не удалось связаться с сервером GitHub.\n"
|
||||
f"Попробуйте позже.\n\n"
|
||||
f"📦 <b>Текущая версия:</b> <code>{version_service.current_version}</code>",
|
||||
reply_markup=get_updates_keyboard(db_user.language),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_version_info(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
await callback.answer("📋 Загружаю информацию о версиях...")
|
||||
|
||||
try:
|
||||
version_info = await version_service.get_version_info()
|
||||
|
||||
current_version = version_info['current_version']
|
||||
current_release = version_info['current_release']
|
||||
newer_releases = version_info['newer_releases']
|
||||
has_updates = version_info['has_updates']
|
||||
last_check = version_info['last_check']
|
||||
repo_url = version_info['repo_url']
|
||||
|
||||
current_info = f"📦 <b>ТЕКУЩАЯ ВЕРСИЯ</b>\n\n"
|
||||
|
||||
if current_release:
|
||||
current_info += f"🏷️ <b>Версия:</b> <code>{current_release.tag_name}</code>\n"
|
||||
current_info += f"📅 <b>Дата релиза:</b> {current_release.formatted_date}\n"
|
||||
if current_release.short_description:
|
||||
current_info += f"📝 <b>Описание:</b>\n{current_release.short_description}\n"
|
||||
else:
|
||||
current_info += f"🏷️ <b>Версия:</b> <code>{current_version}</code>\n"
|
||||
current_info += f"ℹ️ <b>Статус:</b> Информация о релизе недоступна\n"
|
||||
|
||||
message_parts = [current_info]
|
||||
|
||||
if has_updates and newer_releases:
|
||||
updates_info = f"\n🆕 <b>ДОСТУПНЫЕ ОБНОВЛЕНИЯ</b>\n\n"
|
||||
|
||||
for i, release in enumerate(newer_releases):
|
||||
icon = "🔥" if i == 0 else "📦"
|
||||
if release.prerelease:
|
||||
icon = "🧪"
|
||||
elif release.is_dev:
|
||||
icon = "🔧"
|
||||
|
||||
updates_info += f"{icon} <b>{release.tag_name}</b>\n"
|
||||
updates_info += f" 📅 {release.formatted_date}\n"
|
||||
if release.short_description:
|
||||
updates_info += f" 📝 {release.short_description}\n"
|
||||
updates_info += "\n"
|
||||
|
||||
message_parts.append(updates_info.rstrip())
|
||||
|
||||
system_info = f"\n🔧 <b>СИСТЕМА ОБНОВЛЕНИЙ</b>\n\n"
|
||||
system_info += f"🔗 <b>Репозиторий:</b> {version_service.repo}\n"
|
||||
system_info += f"⚡ <b>Автопроверка:</b> {'Включена' if version_service.enabled else 'Отключена'}\n"
|
||||
system_info += f"🕐 <b>Интервал:</b> Каждый час\n"
|
||||
|
||||
if last_check:
|
||||
system_info += f"🕐 <b>Последняя проверка:</b> {last_check.strftime('%d.%m.%Y %H:%M')}\n"
|
||||
|
||||
message_parts.append(system_info.rstrip())
|
||||
|
||||
final_message = "\n".join(message_parts)
|
||||
|
||||
if len(final_message) > 4000:
|
||||
final_message = final_message[:3900] + "\n\n... (информация обрезана)"
|
||||
|
||||
await callback.message.edit_text(
|
||||
final_message,
|
||||
reply_markup=get_version_info_keyboard(db_user.language),
|
||||
parse_mode="HTML",
|
||||
disable_web_page_preview=True
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения информации о версиях: {e}")
|
||||
await callback.message.edit_text(
|
||||
f"❌ <b>ОШИБКА ЗАГРУЗКИ</b>\n\n"
|
||||
f"Не удалось получить информацию о версиях.\n\n"
|
||||
f"📦 <b>Текущая версия:</b> <code>{version_service.current_version}</code>",
|
||||
reply_markup=get_version_info_keyboard(db_user.language),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
|
||||
dp.callback_query.register(
|
||||
show_updates_menu,
|
||||
F.data == "admin_updates"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
check_updates,
|
||||
F.data == "admin_updates_check"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_version_info,
|
||||
F.data == "admin_updates_info"
|
||||
)
|
||||
@@ -0,0 +1,584 @@
|
||||
import logging
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
from aiogram.filters import StateFilter
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.crud.user_message import (
|
||||
create_user_message, get_all_user_messages, get_user_message_by_id,
|
||||
update_user_message, delete_user_message, toggle_user_message_status,
|
||||
get_user_messages_stats
|
||||
)
|
||||
from app.database.models import User
|
||||
from app.keyboards.admin import get_admin_main_keyboard
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class UserMessageStates(StatesGroup):
|
||||
waiting_for_message_text = State()
|
||||
waiting_for_edit_text = State()
|
||||
|
||||
|
||||
def get_user_messages_keyboard(language: str = "ru"):
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="📝 Добавить сообщение",
|
||||
callback_data="add_user_message"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="📋 Список сообщений",
|
||||
callback_data="list_user_messages:0"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="📊 Статистика",
|
||||
callback_data="user_messages_stats"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="🔙 Назад в админку",
|
||||
callback_data="admin_panel"
|
||||
)
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_message_actions_keyboard(message_id: int, is_active: bool, language: str = "ru"):
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
|
||||
status_text = "🔴 Деактивировать" if is_active else "🟢 Активировать"
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="✏️ Редактировать",
|
||||
callback_data=f"edit_user_message:{message_id}"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=status_text,
|
||||
callback_data=f"toggle_user_message:{message_id}"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="🗑️ Удалить",
|
||||
callback_data=f"delete_user_message:{message_id}"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="🔙 К списку",
|
||||
callback_data="list_user_messages:0"
|
||||
)
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_user_messages_panel(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
text = (
|
||||
"📢 <b>Управление сообщениями в главном меню</b>\n\n"
|
||||
"Здесь вы можете добавлять сообщения, которые будут показываться пользователям "
|
||||
"в главном меню между информацией о подписке и кнопками действий.\n\n"
|
||||
"• Сообщения поддерживают HTML теги\n"
|
||||
"• Можно создать несколько сообщений\n"
|
||||
"• Активные сообщения показываются случайно\n"
|
||||
"• Неактивные сообщения не показываются"
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=get_user_messages_keyboard(db_user.language),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def add_user_message_start(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
from app.utils.validators import get_html_help_text
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"📝 <b>Добавление нового сообщения</b>\n\n"
|
||||
f"Введите текст сообщения, которое будет показываться в главном меню.\n\n"
|
||||
f"{get_html_help_text()}\n\n"
|
||||
f"Отправьте /cancel для отмены.",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
await state.set_state(UserMessageStates.waiting_for_message_text)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_new_message_text(
|
||||
message: types.Message,
|
||||
state: FSMContext,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
if message.text == "/cancel":
|
||||
await state.clear()
|
||||
await message.answer(
|
||||
"❌ Добавление сообщения отменено.",
|
||||
reply_markup=get_user_messages_keyboard(db_user.language)
|
||||
)
|
||||
return
|
||||
|
||||
message_text = message.text.strip()
|
||||
|
||||
if len(message_text) > 4000:
|
||||
await message.answer(
|
||||
"❌ Сообщение слишком длинное. Максимум 4000 символов.\n"
|
||||
"Попробуйте еще раз или отправьте /cancel для отмены."
|
||||
)
|
||||
return
|
||||
|
||||
from app.utils.validators import validate_html_tags, get_html_help_text
|
||||
|
||||
is_valid, error_msg = validate_html_tags(message_text)
|
||||
if not is_valid:
|
||||
await message.answer(
|
||||
f"❌ Ошибка в HTML разметке: {error_msg}\n\n"
|
||||
f"Исправьте ошибку и попробуйте еще раз, или отправьте /cancel для отмены.",
|
||||
parse_mode=None
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
new_message = await create_user_message(
|
||||
db=db,
|
||||
message_text=message_text,
|
||||
created_by=db_user.id,
|
||||
is_active=True
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
|
||||
await message.answer(
|
||||
f"✅ <b>Сообщение добавлено!</b>\n\n"
|
||||
f"<b>ID:</b> {new_message.id}\n"
|
||||
f"<b>Статус:</b> {'🟢 Активно' if new_message.is_active else '🔴 Неактивно'}\n"
|
||||
f"<b>Создано:</b> {new_message.created_at.strftime('%d.%m.%Y %H:%M')}\n\n"
|
||||
f"<b>Предварительный просмотр:</b>\n"
|
||||
f"<blockquote>{message_text}</blockquote>",
|
||||
reply_markup=get_user_messages_keyboard(db_user.language),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания сообщения: {e}")
|
||||
await state.clear()
|
||||
await message.answer(
|
||||
"❌ Произошла ошибка при создании сообщения. Попробуйте еще раз.",
|
||||
reply_markup=get_user_messages_keyboard(db_user.language)
|
||||
)
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def list_user_messages(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
page = 0
|
||||
if ":" in callback.data:
|
||||
try:
|
||||
page = int(callback.data.split(":")[1])
|
||||
except (ValueError, IndexError):
|
||||
page = 0
|
||||
|
||||
limit = 5
|
||||
offset = page * limit
|
||||
|
||||
messages = await get_all_user_messages(db, offset=offset, limit=limit)
|
||||
|
||||
if not messages:
|
||||
await callback.message.edit_text(
|
||||
"📋 <b>Список сообщений</b>\n\n"
|
||||
"Сообщений пока нет. Добавьте первое сообщение!",
|
||||
reply_markup=get_user_messages_keyboard(db_user.language),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
text = "📋 <b>Список сообщений</b>\n\n"
|
||||
|
||||
for msg in messages:
|
||||
status_emoji = "🟢" if msg.is_active else "🔴"
|
||||
preview = msg.message_text[:100] + "..." if len(msg.message_text) > 100 else msg.message_text
|
||||
preview = preview.replace('<', '<').replace('>', '>')
|
||||
|
||||
text += (
|
||||
f"{status_emoji} <b>ID {msg.id}</b>\n"
|
||||
f"{preview}\n"
|
||||
f"📅 {msg.created_at.strftime('%d.%m.%Y %H:%M')}\n\n"
|
||||
)
|
||||
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
|
||||
keyboard = []
|
||||
|
||||
for msg in messages:
|
||||
status_emoji = "🟢" if msg.is_active else "🔴"
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=f"{status_emoji} ID {msg.id}",
|
||||
callback_data=f"view_user_message:{msg.id}"
|
||||
)
|
||||
])
|
||||
|
||||
nav_buttons = []
|
||||
if page > 0:
|
||||
nav_buttons.append(
|
||||
InlineKeyboardButton(
|
||||
text="⬅️ Назад",
|
||||
callback_data=f"list_user_messages:{page-1}"
|
||||
)
|
||||
)
|
||||
|
||||
nav_buttons.append(
|
||||
InlineKeyboardButton(
|
||||
text="➕ Добавить",
|
||||
callback_data="add_user_message"
|
||||
)
|
||||
)
|
||||
|
||||
if len(messages) == limit:
|
||||
nav_buttons.append(
|
||||
InlineKeyboardButton(
|
||||
text="Вперед ➡️",
|
||||
callback_data=f"list_user_messages:{page+1}"
|
||||
)
|
||||
)
|
||||
|
||||
if nav_buttons:
|
||||
keyboard.append(nav_buttons)
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text="🔙 Назад",
|
||||
callback_data="user_messages_panel"
|
||||
)
|
||||
])
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def view_user_message(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
try:
|
||||
message_id = int(callback.data.split(":")[1])
|
||||
except (ValueError, IndexError):
|
||||
await callback.answer("❌ Неверный ID сообщения", show_alert=True)
|
||||
return
|
||||
|
||||
message = await get_user_message_by_id(db, message_id)
|
||||
|
||||
if not message:
|
||||
await callback.answer("❌ Сообщение не найдено", show_alert=True)
|
||||
return
|
||||
|
||||
status_text = "🟢 Активно" if message.is_active else "🔴 Неактивно"
|
||||
|
||||
text = (
|
||||
f"📋 <b>Сообщение ID {message.id}</b>\n\n"
|
||||
f"<b>Статус:</b> {status_text}\n"
|
||||
f"<b>Создано:</b> {message.created_at.strftime('%d.%m.%Y %H:%M')}\n"
|
||||
f"<b>Обновлено:</b> {message.updated_at.strftime('%d.%m.%Y %H:%M')}\n\n"
|
||||
f"<b>Содержимое:</b>\n"
|
||||
f"<blockquote>{message.message_text}</blockquote>"
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=get_message_actions_keyboard(
|
||||
message_id, message.is_active, db_user.language
|
||||
),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def toggle_message_status(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
try:
|
||||
message_id = int(callback.data.split(":")[1])
|
||||
except (ValueError, IndexError):
|
||||
await callback.answer("❌ Неверный ID сообщения", show_alert=True)
|
||||
return
|
||||
|
||||
message = await toggle_user_message_status(db, message_id)
|
||||
|
||||
if not message:
|
||||
await callback.answer("❌ Сообщение не найдено", show_alert=True)
|
||||
return
|
||||
|
||||
status_text = "активировано" if message.is_active else "деактивировано"
|
||||
await callback.answer(f"✅ Сообщение {status_text}")
|
||||
|
||||
await view_user_message(callback, db_user, db)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def delete_message_confirm(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
"""Подтвердить удаление сообщения"""
|
||||
try:
|
||||
message_id = int(callback.data.split(":")[1])
|
||||
except (ValueError, IndexError):
|
||||
await callback.answer("❌ Неверный ID сообщения", show_alert=True)
|
||||
return
|
||||
|
||||
success = await delete_user_message(db, message_id)
|
||||
|
||||
if success:
|
||||
await callback.answer("✅ Сообщение удалено")
|
||||
await list_user_messages(
|
||||
types.CallbackQuery(
|
||||
id=callback.id,
|
||||
from_user=callback.from_user,
|
||||
chat_instance=callback.chat_instance,
|
||||
data="list_user_messages:0",
|
||||
message=callback.message
|
||||
),
|
||||
db_user,
|
||||
db
|
||||
)
|
||||
else:
|
||||
await callback.answer("❌ Ошибка удаления сообщения", show_alert=True)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_messages_stats(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
stats = await get_user_messages_stats(db)
|
||||
|
||||
text = (
|
||||
"📊 <b>Статистика сообщений</b>\n\n"
|
||||
f"📝 Всего сообщений: <b>{stats['total_messages']}</b>\n"
|
||||
f"🟢 Активных: <b>{stats['active_messages']}</b>\n"
|
||||
f"🔴 Неактивных: <b>{stats['inactive_messages']}</b>\n\n"
|
||||
"Активные сообщения показываются пользователям случайным образом "
|
||||
"в главном меню между информацией о подписке и кнопками действий."
|
||||
)
|
||||
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="🔙 Назад",
|
||||
callback_data="user_messages_panel"
|
||||
)
|
||||
]
|
||||
])
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def edit_user_message_start(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
try:
|
||||
message_id = int(callback.data.split(":")[1])
|
||||
except (ValueError, IndexError):
|
||||
await callback.answer("❌ Неверный ID сообщения", show_alert=True)
|
||||
return
|
||||
|
||||
message = await get_user_message_by_id(db, message_id)
|
||||
|
||||
if not message:
|
||||
await callback.answer("❌ Сообщение не найдено", show_alert=True)
|
||||
return
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"✏️ <b>Редактирование сообщения ID {message.id}</b>\n\n"
|
||||
f"<b>Текущий текст:</b>\n"
|
||||
f"<blockquote>{message.message_text}</blockquote>\n\n"
|
||||
f"Введите новый текст сообщения или отправьте /cancel для отмены:",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
await state.set_data({"editing_message_id": message_id})
|
||||
await state.set_state(UserMessageStates.waiting_for_edit_text)
|
||||
await callback.answer()
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_edit_message_text(
|
||||
message: types.Message,
|
||||
state: FSMContext,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
if message.text == "/cancel":
|
||||
await state.clear()
|
||||
await message.answer(
|
||||
"❌ Редактирование отменено.",
|
||||
reply_markup=get_user_messages_keyboard(db_user.language)
|
||||
)
|
||||
return
|
||||
|
||||
data = await state.get_data()
|
||||
message_id = data.get("editing_message_id")
|
||||
|
||||
if not message_id:
|
||||
await state.clear()
|
||||
await message.answer("❌ Ошибка: ID сообщения не найден")
|
||||
return
|
||||
|
||||
new_text = message.text.strip()
|
||||
|
||||
if len(new_text) > 4000:
|
||||
await message.answer(
|
||||
"❌ Сообщение слишком длинное. Максимум 4000 символов.\n"
|
||||
"Попробуйте еще раз или отправьте /cancel для отмены."
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
updated_message = await update_user_message(
|
||||
db=db,
|
||||
message_id=message_id,
|
||||
message_text=new_text
|
||||
)
|
||||
|
||||
if updated_message:
|
||||
await state.clear()
|
||||
await message.answer(
|
||||
f"✅ <b>Сообщение обновлено!</b>\n\n"
|
||||
f"<b>ID:</b> {updated_message.id}\n"
|
||||
f"<b>Обновлено:</b> {updated_message.updated_at.strftime('%d.%m.%Y %H:%M')}\n\n"
|
||||
f"<b>Новый текст:</b>\n"
|
||||
f"<blockquote>{new_text}</blockquote>",
|
||||
reply_markup=get_user_messages_keyboard(db_user.language),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
else:
|
||||
await state.clear()
|
||||
await message.answer(
|
||||
"❌ Сообщение не найдено или ошибка обновления.",
|
||||
reply_markup=get_user_messages_keyboard(db_user.language)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обновления сообщения: {e}")
|
||||
await state.clear()
|
||||
await message.answer(
|
||||
"❌ Произошла ошибка при обновлении сообщения.",
|
||||
reply_markup=get_user_messages_keyboard(db_user.language)
|
||||
)
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
|
||||
dp.callback_query.register(
|
||||
show_user_messages_panel,
|
||||
F.data == "user_messages_panel"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
add_user_message_start,
|
||||
F.data == "add_user_message"
|
||||
)
|
||||
|
||||
dp.message.register(
|
||||
process_new_message_text,
|
||||
StateFilter(UserMessageStates.waiting_for_message_text)
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
edit_user_message_start,
|
||||
F.data.startswith("edit_user_message:")
|
||||
)
|
||||
|
||||
dp.message.register(
|
||||
process_edit_message_text,
|
||||
StateFilter(UserMessageStates.waiting_for_edit_text)
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
list_user_messages,
|
||||
F.data.startswith("list_user_messages")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
view_user_message,
|
||||
F.data.startswith("view_user_message:")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
toggle_message_status,
|
||||
F.data.startswith("toggle_user_message:")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
delete_message_confirm,
|
||||
F.data.startswith("delete_user_message:")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_messages_stats,
|
||||
F.data == "user_messages_stats"
|
||||
)
|
||||
+2701
-295
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,441 @@
|
||||
import logging
|
||||
import re
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import User
|
||||
from app.states import AdminStates
|
||||
from app.keyboards.admin import get_welcome_text_keyboard, get_admin_main_keyboard
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
from app.database.crud.welcome_text import (
|
||||
get_active_welcome_text,
|
||||
set_welcome_text,
|
||||
get_current_welcome_text_or_default,
|
||||
get_available_placeholders,
|
||||
get_current_welcome_text_settings,
|
||||
toggle_welcome_text_status
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def validate_html_tags(text: str) -> tuple[bool, str]:
|
||||
"""
|
||||
Проверяет HTML-теги в тексте на соответствие требованиям Telegram API.
|
||||
|
||||
Args:
|
||||
text: Текст для проверки
|
||||
|
||||
Returns:
|
||||
Кортеж из (валидно ли, сообщение об ошибке или None)
|
||||
"""
|
||||
# Поддерживаемые теги в parse_mode="HTML" для Telegram API
|
||||
allowed_tags = {
|
||||
'b', 'strong', # жирный
|
||||
'i', 'em', # курсив
|
||||
'u', 'ins', # подчеркнуто
|
||||
's', 'strike', 'del', # зачеркнуто
|
||||
'code', # моноширинный для коротких фрагментов
|
||||
'pre', # моноширинный блок кода
|
||||
'a' # ссылки
|
||||
}
|
||||
|
||||
# Убираем плейсхолдеры из строки перед проверкой тегов
|
||||
# Плейсхолдеры имеют формат {ключ}, и не являются тегами
|
||||
placeholder_pattern = r'\{[^{}]+\}'
|
||||
clean_text = re.sub(placeholder_pattern, '', text)
|
||||
|
||||
# Находим все открывающие и закрывающие теги
|
||||
tag_pattern = r'<(/?)([a-zA-Z]+)(\s[^>]*)?>'
|
||||
tags_with_pos = [(m.group(1), m.group(2), m.group(3), m.start(), m.end()) for m in re.finditer(tag_pattern, clean_text)]
|
||||
|
||||
for closing, tag, attrs, start_pos, end_pos in tags_with_pos:
|
||||
tag_lower = tag.lower()
|
||||
|
||||
# Проверяем, является ли тег поддерживаемым
|
||||
if tag_lower not in allowed_tags:
|
||||
return False, f"Неподдерживаемый HTML-тег: <{tag}>. Используйте только теги: {', '.join(sorted(allowed_tags))}"
|
||||
|
||||
# Проверяем атрибуты для тега <a>
|
||||
if tag_lower == 'a':
|
||||
if closing:
|
||||
continue # Для закрывающего тега не нужно проверять атрибуты
|
||||
if not attrs:
|
||||
return False, "Тег <a> должен содержать атрибут href, например: <a href='URL'>ссылка</a>"
|
||||
|
||||
# Проверяем, что есть атрибут href
|
||||
if 'href=' not in attrs.lower():
|
||||
return False, "Тег <a> должен содержать атрибут href, например: <a href='URL'>ссылка</a>"
|
||||
|
||||
# Проверяем формат URL
|
||||
href_match = re.search(r'href\s*=\s*[\'"]([^\'"]+)[\'"]', attrs, re.IGNORECASE)
|
||||
if href_match:
|
||||
url = href_match.group(1)
|
||||
# Проверяем, что URL начинается с поддерживаемой схемы
|
||||
if not re.match(r'^https?://|^tg://', url, re.IGNORECASE):
|
||||
return False, f"URL в теге <a> должен начинаться с http://, https:// или tg://. Найдено: {url}"
|
||||
else:
|
||||
return False, "Не удалось извлечь URL из атрибута href тега <a>"
|
||||
|
||||
# Проверяем парность тегов с использованием стека
|
||||
stack = []
|
||||
for closing, tag, attrs, start_pos, end_pos in tags_with_pos:
|
||||
tag_lower = tag.lower()
|
||||
|
||||
if tag_lower not in allowed_tags:
|
||||
continue
|
||||
|
||||
if closing:
|
||||
# Это закрывающий тег
|
||||
if not stack:
|
||||
return False, f"Лишний закрывающий тег: </{tag}>"
|
||||
|
||||
last_opening_tag = stack.pop()
|
||||
if last_opening_tag.lower() != tag_lower:
|
||||
return False, f"Тег </{tag}> не соответствует открывающему тегу <{last_opening_tag}>"
|
||||
else:
|
||||
# Это открывающий тег
|
||||
stack.append(tag)
|
||||
|
||||
# Если остались незакрытые теги
|
||||
if stack:
|
||||
unclosed_tags = ", ".join([f"<{tag}>" for tag in stack])
|
||||
return False, f"Незакрытые теги: {unclosed_tags}"
|
||||
|
||||
return True, None
|
||||
|
||||
def get_telegram_formatting_info() -> str:
|
||||
return """
|
||||
📝 <b>Поддерживаемые теги форматирования:</b>
|
||||
|
||||
• <code><b>жирный текст</b></code> → <b>жирный текст</b>
|
||||
• <code><i>курсив</i></code> → <i>курсив</i>
|
||||
• <code><u>подчеркнутый</u></code> → <u>подчеркнутый</u>
|
||||
• <code><s>зачеркнутый</s></code> → <s>зачеркнутый</s>
|
||||
• <code><code>моноширинный</code></code> → <code>моноширинный</code>
|
||||
• <code><pre>блок кода</pre></code> → многострочный код
|
||||
• <code><a href="URL">ссылка</a></code> → ссылка
|
||||
|
||||
⚠️ <b>ВНИМАНИЕ:</b> Используйте ТОЛЬКО указанные выше теги!
|
||||
Любые другие HTML-теги не поддерживаются и будут отображаться как обычный текст.
|
||||
|
||||
❌ <b>НЕ используйте:</b> <div>, <span>, <p>, <br>, <h1>-<h6>, <img> и другие HTML-теги.
|
||||
"""
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_welcome_text_panel(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
welcome_settings = await get_current_welcome_text_settings(db)
|
||||
status_emoji = "🟢" if welcome_settings['is_enabled'] else "🔴"
|
||||
status_text = "включено" if welcome_settings['is_enabled'] else "отключено"
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"👋 Управление приветственным текстом\n\n"
|
||||
f"{status_emoji} <b>Статус:</b> {status_text}\n\n"
|
||||
f"Здесь вы можете управлять текстом, который показывается новым пользователям после регистрации.\n\n"
|
||||
f"💡 Доступные плейсхолдеры для автозамены:",
|
||||
reply_markup=get_welcome_text_keyboard(db_user.language, welcome_settings['is_enabled']),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def toggle_welcome_text(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
new_status = await toggle_welcome_text_status(db, db_user.id)
|
||||
|
||||
status_emoji = "🟢" if new_status else "🔴"
|
||||
status_text = "включено" if new_status else "отключено"
|
||||
action_text = "включены" if new_status else "отключены"
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"👋 Управление приветственным текстом\n\n"
|
||||
f"{status_emoji} <b>Статус:</b> {status_text}\n\n"
|
||||
f"✅ Приветственные сообщения {action_text}!\n\n"
|
||||
f"Здесь вы можете управлять текстом, который показывается новым пользователям после регистрации.\n\n"
|
||||
f"💡 Доступные плейсхолдеры для автозамены:",
|
||||
reply_markup=get_welcome_text_keyboard(db_user.language, new_status),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_current_welcome_text(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
welcome_settings = await get_current_welcome_text_settings(db)
|
||||
current_text = welcome_settings['text']
|
||||
is_enabled = welcome_settings['is_enabled']
|
||||
|
||||
if not welcome_settings['id']:
|
||||
status = "📝 Используется стандартный текст:"
|
||||
else:
|
||||
status = "📝 Текущий приветственный текст:"
|
||||
|
||||
status_emoji = "🟢" if is_enabled else "🔴"
|
||||
status_text = "включено" if is_enabled else "отключено"
|
||||
|
||||
placeholders = get_available_placeholders()
|
||||
placeholders_text = "\n".join([f"• <code>{key}</code> - {desc}" for key, desc in placeholders.items()])
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"{status_emoji} <b>Статус:</b> {status_text}\n\n"
|
||||
f"{status}\n\n"
|
||||
f"<code>{current_text}</code>\n\n"
|
||||
f"💡 Доступные плейсхолдеры:\n{placeholders_text}",
|
||||
reply_markup=get_welcome_text_keyboard(db_user.language, is_enabled),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_placeholders_help(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
welcome_settings = await get_current_welcome_text_settings(db)
|
||||
placeholders = get_available_placeholders()
|
||||
placeholders_text = "\n".join([f"• <code>{key}</code>\n {desc}" for key, desc in placeholders.items()])
|
||||
|
||||
help_text = (
|
||||
"💡 Доступные плейсхолдеры для автозамены:\n\n"
|
||||
f"{placeholders_text}\n\n"
|
||||
"📌 Примеры использования:\n"
|
||||
"• <code>Привет, {user_name}! Добро пожаловать!</code>\n"
|
||||
"• <code>Здравствуйте, {first_name}! Рады видеть вас!</code>\n"
|
||||
"• <code>Привет, {username}! Спасибо за регистрацию!</code>\n\n"
|
||||
"При отсутствии данных пользователя используется слово 'друг'."
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
help_text,
|
||||
reply_markup=get_welcome_text_keyboard(db_user.language, welcome_settings['is_enabled']),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_formatting_help(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
welcome_settings = await get_current_welcome_text_settings(db)
|
||||
formatting_info = get_telegram_formatting_info()
|
||||
|
||||
await callback.message.edit_text(
|
||||
formatting_info,
|
||||
reply_markup=get_welcome_text_keyboard(db_user.language, welcome_settings['is_enabled']),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_edit_welcome_text(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
welcome_settings = await get_current_welcome_text_settings(db)
|
||||
current_text = welcome_settings['text']
|
||||
|
||||
placeholders = get_available_placeholders()
|
||||
placeholders_text = "\n".join([f"• <code>{key}</code> - {desc}" for key, desc in placeholders.items()])
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"📝 Редактирование приветственного текста\n\n"
|
||||
f"Текущий текст:\n"
|
||||
f"<code>{current_text}</code>\n\n"
|
||||
f"💡 Доступные плейсхолдеры:\n{placeholders_text}\n\n"
|
||||
f"Отправьте новый текст:",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
await state.set_state(AdminStates.editing_welcome_text)
|
||||
await callback.answer()
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_welcome_text_edit(
|
||||
message: types.Message,
|
||||
state: FSMContext,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
new_text = message.text.strip()
|
||||
|
||||
if len(new_text) < 10:
|
||||
await message.answer("❌ Текст слишком короткий! Минимум 10 символов.")
|
||||
return
|
||||
|
||||
if len(new_text) > 4000:
|
||||
await message.answer("❌ Текст слишком длинный! Максимум 4000 символов.")
|
||||
return
|
||||
|
||||
# Проверяем HTML-теги на валидность
|
||||
is_valid, error_msg = validate_html_tags(new_text)
|
||||
if not is_valid:
|
||||
await message.answer(f"❌ Ошибка в HTML-разметке:\n\n{error_msg}")
|
||||
return
|
||||
|
||||
success = await set_welcome_text(db, new_text, db_user.id)
|
||||
|
||||
if success:
|
||||
welcome_settings = await get_current_welcome_text_settings(db)
|
||||
status_emoji = "🟢" if welcome_settings['is_enabled'] else "🔴"
|
||||
status_text = "включено" if welcome_settings['is_enabled'] else "отключено"
|
||||
|
||||
placeholders = get_available_placeholders()
|
||||
placeholders_text = "\n".join([f"• <code>{key}</code>" for key in placeholders.keys()])
|
||||
|
||||
await message.answer(
|
||||
f"✅ Приветственный текст успешно обновлен!\n\n"
|
||||
f"{status_emoji} <b>Статус:</b> {status_text}\n\n"
|
||||
f"Новый текст:\n"
|
||||
f"<code>{new_text}</code>\n\n"
|
||||
f"💡 Будут заменяться плейсхолдеры: {placeholders_text}",
|
||||
reply_markup=get_welcome_text_keyboard(db_user.language, welcome_settings['is_enabled']),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
else:
|
||||
welcome_settings = await get_current_welcome_text_settings(db)
|
||||
await message.answer(
|
||||
"❌ Ошибка при сохранении текста. Попробуйте еще раз.",
|
||||
reply_markup=get_welcome_text_keyboard(db_user.language, welcome_settings['is_enabled'])
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def reset_welcome_text(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
default_text = await get_current_welcome_text_or_default()
|
||||
success = await set_welcome_text(db, default_text, db_user.id)
|
||||
|
||||
if success:
|
||||
welcome_settings = await get_current_welcome_text_settings(db)
|
||||
status_emoji = "🟢" if welcome_settings['is_enabled'] else "🔴"
|
||||
status_text = "включено" if welcome_settings['is_enabled'] else "отключено"
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"✅ Приветственный текст сброшен на стандартный!\n\n"
|
||||
f"{status_emoji} <b>Статус:</b> {status_text}\n\n"
|
||||
f"Стандартный текст:\n"
|
||||
f"<code>{default_text}</code>\n\n"
|
||||
f"💡 Плейсхолдер <code>{{user_name}}</code> будет заменяться на имя пользователя",
|
||||
reply_markup=get_welcome_text_keyboard(db_user.language, welcome_settings['is_enabled']),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
else:
|
||||
welcome_settings = await get_current_welcome_text_settings(db)
|
||||
await callback.message.edit_text(
|
||||
"❌ Ошибка при сбросе текста. Попробуйте еще раз.",
|
||||
reply_markup=get_welcome_text_keyboard(db_user.language, welcome_settings['is_enabled'])
|
||||
)
|
||||
|
||||
await callback.answer()
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_preview_welcome_text(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
from app.database.crud.welcome_text import get_welcome_text_for_user
|
||||
|
||||
class TestUser:
|
||||
def __init__(self):
|
||||
self.first_name = "Иван"
|
||||
self.username = "test_user"
|
||||
|
||||
test_user = TestUser()
|
||||
preview_text = await get_welcome_text_for_user(db, test_user)
|
||||
|
||||
welcome_settings = await get_current_welcome_text_settings(db)
|
||||
|
||||
if preview_text:
|
||||
await callback.message.edit_text(
|
||||
f"👁️ Предварительный просмотр\n\n"
|
||||
f"Как будет выглядеть текст для пользователя 'Иван' (@test_user):\n\n"
|
||||
f"<code>{preview_text}</code>",
|
||||
reply_markup=get_welcome_text_keyboard(db_user.language, welcome_settings['is_enabled']),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
else:
|
||||
await callback.message.edit_text(
|
||||
f"👁️ Предварительный просмотр\n\n"
|
||||
f"🔴 Приветственные сообщения отключены.\n"
|
||||
f"Новые пользователи не будут получать приветственный текст после регистрации.",
|
||||
reply_markup=get_welcome_text_keyboard(db_user.language, welcome_settings['is_enabled']),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
await callback.answer()
|
||||
|
||||
def register_welcome_text_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(
|
||||
show_welcome_text_panel,
|
||||
F.data == "welcome_text_panel"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
toggle_welcome_text,
|
||||
F.data == "toggle_welcome_text"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_current_welcome_text,
|
||||
F.data == "show_welcome_text"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_placeholders_help,
|
||||
F.data == "show_placeholders_help"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_formatting_help,
|
||||
F.data == "show_formatting_help"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_preview_welcome_text,
|
||||
F.data == "preview_welcome_text"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
start_edit_welcome_text,
|
||||
F.data == "edit_welcome_text"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
reset_welcome_text,
|
||||
F.data == "reset_welcome_text"
|
||||
)
|
||||
|
||||
dp.message.register(
|
||||
process_welcome_text_edit,
|
||||
AdminStates.editing_welcome_text
|
||||
)
|
||||
@@ -1,581 +0,0 @@
|
||||
import logging
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.states import BalanceStates
|
||||
from app.database.crud.user import add_user_balance
|
||||
from app.database.crud.transaction import (
|
||||
get_user_transactions, get_user_transactions_count,
|
||||
create_transaction
|
||||
)
|
||||
from app.database.models import User, TransactionType, PaymentMethod
|
||||
from app.keyboards.inline import (
|
||||
get_balance_keyboard, get_payment_methods_keyboard,
|
||||
get_back_keyboard, get_pagination_keyboard
|
||||
)
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.utils.pagination import paginate_list
|
||||
from app.utils.decorators import error_handler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TRANSACTIONS_PER_PAGE = 10
|
||||
|
||||
|
||||
@error_handler
|
||||
async def show_balance_menu(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
balance_text = texts.BALANCE_INFO.format(
|
||||
balance=texts.format_price(db_user.balance_kopeks)
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
balance_text,
|
||||
reply_markup=get_balance_keyboard(db_user.language)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def show_balance_history(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
page: int = 1
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
offset = (page - 1) * TRANSACTIONS_PER_PAGE
|
||||
|
||||
raw_transactions = await get_user_transactions(
|
||||
db, db_user.id,
|
||||
limit=TRANSACTIONS_PER_PAGE * 3,
|
||||
offset=offset
|
||||
)
|
||||
|
||||
seen_transactions = set()
|
||||
unique_transactions = []
|
||||
|
||||
for transaction in raw_transactions:
|
||||
rounded_time = transaction.created_at.replace(second=0, microsecond=0)
|
||||
transaction_key = (
|
||||
transaction.amount_kopeks,
|
||||
transaction.description,
|
||||
rounded_time
|
||||
)
|
||||
|
||||
if transaction_key not in seen_transactions:
|
||||
seen_transactions.add(transaction_key)
|
||||
unique_transactions.append(transaction)
|
||||
|
||||
if len(unique_transactions) >= TRANSACTIONS_PER_PAGE:
|
||||
break
|
||||
|
||||
all_transactions = await get_user_transactions(db, db_user.id, limit=1000)
|
||||
seen_all = set()
|
||||
total_unique = 0
|
||||
|
||||
for transaction in all_transactions:
|
||||
rounded_time = transaction.created_at.replace(second=0, microsecond=0)
|
||||
transaction_key = (
|
||||
transaction.amount_kopeks,
|
||||
transaction.description,
|
||||
rounded_time
|
||||
)
|
||||
if transaction_key not in seen_all:
|
||||
seen_all.add(transaction_key)
|
||||
total_unique += 1
|
||||
|
||||
if not unique_transactions:
|
||||
await callback.message.edit_text(
|
||||
"📊 История операций пуста",
|
||||
reply_markup=get_back_keyboard(db_user.language)
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
text = "📊 <b>История операций</b>\n\n"
|
||||
|
||||
for transaction in unique_transactions:
|
||||
emoji = "💰" if transaction.type == TransactionType.DEPOSIT.value else "💸"
|
||||
amount_text = f"+{texts.format_price(transaction.amount_kopeks)}" if transaction.type == TransactionType.DEPOSIT.value else f"-{texts.format_price(transaction.amount_kopeks)}"
|
||||
|
||||
text += f"{emoji} {amount_text}\n"
|
||||
text += f"📝 {transaction.description}\n"
|
||||
text += f"📅 {transaction.created_at.strftime('%d.%m.%Y %H:%M')}\n\n"
|
||||
|
||||
keyboard = []
|
||||
total_pages = (total_unique + TRANSACTIONS_PER_PAGE - 1) // TRANSACTIONS_PER_PAGE
|
||||
|
||||
if total_pages > 1:
|
||||
pagination_row = get_pagination_keyboard(
|
||||
page, total_pages, "balance_history", db_user.language
|
||||
)
|
||||
keyboard.extend(pagination_row)
|
||||
|
||||
keyboard.append([
|
||||
types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")
|
||||
])
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def handle_balance_history_pagination(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
page = int(callback.data.split('_')[-1])
|
||||
await show_balance_history(callback, db_user, db, page)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def show_payment_methods(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
payment_text = """
|
||||
💳 <b>Способы пополнения баланса</b>
|
||||
|
||||
Выберите удобный для вас способ оплаты:
|
||||
|
||||
⭐ <b>Telegram Stars</b> - быстро и удобно
|
||||
💳 <b>Банковская карта</b> - через YooKassa/Tribute
|
||||
🛠️ <b>Через поддержку</b> - другие способы
|
||||
|
||||
Выберите способ пополнения:
|
||||
"""
|
||||
|
||||
await callback.message.edit_text(
|
||||
payment_text,
|
||||
reply_markup=get_payment_methods_keyboard(0, db_user.language),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_stars_payment(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.TELEGRAM_STARS_ENABLED:
|
||||
await callback.answer("❌ Пополнение через Stars временно недоступно", show_alert=True)
|
||||
return
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.TOP_UP_AMOUNT,
|
||||
reply_markup=get_back_keyboard(db_user.language)
|
||||
)
|
||||
|
||||
await state.set_state(BalanceStates.waiting_for_amount)
|
||||
await state.update_data(payment_method="stars")
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_yookassa_payment(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_yookassa_enabled():
|
||||
await callback.answer("❌ Оплата картой через YooKassa временно недоступна", show_alert=True)
|
||||
return
|
||||
|
||||
await callback.message.edit_text(
|
||||
"💳 <b>Оплата банковской картой</b>\n\n"
|
||||
"Введите сумму для пополнения от 100 до 50,000 рублей:",
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
await state.set_state(BalanceStates.waiting_for_amount)
|
||||
await state.update_data(payment_method="yookassa")
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_tribute_payment(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.TRIBUTE_ENABLED:
|
||||
await callback.answer("❌ Оплата картой временно недоступна", show_alert=True)
|
||||
return
|
||||
|
||||
try:
|
||||
from app.services.tribute_service import TributeService
|
||||
|
||||
tribute_service = TributeService(callback.bot)
|
||||
payment_url = await tribute_service.create_payment_link(
|
||||
user_id=db_user.telegram_id,
|
||||
amount_kopeks=0,
|
||||
description="Пополнение баланса VPN"
|
||||
)
|
||||
|
||||
if not payment_url:
|
||||
await callback.answer("❌ Ошибка создания платежа", show_alert=True)
|
||||
return
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="💳 Перейти к оплате", url=payment_url)],
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]
|
||||
])
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"💳 <b>Пополнение банковской картой</b>\n\n"
|
||||
f"• Введите любую сумму от 100₽\n"
|
||||
f"• Безопасная оплата через Tribute\n"
|
||||
f"• Мгновенное зачисление на баланс\n"
|
||||
f"• Принимаем карты Visa, MasterCard, МИР\n\n"
|
||||
f"• 🚨 НЕ ОТПРАВЛЯТЬ ПЛАТЕЖ АНОНИМНО!\n\n"
|
||||
f"Нажмите кнопку для перехода к оплате:",
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания Tribute платежа: {e}")
|
||||
await callback.answer("❌ Ошибка создания платежа", show_alert=True)
|
||||
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def request_support_topup(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
support_text = f"""
|
||||
🛠️ <b>Пополнение через поддержку</b>
|
||||
|
||||
Для пополнения баланса обратитесь в техподдержку:
|
||||
{settings.SUPPORT_USERNAME}
|
||||
|
||||
Укажите:
|
||||
• ID: {db_user.telegram_id}
|
||||
• Сумму пополнения
|
||||
• Способ оплаты
|
||||
|
||||
⏰ Время обработки: 1-24 часа
|
||||
|
||||
<b>Доступные способы:</b>
|
||||
• Криптовалюта
|
||||
• Переводы между банками
|
||||
• Другие платежные системы
|
||||
"""
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(
|
||||
text="💬 Написать в поддержку",
|
||||
url=f"https://t.me/{settings.SUPPORT_USERNAME.lstrip('@')}"
|
||||
)],
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]
|
||||
])
|
||||
|
||||
await callback.message.edit_text(
|
||||
support_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_topup_amount(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
try:
|
||||
amount_rubles = float(message.text.replace(',', '.'))
|
||||
|
||||
if amount_rubles < 1:
|
||||
await message.answer("Минимальная сумма пополнения: 1 ₽")
|
||||
return
|
||||
|
||||
if amount_rubles > 50000:
|
||||
await message.answer("Максимальная сумма пополнения: 50,000 ₽")
|
||||
return
|
||||
|
||||
amount_kopeks = int(amount_rubles * 100)
|
||||
data = await state.get_data()
|
||||
payment_method = data.get("payment_method", "stars")
|
||||
|
||||
if payment_method == "stars":
|
||||
await process_stars_payment_amount(message, db_user, amount_kopeks, state)
|
||||
elif payment_method == "yookassa":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_yookassa_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||
else:
|
||||
await message.answer("Неизвестный способ оплаты")
|
||||
|
||||
except ValueError:
|
||||
await message.answer(
|
||||
texts.INVALID_AMOUNT,
|
||||
reply_markup=get_back_keyboard(db_user.language)
|
||||
)
|
||||
|
||||
@error_handler
|
||||
async def process_stars_payment_amount(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
amount_kopeks: int,
|
||||
state: FSMContext
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.TELEGRAM_STARS_ENABLED:
|
||||
await message.answer("⚠ Оплата Stars временно недоступна")
|
||||
return
|
||||
|
||||
try:
|
||||
from app.external.telegram_stars import TelegramStarsService
|
||||
|
||||
amount_rubles = amount_kopeks / 100
|
||||
stars_amount = TelegramStarsService.calculate_stars_from_rubles(amount_rubles)
|
||||
|
||||
payment_service = PaymentService(message.bot)
|
||||
invoice_link = await payment_service.create_stars_invoice(
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=f"Пополнение баланса на {texts.format_price(amount_kopeks)}",
|
||||
payload=f"balance_{db_user.id}_{amount_kopeks}"
|
||||
)
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="⭐ Оплатить", url=invoice_link)],
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]
|
||||
])
|
||||
|
||||
await message.answer(
|
||||
f"⭐ <b>Оплата через Telegram Stars</b>\n\n"
|
||||
f"💰 Сумма: {texts.format_price(amount_kopeks)}\n"
|
||||
f"⭐ К оплате: {stars_amount} звезд\n"
|
||||
f"📊 Курс: {settings.get_stars_rate():.2f}₽ за звезду\n\n"
|
||||
f"Нажмите кнопку ниже для оплаты:",
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания Stars invoice: {e}")
|
||||
await message.answer("⚠ Ошибка создания платежа")
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_yookassa_payment_amount(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
amount_kopeks: int,
|
||||
state: FSMContext
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_yookassa_enabled():
|
||||
await message.answer("❌ Оплата через YooKassa временно недоступна")
|
||||
return
|
||||
|
||||
if amount_kopeks < 10000:
|
||||
await message.answer("❌ Минимальная сумма для оплаты картой: 100 ₽")
|
||||
return
|
||||
|
||||
try:
|
||||
payment_service = PaymentService(message.bot)
|
||||
|
||||
payment_result = await payment_service.create_yookassa_payment(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=settings.get_balance_payment_description(amount_kopeks),
|
||||
receipt_email=None,
|
||||
receipt_phone=None,
|
||||
metadata={
|
||||
"user_telegram_id": str(db_user.telegram_id),
|
||||
"user_username": db_user.username or "",
|
||||
"purpose": "balance_topup"
|
||||
}
|
||||
)
|
||||
|
||||
if not payment_result:
|
||||
await message.answer("❌ Ошибка создания платежа. Попробуйте позже или обратитесь в поддержку.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
confirmation_url = payment_result.get("confirmation_url")
|
||||
if not confirmation_url:
|
||||
await message.answer("❌ Ошибка получения ссылки для оплаты. Обратитесь в поддержку.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="💳 Оплатить картой", url=confirmation_url)],
|
||||
[types.InlineKeyboardButton(text="📊 Проверить статус", callback_data=f"check_yookassa_{payment_result['local_payment_id']}")],
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]
|
||||
])
|
||||
|
||||
await message.answer(
|
||||
f"💳 <b>Оплата банковской картой</b>\n\n"
|
||||
f"💰 Сумма: {settings.format_price(amount_kopeks)}\n"
|
||||
f"🆔 ID платежа: {payment_result['yookassa_payment_id'][:8]}...\n\n"
|
||||
f"📱 <b>Инструкция:</b>\n"
|
||||
f"1. Нажмите кнопку 'Оплатить картой'\n"
|
||||
f"2. Введите данные вашей карты\n"
|
||||
f"3. Подтвердите платеж\n"
|
||||
f"4. Деньги поступят на баланс автоматически\n\n"
|
||||
f"🔒 Оплата происходит через защищенную систему YooKassa\n"
|
||||
f"✅ Принимаем карты: Visa, MasterCard, МИР\n\n"
|
||||
f"❓ Если возникнут проблемы, обратитесь в {settings.SUPPORT_USERNAME}",
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
|
||||
logger.info(f"Создан платеж YooKassa для пользователя {db_user.telegram_id}: "
|
||||
f"{amount_kopeks/100}₽, ID: {payment_result['yookassa_payment_id']}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания YooKassa платежа: {e}")
|
||||
await message.answer("❌ Ошибка создания платежа. Попробуйте позже или обратитесь в поддержку.")
|
||||
await state.clear()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def check_yookassa_payment_status(
|
||||
callback: types.CallbackQuery,
|
||||
db: AsyncSession
|
||||
):
|
||||
try:
|
||||
local_payment_id = int(callback.data.split('_')[-1])
|
||||
|
||||
from app.database.crud.yookassa import get_yookassa_payment_by_local_id
|
||||
payment = await get_yookassa_payment_by_local_id(db, local_payment_id)
|
||||
|
||||
if not payment:
|
||||
await callback.answer("❌ Платеж не найден", show_alert=True)
|
||||
return
|
||||
|
||||
status_emoji = {
|
||||
"pending": "⏳",
|
||||
"waiting_for_capture": "⌛",
|
||||
"succeeded": "✅",
|
||||
"canceled": "❌",
|
||||
"failed": "❌"
|
||||
}
|
||||
|
||||
status_text = {
|
||||
"pending": "Ожидает оплаты",
|
||||
"waiting_for_capture": "Ожидает подтверждения",
|
||||
"succeeded": "Оплачен",
|
||||
"canceled": "Отменен",
|
||||
"failed": "Ошибка"
|
||||
}
|
||||
|
||||
emoji = status_emoji.get(payment.status, "❓")
|
||||
status = status_text.get(payment.status, "Неизвестно")
|
||||
|
||||
message_text = (f"💳 Статус платежа:\n\n"
|
||||
f"🆔 ID: {payment.yookassa_payment_id[:8]}...\n"
|
||||
f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n"
|
||||
f"📊 Статус: {emoji} {status}\n"
|
||||
f"📅 Создан: {payment.created_at.strftime('%d.%m.%Y %H:%M')}\n")
|
||||
|
||||
if payment.is_succeeded:
|
||||
message_text += "\n✅ Платеж успешно завершен!\n\nСредства зачислены на баланс."
|
||||
elif payment.is_pending:
|
||||
message_text += "\n⏳ Платеж ожидает оплаты. Нажмите кнопку 'Оплатить' выше."
|
||||
elif payment.is_failed:
|
||||
message_text += f"\n❌ Платеж не прошел. Обратитесь в {settings.SUPPORT_USERNAME}"
|
||||
|
||||
await callback.answer(message_text, show_alert=True)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка проверки статуса платежа: {e}")
|
||||
await callback.answer("❌ Ошибка проверки статуса", show_alert=True)
|
||||
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
|
||||
dp.callback_query.register(
|
||||
show_balance_menu,
|
||||
F.data == "menu_balance"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_balance_history,
|
||||
F.data == "balance_history"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
handle_balance_history_pagination,
|
||||
F.data.startswith("balance_history_page_")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_payment_methods,
|
||||
F.data == "balance_topup"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
start_stars_payment,
|
||||
F.data == "topup_stars"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
start_yookassa_payment,
|
||||
F.data == "topup_yookassa"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
start_tribute_payment,
|
||||
F.data == "topup_tribute"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
request_support_topup,
|
||||
F.data == "topup_support"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
check_yookassa_payment_status,
|
||||
F.data.startswith("check_yookassa_")
|
||||
)
|
||||
|
||||
dp.message.register(
|
||||
process_topup_amount,
|
||||
BalanceStates.waiting_for_amount
|
||||
)
|
||||
@@ -0,0 +1,3 @@
|
||||
from .main import register_balance_handlers
|
||||
|
||||
__all__ = ["register_balance_handlers"]
|
||||
@@ -0,0 +1,241 @@
|
||||
import logging
|
||||
from aiogram import types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.keyboards.inline import get_back_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.utils.decorators import error_handler
|
||||
from app.states import BalanceStates
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_cryptobot_payment(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_cryptobot_enabled():
|
||||
await callback.answer("❌ Оплата криптовалютой временно недоступна", show_alert=True)
|
||||
return
|
||||
|
||||
from app.utils.currency_converter import currency_converter
|
||||
try:
|
||||
current_rate = await currency_converter.get_usd_to_rub_rate()
|
||||
rate_text = f"💱 Текущий курс: 1 USD = {current_rate:.2f} ₽"
|
||||
except Exception as e:
|
||||
logger.warning(f"Не удалось получить курс валют: {e}")
|
||||
current_rate = 95.0
|
||||
rate_text = f"💱 Курс: 1 USD ≈ {current_rate:.0f} ₽"
|
||||
|
||||
available_assets = settings.get_cryptobot_assets()
|
||||
assets_text = ", ".join(available_assets)
|
||||
|
||||
# Формируем текст сообщения в зависимости от настройки
|
||||
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
|
||||
message_text = (
|
||||
f"🪙 <b>Пополнение криптовалютой</b>\n\n"
|
||||
f"Выберите сумму пополнения или введите вручную сумму "
|
||||
f"от 100 до 100,000 ₽:\n\n"
|
||||
f"💰 Доступные активы: {assets_text}\n"
|
||||
f"⚡ Мгновенное зачисление на баланс\n"
|
||||
f"🔒 Безопасная оплата через CryptoBot\n\n"
|
||||
f"{rate_text}\n"
|
||||
f"Сумма будет автоматически конвертирована в USD для оплаты."
|
||||
)
|
||||
else:
|
||||
message_text = (
|
||||
f"🪙 <b>Пополнение криптовалютой</b>\n\n"
|
||||
f"Введите сумму для пополнения от 100 до 100,000 ₽:\n\n"
|
||||
f"💰 Доступные активы: {assets_text}\n"
|
||||
f"⚡ Мгновенное зачисление на баланс\n"
|
||||
f"🔒 Безопасная оплата через CryptoBot\n\n"
|
||||
f"{rate_text}\n"
|
||||
f"Сумма будет автоматически конвертирована в USD для оплаты."
|
||||
)
|
||||
|
||||
# Создаем клавиатуру
|
||||
keyboard = get_back_keyboard(db_user.language)
|
||||
|
||||
# Если включен быстрый выбор суммы и не отключены кнопки, добавляем кнопки
|
||||
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
|
||||
from .main import get_quick_amount_buttons
|
||||
quick_amount_buttons = get_quick_amount_buttons(db_user.language, db_user)
|
||||
if quick_amount_buttons:
|
||||
# Вставляем кнопки быстрого выбора перед кнопкой "Назад"
|
||||
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
|
||||
|
||||
await callback.message.edit_text(
|
||||
message_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
await state.set_state(BalanceStates.waiting_for_amount)
|
||||
await state.update_data(payment_method="cryptobot", current_rate=current_rate)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_cryptobot_payment_amount(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
amount_kopeks: int,
|
||||
state: FSMContext
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_cryptobot_enabled():
|
||||
await message.answer("❌ Оплата криптовалютой временно недоступна")
|
||||
return
|
||||
|
||||
amount_rubles = amount_kopeks / 100
|
||||
|
||||
if amount_rubles < 100:
|
||||
await message.answer("Минимальная сумма пополнения: 100 ₽")
|
||||
return
|
||||
|
||||
if amount_rubles > 100000:
|
||||
await message.answer("Максимальная сумма пополнения: 100,000 ₽")
|
||||
return
|
||||
|
||||
try:
|
||||
data = await state.get_data()
|
||||
current_rate = data.get('current_rate')
|
||||
|
||||
if not current_rate:
|
||||
from app.utils.currency_converter import currency_converter
|
||||
current_rate = await currency_converter.get_usd_to_rub_rate()
|
||||
|
||||
amount_usd = amount_rubles / current_rate
|
||||
|
||||
amount_usd = round(amount_usd, 2)
|
||||
|
||||
if amount_usd < 1:
|
||||
await message.answer("❌ Минимальная сумма для оплаты в USD: 1.00 USD")
|
||||
return
|
||||
|
||||
if amount_usd > 1000:
|
||||
await message.answer("❌ Максимальная сумма для оплаты в USD: 1,000 USD")
|
||||
return
|
||||
|
||||
payment_service = PaymentService(message.bot)
|
||||
|
||||
payment_result = await payment_service.create_cryptobot_payment(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
amount_usd=amount_usd,
|
||||
asset=settings.CRYPTOBOT_DEFAULT_ASSET,
|
||||
description=f"Пополнение баланса на {amount_rubles:.0f} ₽ ({amount_usd:.2f} USD)",
|
||||
payload=f"balance_{db_user.id}_{amount_kopeks}"
|
||||
)
|
||||
|
||||
if not payment_result:
|
||||
await message.answer("❌ Ошибка создания платежа. Попробуйте позже или обратитесь в поддержку.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
bot_invoice_url = payment_result.get("bot_invoice_url")
|
||||
mini_app_invoice_url = payment_result.get("mini_app_invoice_url")
|
||||
|
||||
payment_url = bot_invoice_url or mini_app_invoice_url
|
||||
|
||||
if not payment_url:
|
||||
await message.answer("❌ Ошибка получения ссылки для оплаты. Обратитесь в поддержку.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="🪙 Оплатить", url=payment_url)],
|
||||
[types.InlineKeyboardButton(text="📊 Проверить статус", callback_data=f"check_cryptobot_{payment_result['local_payment_id']}")],
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]
|
||||
])
|
||||
|
||||
await message.answer(
|
||||
f"🪙 <b>Оплата криптовалютой</b>\n\n"
|
||||
f"💰 Сумма к зачислению: {amount_rubles:.0f} ₽\n"
|
||||
f"💵 К оплате: {amount_usd:.2f} USD\n"
|
||||
f"🪙 Актив: {payment_result['asset']}\n"
|
||||
f"💱 Курс: 1 USD = {current_rate:.2f} ₽\n"
|
||||
f"🆔 ID платежа: {payment_result['invoice_id'][:8]}...\n\n"
|
||||
f"📱 <b>Инструкция:</b>\n"
|
||||
f"1. Нажмите кнопку 'Оплатить'\n"
|
||||
f"2. Выберите удобный актив\n"
|
||||
f"3. Переведите указанную сумму\n"
|
||||
f"4. Деньги поступят на баланс автоматически\n\n"
|
||||
f"🔒 Оплата проходит через защищенную систему CryptoBot\n"
|
||||
f"⚡ Поддерживаемые активы: USDT, TON, BTC, ETH\n\n"
|
||||
f"❓ Если возникнут проблемы, обратитесь в {settings.get_support_contact_display_html()}",
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
|
||||
logger.info(f"Создан CryptoBot платеж для пользователя {db_user.telegram_id}: "
|
||||
f"{amount_rubles:.0f} ₽ ({amount_usd:.2f} USD), ID: {payment_result['invoice_id']}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания CryptoBot платежа: {e}")
|
||||
await message.answer("❌ Ошибка создания платежа. Попробуйте позже или обратитесь в поддержку.")
|
||||
await state.clear()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def check_cryptobot_payment_status(
|
||||
callback: types.CallbackQuery,
|
||||
db: AsyncSession
|
||||
):
|
||||
try:
|
||||
local_payment_id = int(callback.data.split('_')[-1])
|
||||
|
||||
from app.database.crud.cryptobot import get_cryptobot_payment_by_id
|
||||
payment = await get_cryptobot_payment_by_id(db, local_payment_id)
|
||||
|
||||
if not payment:
|
||||
await callback.answer("❌ Платеж не найден", show_alert=True)
|
||||
return
|
||||
|
||||
status_emoji = {
|
||||
"active": "⏳",
|
||||
"paid": "✅",
|
||||
"expired": "❌"
|
||||
}
|
||||
|
||||
status_text = {
|
||||
"active": "Ожидает оплаты",
|
||||
"paid": "Оплачен",
|
||||
"expired": "Истек"
|
||||
}
|
||||
|
||||
emoji = status_emoji.get(payment.status, "❓")
|
||||
status = status_text.get(payment.status, "Неизвестно")
|
||||
|
||||
message_text = (f"🪙 Статус платежа:\n\n"
|
||||
f"🆔 ID: {payment.invoice_id[:8]}...\n"
|
||||
f"💰 Сумма: {payment.amount} {payment.asset}\n"
|
||||
f"📊 Статус: {emoji} {status}\n"
|
||||
f"📅 Создан: {payment.created_at.strftime('%d.%m.%Y %H:%M')}\n")
|
||||
|
||||
if payment.is_paid:
|
||||
message_text += "\n✅ Платеж успешно завершен!\n\nСредства зачислены на баланс."
|
||||
elif payment.is_pending:
|
||||
message_text += "\n⏳ Платеж ожидает оплаты. Нажмите кнопку 'Оплатить' выше."
|
||||
elif payment.is_expired:
|
||||
message_text += (
|
||||
f"\n❌ Платеж истек. Обратитесь в {settings.get_support_contact_display()}"
|
||||
)
|
||||
|
||||
await callback.answer(message_text, show_alert=True)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка проверки статуса CryptoBot платежа: {e}")
|
||||
await callback.answer("❌ Ошибка проверки статуса", show_alert=True)
|
||||
@@ -0,0 +1,258 @@
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from aiogram import types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.keyboards.inline import get_back_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.states import BalanceStates
|
||||
from app.utils.decorators import error_handler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_heleket_payment(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
) -> None:
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_heleket_enabled():
|
||||
await callback.answer("❌ Оплата через Heleket недоступна", show_alert=True)
|
||||
return
|
||||
|
||||
markup = settings.get_heleket_markup_percent()
|
||||
markup_text: Optional[str]
|
||||
if markup > 0:
|
||||
label = texts.t("PAYMENT_HELEKET_MARKUP_LABEL", "Наценка провайдера")
|
||||
markup_text = f"{label}: {markup:.0f}%"
|
||||
elif markup < 0:
|
||||
label = texts.t("PAYMENT_HELEKET_DISCOUNT_LABEL", "Скидка провайдера")
|
||||
markup_text = f"{label}: {abs(markup):.0f}%"
|
||||
else:
|
||||
markup_text = None
|
||||
|
||||
message_lines = [
|
||||
"🪙 <b>Пополнение через Heleket</b>",
|
||||
"\n",
|
||||
"Введите сумму пополнения от 100 до 100,000 ₽:",
|
||||
"",
|
||||
"⚡ Мгновенное зачисление", "🔒 Безопасная оплата",
|
||||
]
|
||||
|
||||
if markup_text:
|
||||
message_lines.extend(["", markup_text])
|
||||
|
||||
keyboard = get_back_keyboard(db_user.language)
|
||||
|
||||
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
|
||||
from .main import get_quick_amount_buttons
|
||||
|
||||
quick_buttons = get_quick_amount_buttons(db_user.language, db_user)
|
||||
if quick_buttons:
|
||||
keyboard.inline_keyboard = quick_buttons + keyboard.inline_keyboard
|
||||
|
||||
await callback.message.edit_text(
|
||||
"\n".join(filter(None, message_lines)),
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
await state.set_state(BalanceStates.waiting_for_amount)
|
||||
await state.update_data(payment_method="heleket")
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_heleket_payment_amount(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
amount_kopeks: int,
|
||||
state: FSMContext,
|
||||
) -> None:
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_heleket_enabled():
|
||||
await message.answer("❌ Оплата через Heleket недоступна")
|
||||
return
|
||||
|
||||
amount_rubles = amount_kopeks / 100
|
||||
|
||||
if amount_rubles < 100:
|
||||
await message.answer("Минимальная сумма пополнения: 100 ₽")
|
||||
return
|
||||
|
||||
if amount_rubles > 100000:
|
||||
await message.answer("Максимальная сумма пополнения: 100,000 ₽")
|
||||
return
|
||||
|
||||
payment_service = PaymentService(message.bot)
|
||||
|
||||
result = await payment_service.create_heleket_payment(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=f"Пополнение баланса на {amount_rubles:.0f} ₽",
|
||||
language=db_user.language,
|
||||
)
|
||||
|
||||
if not result:
|
||||
await message.answer(
|
||||
"❌ Не удалось создать счёт в Heleket. Попробуйте позже или обратитесь в поддержку."
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
payment_url = result.get("payment_url")
|
||||
if not payment_url:
|
||||
await message.answer("❌ Не удалось получить ссылку для оплаты Heleket")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
payer_amount = result.get("payer_amount")
|
||||
payer_currency = result.get("payer_currency")
|
||||
exchange_rate = result.get("exchange_rate")
|
||||
discount_percent = result.get("discount_percent")
|
||||
|
||||
details = [
|
||||
"🪙 <b>Оплата через Heleket</b>",
|
||||
"",
|
||||
f"💰 Сумма к зачислению: {amount_rubles:.0f} ₽",
|
||||
]
|
||||
|
||||
if payer_amount and payer_currency:
|
||||
details.append(f"🪙 К оплате: {payer_amount} {payer_currency}")
|
||||
|
||||
markup_percent: Optional[float] = None
|
||||
if discount_percent is not None:
|
||||
try:
|
||||
discount_int = int(discount_percent)
|
||||
markup_percent = -discount_int
|
||||
except (TypeError, ValueError):
|
||||
markup_percent = None
|
||||
|
||||
if markup_percent:
|
||||
label_markup = texts.t("PAYMENT_HELEKET_MARKUP_LABEL", "Наценка провайдера")
|
||||
label_discount = texts.t("PAYMENT_HELEKET_DISCOUNT_LABEL", "Скидка провайдера")
|
||||
absolute = abs(markup_percent)
|
||||
if markup_percent > 0:
|
||||
details.append(f"📈 {label_markup}: +{absolute}%")
|
||||
else:
|
||||
details.append(f"📉 {label_discount}: {absolute}%")
|
||||
|
||||
if payer_amount and payer_currency:
|
||||
try:
|
||||
payer_amount_float = float(payer_amount)
|
||||
if payer_amount_float > 0:
|
||||
rub_per_currency = amount_rubles / payer_amount_float
|
||||
details.append(
|
||||
f"💱 Курс: 1 {payer_currency} ≈ {rub_per_currency:.2f} ₽"
|
||||
)
|
||||
except (TypeError, ValueError, ZeroDivisionError):
|
||||
pass
|
||||
|
||||
details.extend(
|
||||
[
|
||||
"",
|
||||
"📱 Инструкция:",
|
||||
"1. Нажмите кнопку 'Оплатить'",
|
||||
"2. Перейдите на страницу Heleket",
|
||||
"3. Оплатите указанную сумму",
|
||||
"4. Баланс пополнится автоматически",
|
||||
]
|
||||
)
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text=texts.t("PAY_WITH_COINS_BUTTON", "🪙 Оплатить"), url=payment_url)],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"),
|
||||
callback_data=f"check_heleket_{result['local_payment_id']}"
|
||||
)
|
||||
],
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")],
|
||||
])
|
||||
|
||||
await message.answer("\n".join(details), parse_mode="HTML", reply_markup=keyboard)
|
||||
await state.clear()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def check_heleket_payment_status(
|
||||
callback: types.CallbackQuery,
|
||||
db: AsyncSession,
|
||||
) -> None:
|
||||
try:
|
||||
local_payment_id = int(callback.data.split("_")[-1])
|
||||
except (ValueError, IndexError):
|
||||
await callback.answer("Некорректный идентификатор платежа", show_alert=True)
|
||||
return
|
||||
|
||||
from app.database.crud.heleket import get_heleket_payment_by_id
|
||||
|
||||
payment = await get_heleket_payment_by_id(db, local_payment_id)
|
||||
if not payment:
|
||||
await callback.answer("Платёж не найден", show_alert=True)
|
||||
return
|
||||
|
||||
language = getattr(payment.user, "language", None) or settings.DEFAULT_LANGUAGE
|
||||
texts = get_texts(language)
|
||||
|
||||
if payment.is_paid:
|
||||
message = texts.t("HELEKET_PAYMENT_ALREADY_PAID", "✅ Платёж уже зачислен")
|
||||
await callback.answer(message, show_alert=True)
|
||||
return
|
||||
|
||||
payment_service = PaymentService(callback.bot)
|
||||
updated_payment = await payment_service.sync_heleket_payment_status(
|
||||
db,
|
||||
local_payment_id=local_payment_id,
|
||||
)
|
||||
|
||||
if updated_payment:
|
||||
payment = updated_payment
|
||||
|
||||
if payment.is_paid:
|
||||
message = texts.t("HELEKET_PAYMENT_SUCCESS", "✅ Платёж зачислен на баланс")
|
||||
await callback.answer(message, show_alert=True)
|
||||
return
|
||||
|
||||
status_normalized = (payment.status or "").lower()
|
||||
status_messages = {
|
||||
"check": texts.t("HELEKET_STATUS_CHECK", "⏳ Ожидание оплаты"),
|
||||
"process": texts.t("HELEKET_STATUS_PROCESS", "⚙️ Платёж обрабатывается"),
|
||||
"confirm_check": texts.t("HELEKET_STATUS_CONFIRM_CHECK", "⛓ Ожидание подтверждений сети"),
|
||||
"wrong_amount": texts.t("HELEKET_STATUS_WRONG_AMOUNT", "❗️ Оплачена неверная сумма"),
|
||||
"wrong_amount_waiting": texts.t(
|
||||
"HELEKET_STATUS_WRONG_AMOUNT_WAITING",
|
||||
"❗️ Недостаточная сумма, ожидаем доплату",
|
||||
),
|
||||
"paid_over": texts.t("HELEKET_STATUS_PAID_OVER", "✅ Платёж зачислен (с переплатой)"),
|
||||
"paid": texts.t("HELEKET_STATUS_PAID", "✅ Платёж зачислен"),
|
||||
"cancel": texts.t("HELEKET_STATUS_CANCEL", "🚫 Платёж отменён"),
|
||||
"fail": texts.t("HELEKET_STATUS_FAIL", "❌ Ошибка при оплате"),
|
||||
"system_fail": texts.t("HELEKET_STATUS_SYSTEM_FAIL", "❌ Системная ошибка Heleket"),
|
||||
"refund_process": texts.t("HELEKET_STATUS_REFUND_PROCESS", "↩️ Возврат обрабатывается"),
|
||||
"refund_fail": texts.t("HELEKET_STATUS_REFUND_FAIL", "⚠️ Ошибка возврата"),
|
||||
"refund_paid": texts.t("HELEKET_STATUS_REFUND_PAID", "✅ Возврат выполнен"),
|
||||
"locked": texts.t("HELEKET_STATUS_LOCKED", "🔒 Средства заблокированы"),
|
||||
}
|
||||
|
||||
message = status_messages.get(status_normalized)
|
||||
if message is None:
|
||||
template = texts.t("HELEKET_STATUS_UNKNOWN", "ℹ️ Статус платежа: {status}")
|
||||
status_value = payment.status or status_normalized or "—"
|
||||
try:
|
||||
message = template.format(status=status_value)
|
||||
except Exception: # pragma: no cover - defensive formatting
|
||||
message = f"ℹ️ Статус платежа: {status_value}"
|
||||
|
||||
await callback.answer(message, show_alert=True)
|
||||
@@ -0,0 +1,965 @@
|
||||
import html
|
||||
import logging
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.exceptions import TelegramBadRequest
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.states import BalanceStates
|
||||
from app.database.crud.user import add_user_balance
|
||||
from app.utils.price_display import calculate_user_price, format_price_button
|
||||
from app.utils.pricing_utils import format_period_description
|
||||
from app.database.crud.transaction import (
|
||||
get_user_transactions, get_user_transactions_count,
|
||||
create_transaction
|
||||
)
|
||||
from app.database.models import User, TransactionType, PaymentMethod
|
||||
from app.keyboards.inline import (
|
||||
get_balance_keyboard, get_payment_methods_keyboard,
|
||||
get_back_keyboard, get_pagination_keyboard
|
||||
)
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.utils.pagination import paginate_list
|
||||
from app.utils.decorators import error_handler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TRANSACTIONS_PER_PAGE = 10
|
||||
|
||||
|
||||
def get_quick_amount_buttons(language: str, user: User) -> list:
|
||||
"""
|
||||
Generate quick amount buttons with user-specific pricing and discounts.
|
||||
|
||||
Args:
|
||||
language: User's language for formatting
|
||||
user: User object to calculate personalized discounts
|
||||
|
||||
Returns:
|
||||
List of button rows for inline keyboard
|
||||
"""
|
||||
if not settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED or settings.DISABLE_TOPUP_BUTTONS:
|
||||
return []
|
||||
|
||||
from app.localization.texts import get_texts
|
||||
texts = get_texts(language)
|
||||
|
||||
buttons = []
|
||||
periods = settings.get_available_subscription_periods()
|
||||
periods = periods[:6] # Limit to 6 periods
|
||||
|
||||
for period in periods:
|
||||
price_attr = f"PRICE_{period}_DAYS"
|
||||
if hasattr(settings, price_attr):
|
||||
base_price_kopeks = getattr(settings, price_attr)
|
||||
|
||||
# Calculate price with user's promo group discount using unified system
|
||||
price_info = calculate_user_price(user, base_price_kopeks, period, "period")
|
||||
|
||||
callback_data = f"quick_amount_{price_info.final_price}"
|
||||
|
||||
# Format button text with discount display
|
||||
period_label = f"{period} дней"
|
||||
|
||||
# For balance buttons, use simpler format without emoji and period label prefix
|
||||
if price_info.has_discount:
|
||||
button_text = (
|
||||
f"{texts.format_price(price_info.base_price)} ➜ "
|
||||
f"{texts.format_price(price_info.final_price)} "
|
||||
f"(-{price_info.discount_percent}%) • {period_label}"
|
||||
)
|
||||
else:
|
||||
button_text = f"{texts.format_price(price_info.final_price)} • {period_label}"
|
||||
|
||||
buttons.append(
|
||||
types.InlineKeyboardButton(
|
||||
text=button_text,
|
||||
callback_data=callback_data
|
||||
)
|
||||
)
|
||||
|
||||
keyboard_rows = []
|
||||
for i in range(0, len(buttons), 2):
|
||||
keyboard_rows.append(buttons[i:i + 2])
|
||||
|
||||
return keyboard_rows
|
||||
|
||||
|
||||
@error_handler
|
||||
async def show_balance_menu(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
balance_text = texts.BALANCE_INFO.format(
|
||||
balance=texts.format_price(db_user.balance_kopeks)
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
balance_text,
|
||||
reply_markup=get_balance_keyboard(db_user.language)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def show_balance_history(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
page: int = 1
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
offset = (page - 1) * TRANSACTIONS_PER_PAGE
|
||||
|
||||
raw_transactions = await get_user_transactions(
|
||||
db, db_user.id,
|
||||
limit=TRANSACTIONS_PER_PAGE * 3,
|
||||
offset=offset
|
||||
)
|
||||
|
||||
seen_transactions = set()
|
||||
unique_transactions = []
|
||||
|
||||
for transaction in raw_transactions:
|
||||
rounded_time = transaction.created_at.replace(second=0, microsecond=0)
|
||||
transaction_key = (
|
||||
transaction.amount_kopeks,
|
||||
transaction.description,
|
||||
rounded_time
|
||||
)
|
||||
|
||||
if transaction_key not in seen_transactions:
|
||||
seen_transactions.add(transaction_key)
|
||||
unique_transactions.append(transaction)
|
||||
|
||||
if len(unique_transactions) >= TRANSACTIONS_PER_PAGE:
|
||||
break
|
||||
|
||||
all_transactions = await get_user_transactions(db, db_user.id, limit=1000)
|
||||
seen_all = set()
|
||||
total_unique = 0
|
||||
|
||||
for transaction in all_transactions:
|
||||
rounded_time = transaction.created_at.replace(second=0, microsecond=0)
|
||||
transaction_key = (
|
||||
transaction.amount_kopeks,
|
||||
transaction.description,
|
||||
rounded_time
|
||||
)
|
||||
if transaction_key not in seen_all:
|
||||
seen_all.add(transaction_key)
|
||||
total_unique += 1
|
||||
|
||||
if not unique_transactions:
|
||||
await callback.message.edit_text(
|
||||
"📊 История операций пуста",
|
||||
reply_markup=get_back_keyboard(db_user.language)
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
text = "📊 <b>История операций</b>\n\n"
|
||||
|
||||
for transaction in unique_transactions:
|
||||
emoji = "💰" if transaction.type == TransactionType.DEPOSIT.value else "💸"
|
||||
amount_text = f"+{texts.format_price(transaction.amount_kopeks)}" if transaction.type == TransactionType.DEPOSIT.value else f"-{texts.format_price(transaction.amount_kopeks)}"
|
||||
|
||||
text += f"{emoji} {amount_text}\n"
|
||||
text += f"📝 {transaction.description}\n"
|
||||
text += f"📅 {transaction.created_at.strftime('%d.%m.%Y %H:%M')}\n\n"
|
||||
|
||||
keyboard = []
|
||||
total_pages = (total_unique + TRANSACTIONS_PER_PAGE - 1) // TRANSACTIONS_PER_PAGE
|
||||
|
||||
if total_pages > 1:
|
||||
pagination_row = get_pagination_keyboard(
|
||||
page, total_pages, "balance_history", db_user.language
|
||||
)
|
||||
keyboard.extend(pagination_row)
|
||||
|
||||
keyboard.append([
|
||||
types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")
|
||||
])
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def handle_balance_history_pagination(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
page = int(callback.data.split('_')[-1])
|
||||
await show_balance_history(callback, db_user, db, page)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def show_payment_methods(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext
|
||||
):
|
||||
from app.utils.payment_utils import get_payment_methods_text
|
||||
from app.database.crud.subscription import get_subscription_by_user_id
|
||||
from app.utils.pricing_utils import calculate_months_from_days, apply_percentage_discount
|
||||
from app.config import settings
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
payment_text = get_payment_methods_text(db_user.language)
|
||||
|
||||
# Добавляем информацию о текущем тарифе пользователя
|
||||
subscription = await get_subscription_by_user_id(db, db_user.id)
|
||||
tariff_info = ""
|
||||
if subscription and not subscription.is_trial:
|
||||
# Рассчитываем приблизительную стоимость продления на 30 дней
|
||||
duration_days = 30 # Берем для примера 30 дней
|
||||
current_traffic = subscription.traffic_limit_gb
|
||||
current_connected_squads = subscription.connected_squads or []
|
||||
current_device_limit = subscription.device_limit or settings.DEFAULT_DEVICE_LIMIT
|
||||
|
||||
try:
|
||||
# Получаем цены для текущих параметров
|
||||
from app.config import PERIOD_PRICES
|
||||
base_price_original = PERIOD_PRICES.get(duration_days, 0)
|
||||
period_discount_percent = db_user.get_promo_discount("period", duration_days)
|
||||
base_price, base_discount_total = apply_percentage_discount(
|
||||
base_price_original,
|
||||
period_discount_percent,
|
||||
)
|
||||
|
||||
# Рассчитываем стоимость серверов
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
subscription_service = SubscriptionService()
|
||||
servers_price_per_month, per_server_monthly_prices = await subscription_service.get_countries_price_by_uuids(
|
||||
current_connected_squads,
|
||||
db,
|
||||
promo_group_id=db_user.promo_group_id,
|
||||
)
|
||||
servers_discount_percent = db_user.get_promo_discount("servers", duration_days)
|
||||
total_servers_price = 0
|
||||
for server_price in per_server_monthly_prices:
|
||||
discounted_per_month, discount_per_month = apply_percentage_discount(
|
||||
server_price,
|
||||
servers_discount_percent,
|
||||
)
|
||||
total_servers_price += discounted_per_month
|
||||
|
||||
# Рассчитываем стоимость трафика
|
||||
traffic_price_per_month = settings.get_traffic_price(current_traffic)
|
||||
traffic_discount_percent = db_user.get_promo_discount("traffic", duration_days)
|
||||
traffic_discounted_per_month, traffic_discount_per_month = apply_percentage_discount(
|
||||
traffic_price_per_month,
|
||||
traffic_discount_percent,
|
||||
)
|
||||
|
||||
# Рассчитываем стоимость устройств
|
||||
additional_devices = max(0, (current_device_limit or 0) - settings.DEFAULT_DEVICE_LIMIT)
|
||||
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
|
||||
devices_discount_percent = db_user.get_promo_discount("devices", duration_days)
|
||||
devices_discounted_per_month, devices_discount_per_month = apply_percentage_discount(
|
||||
devices_price_per_month,
|
||||
devices_discount_percent,
|
||||
)
|
||||
|
||||
# Общая стоимость
|
||||
months_in_period = calculate_months_from_days(duration_days)
|
||||
total_price = (
|
||||
base_price +
|
||||
total_servers_price * months_in_period +
|
||||
traffic_discounted_per_month * months_in_period +
|
||||
devices_discounted_per_month * months_in_period
|
||||
)
|
||||
|
||||
traffic_value = current_traffic or 0
|
||||
if traffic_value <= 0:
|
||||
traffic_display = texts.t("TRAFFIC_UNLIMITED_SHORT", "Безлимит")
|
||||
else:
|
||||
traffic_display = texts.format_traffic(traffic_value)
|
||||
|
||||
current_tariff_desc = (
|
||||
f"📱 Подписка: {len(current_connected_squads)} серверов, "
|
||||
f"{traffic_display}, {current_device_limit} устр."
|
||||
)
|
||||
estimated_price_info = f"💰 Стоимость продления (примерно): {texts.format_price(total_price)} за {duration_days} дней"
|
||||
|
||||
tariff_info = f"\n\n📋 <b>Ваш текущий тариф:</b>\n{current_tariff_desc}\n{estimated_price_info}"
|
||||
except Exception as e:
|
||||
logger.warning(f"Не удалось рассчитать стоимость текущей подписки для пользователя {db_user.id}: {e}")
|
||||
tariff_info = ""
|
||||
|
||||
full_text = payment_text + tariff_info
|
||||
|
||||
keyboard = get_payment_methods_keyboard(0, db_user.language)
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
full_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
except TelegramBadRequest:
|
||||
try:
|
||||
await callback.message.edit_caption(
|
||||
full_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
except TelegramBadRequest:
|
||||
try:
|
||||
await callback.message.delete()
|
||||
except TelegramBadRequest:
|
||||
pass
|
||||
await callback.message.answer(
|
||||
full_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def handle_payment_methods_unavailable(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"PAYMENT_METHODS_UNAVAILABLE_ALERT",
|
||||
"⚠️ В данный момент автоматические способы оплаты временно недоступны. Для пополнения баланса обратитесь в техподдержку.",
|
||||
),
|
||||
show_alert=True
|
||||
)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def handle_successful_topup_with_cart(
|
||||
user_id: int,
|
||||
amount_kopeks: int,
|
||||
bot,
|
||||
db: AsyncSession
|
||||
):
|
||||
from app.database.crud.user import get_user_by_id
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.storage.base import StorageKey
|
||||
from app.bot import dp
|
||||
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
return
|
||||
|
||||
storage = dp.storage
|
||||
key = StorageKey(bot_id=bot.id, chat_id=user.telegram_id, user_id=user.telegram_id)
|
||||
|
||||
try:
|
||||
state_data = await storage.get_data(key)
|
||||
current_state = await storage.get_state(key)
|
||||
|
||||
if (current_state == "SubscriptionStates:cart_saved_for_topup" and
|
||||
state_data.get('saved_cart')):
|
||||
|
||||
texts = get_texts(user.language)
|
||||
total_price = state_data.get('total_price', 0)
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(
|
||||
text="🛒 Вернуться к оформлению подписки",
|
||||
callback_data="return_to_saved_cart"
|
||||
)],
|
||||
[types.InlineKeyboardButton(
|
||||
text="💰 Мой баланс",
|
||||
callback_data="menu_balance"
|
||||
)],
|
||||
[types.InlineKeyboardButton(
|
||||
text="🏠 Главное меню",
|
||||
callback_data="back_to_menu"
|
||||
)]
|
||||
])
|
||||
|
||||
success_text = (
|
||||
f"✅ Баланс пополнен на {texts.format_price(amount_kopeks)}!\n\n"
|
||||
f"💰 Текущий баланс: {texts.format_price(user.balance_kopeks)}\n\n"
|
||||
f"⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. "
|
||||
f"Обязательно активируйте подписку отдельно!\n\n"
|
||||
f"🔄 При наличии сохранённой корзины подписки и включенной автопокупке, "
|
||||
f"подписка будет приобретена автоматически после пополнения баланса.\n\n"
|
||||
f"🛒 У вас есть сохраненная корзина подписки\n"
|
||||
f"Стоимость: {texts.format_price(total_price)}\n\n"
|
||||
f"Хотите продолжить оформление?"
|
||||
)
|
||||
|
||||
await bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=success_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обработки успешного пополнения с корзиной: {e}")
|
||||
|
||||
|
||||
@error_handler
|
||||
async def request_support_topup(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
support_text = f"""
|
||||
🛠️ <b>Пополнение через поддержку</b>
|
||||
|
||||
Для пополнения баланса обратитесь в техподдержку:
|
||||
{settings.get_support_contact_display_html()}
|
||||
|
||||
Укажите:
|
||||
• ID: {db_user.telegram_id}
|
||||
• Сумму пополнения
|
||||
• Способ оплаты
|
||||
|
||||
⏰ Время обработки: 1-24 часа
|
||||
|
||||
<b>Доступные способы:</b>
|
||||
• Криптовалюта
|
||||
• Переводы между банками
|
||||
• Другие платежные системы
|
||||
"""
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(
|
||||
text="💬 Написать в поддержку",
|
||||
url=settings.get_support_contact_url() or "https://t.me/"
|
||||
)],
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]
|
||||
])
|
||||
|
||||
await callback.message.edit_text(
|
||||
support_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_topup_amount(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
try:
|
||||
if not message.text:
|
||||
if message.successful_payment:
|
||||
logger.info(
|
||||
"Получено сообщение об успешном платеже без текста, "
|
||||
"обработчик суммы пополнения завершает работу"
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
await message.answer(
|
||||
texts.INVALID_AMOUNT,
|
||||
reply_markup=get_back_keyboard(db_user.language)
|
||||
)
|
||||
return
|
||||
|
||||
amount_text = message.text.strip()
|
||||
if not amount_text:
|
||||
await message.answer(
|
||||
texts.INVALID_AMOUNT,
|
||||
reply_markup=get_back_keyboard(db_user.language)
|
||||
)
|
||||
return
|
||||
|
||||
amount_rubles = float(amount_text.replace(',', '.'))
|
||||
|
||||
if amount_rubles < 1:
|
||||
await message.answer("Минимальная сумма пополнения: 1 ₽")
|
||||
return
|
||||
|
||||
if amount_rubles > 50000:
|
||||
await message.answer("Максимальная сумма пополнения: 50,000 ₽")
|
||||
return
|
||||
|
||||
amount_kopeks = int(amount_rubles * 100)
|
||||
data = await state.get_data()
|
||||
payment_method = data.get("payment_method", "stars")
|
||||
|
||||
if payment_method in ["yookassa", "yookassa_sbp"]:
|
||||
if amount_kopeks < settings.YOOKASSA_MIN_AMOUNT_KOPEKS:
|
||||
min_rubles = settings.YOOKASSA_MIN_AMOUNT_KOPEKS / 100
|
||||
await message.answer(f"❌ Минимальная сумма для оплаты через YooKassa: {min_rubles:.0f} ₽")
|
||||
return
|
||||
|
||||
if amount_kopeks > settings.YOOKASSA_MAX_AMOUNT_KOPEKS:
|
||||
max_rubles = settings.YOOKASSA_MAX_AMOUNT_KOPEKS / 100
|
||||
await message.answer(f"❌ Максимальная сумма для оплаты через YooKassa: {max_rubles:,.0f} ₽".replace(',', ' '))
|
||||
return
|
||||
|
||||
if payment_method == "stars":
|
||||
from .stars import process_stars_payment_amount
|
||||
await process_stars_payment_amount(message, db_user, amount_kopeks, state)
|
||||
elif payment_method == "yookassa":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .yookassa import process_yookassa_payment_amount
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_yookassa_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||
elif payment_method == "yookassa_sbp":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .yookassa import process_yookassa_sbp_payment_amount
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_yookassa_sbp_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||
elif payment_method == "mulenpay":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .mulenpay import process_mulenpay_payment_amount
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_mulenpay_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||
elif payment_method == "platega":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .platega import process_platega_payment_amount
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_platega_payment_amount(
|
||||
message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif payment_method == "wata":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .wata import process_wata_payment_amount
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_wata_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||
elif payment_method == "pal24":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .pal24 import process_pal24_payment_amount
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_pal24_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||
elif payment_method == "cryptobot":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .cryptobot import process_cryptobot_payment_amount
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_cryptobot_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||
elif payment_method == "heleket":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .heleket import process_heleket_payment_amount
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_heleket_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||
else:
|
||||
await message.answer("Неизвестный способ оплаты")
|
||||
|
||||
except ValueError:
|
||||
await message.answer(
|
||||
texts.INVALID_AMOUNT,
|
||||
reply_markup=get_back_keyboard(db_user.language)
|
||||
)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def handle_sbp_payment(
|
||||
callback: types.CallbackQuery,
|
||||
db: AsyncSession
|
||||
):
|
||||
try:
|
||||
local_payment_id = int(callback.data.split('_')[-1])
|
||||
|
||||
from app.database.crud.yookassa import get_yookassa_payment_by_local_id
|
||||
payment = await get_yookassa_payment_by_local_id(db, local_payment_id)
|
||||
|
||||
if not payment:
|
||||
await callback.answer("❌ Платеж не найден", show_alert=True)
|
||||
return
|
||||
|
||||
import json
|
||||
metadata = json.loads(payment.metadata_json) if payment.metadata_json else {}
|
||||
confirmation_token = metadata.get("confirmation_token")
|
||||
|
||||
if not confirmation_token:
|
||||
await callback.answer("❌ Токен подтверждения не найден", show_alert=True)
|
||||
return
|
||||
|
||||
await callback.message.answer(
|
||||
f"Для оплаты через СБП откройте приложение вашего банка и подтвердите платеж.\\n\\n"
|
||||
f"Если у вас не открылось банковское приложение автоматически, вы можете:\\n"
|
||||
f"1. Скопировать этот токен: <code>{confirmation_token}</code>\\n"
|
||||
f"2. Открыть приложение вашего банка\\n"
|
||||
f"3. Найти функцию оплаты по токену\\n"
|
||||
f"4. Вставить токен и подтвердить платеж",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
await callback.answer("Информация об оплате отправлена", show_alert=True)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обработки embedded платежа СБП: {e}")
|
||||
await callback.answer("❌ Ошибка обработки платежа", show_alert=True)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def handle_quick_amount_selection(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
):
|
||||
"""
|
||||
Обработчик выбора суммы через кнопки быстрого выбора
|
||||
"""
|
||||
# Извлекаем сумму из callback_data
|
||||
try:
|
||||
amount_kopeks = int(callback.data.split('_')[-1])
|
||||
amount_rubles = amount_kopeks / 100
|
||||
|
||||
# Получаем метод оплаты из состояния
|
||||
data = await state.get_data()
|
||||
payment_method = data.get("payment_method", "yookassa")
|
||||
|
||||
# Проверяем, какой метод оплаты был выбран и вызываем соответствующий обработчик
|
||||
if payment_method == "yookassa":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .yookassa import process_yookassa_payment_amount
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_yookassa_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif payment_method == "yookassa_sbp":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .yookassa import process_yookassa_sbp_payment_amount
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_yookassa_sbp_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif payment_method == "mulenpay":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .mulenpay import process_mulenpay_payment_amount
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_mulenpay_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif payment_method == "platega":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .platega import process_platega_payment_amount
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_platega_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif payment_method == "wata":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .wata import process_wata_payment_amount
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_wata_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif payment_method == "pal24":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .pal24 import process_pal24_payment_amount
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_pal24_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif payment_method == "cryptobot":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .cryptobot import process_cryptobot_payment_amount
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_cryptobot_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif payment_method == "heleket":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .heleket import process_heleket_payment_amount
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_heleket_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif payment_method == "stars":
|
||||
from .stars import process_stars_payment_amount
|
||||
|
||||
await process_stars_payment_amount(
|
||||
callback.message, db_user, amount_kopeks, state
|
||||
)
|
||||
else:
|
||||
await callback.answer("❌ Неизвестный способ оплаты", show_alert=True)
|
||||
return
|
||||
|
||||
except ValueError:
|
||||
await callback.answer("❌ Ошибка обработки суммы", show_alert=True)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обработки быстрого выбора суммы: {e}")
|
||||
await callback.answer("❌ Ошибка обработки запроса", show_alert=True)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def handle_topup_amount_callback(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
):
|
||||
try:
|
||||
_, method, amount_str = callback.data.split("|", 2)
|
||||
amount_kopeks = int(amount_str)
|
||||
except ValueError:
|
||||
await callback.answer("❌ Некорректный запрос", show_alert=True)
|
||||
return
|
||||
|
||||
if amount_kopeks <= 0:
|
||||
await callback.answer("❌ Некорректная сумма", show_alert=True)
|
||||
return
|
||||
|
||||
try:
|
||||
if method == "yookassa":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .yookassa import process_yookassa_payment_amount
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_yookassa_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif method == "yookassa_sbp":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .yookassa import process_yookassa_sbp_payment_amount
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_yookassa_sbp_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif method == "mulenpay":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .mulenpay import process_mulenpay_payment_amount
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_mulenpay_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif method == "platega":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .platega import process_platega_payment_amount
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_platega_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif method == "pal24":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .pal24 import process_pal24_payment_amount
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_pal24_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif method == "cryptobot":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .cryptobot import process_cryptobot_payment_amount
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_cryptobot_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif method == "heleket":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .heleket import process_heleket_payment_amount
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_heleket_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif method == "wata":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .wata import process_wata_payment_amount
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_wata_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif method == "stars":
|
||||
from .stars import process_stars_payment_amount
|
||||
await process_stars_payment_amount(
|
||||
callback.message, db_user, amount_kopeks, state
|
||||
)
|
||||
elif method == "tribute":
|
||||
from .tribute import start_tribute_payment
|
||||
await start_tribute_payment(callback, db_user)
|
||||
return
|
||||
else:
|
||||
await callback.answer("❌ Неизвестный способ оплаты", show_alert=True)
|
||||
return
|
||||
|
||||
await callback.answer()
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка быстрого пополнения: {error}")
|
||||
await callback.answer("❌ Ошибка обработки запроса", show_alert=True)
|
||||
|
||||
|
||||
def register_balance_handlers(dp: Dispatcher):
|
||||
|
||||
dp.callback_query.register(
|
||||
show_balance_menu,
|
||||
F.data == "menu_balance"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_balance_history,
|
||||
F.data == "balance_history"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
handle_balance_history_pagination,
|
||||
F.data.startswith("balance_history_page_")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_payment_methods,
|
||||
F.data == "balance_topup"
|
||||
)
|
||||
|
||||
from .stars import start_stars_payment
|
||||
dp.callback_query.register(
|
||||
start_stars_payment,
|
||||
F.data == "topup_stars"
|
||||
)
|
||||
|
||||
from .yookassa import start_yookassa_payment
|
||||
dp.callback_query.register(
|
||||
start_yookassa_payment,
|
||||
F.data == "topup_yookassa"
|
||||
)
|
||||
|
||||
from .yookassa import start_yookassa_sbp_payment
|
||||
dp.callback_query.register(
|
||||
start_yookassa_sbp_payment,
|
||||
F.data == "topup_yookassa_sbp"
|
||||
)
|
||||
|
||||
from .mulenpay import start_mulenpay_payment
|
||||
dp.callback_query.register(
|
||||
start_mulenpay_payment,
|
||||
F.data == "topup_mulenpay"
|
||||
)
|
||||
|
||||
from .wata import start_wata_payment
|
||||
dp.callback_query.register(
|
||||
start_wata_payment,
|
||||
F.data == "topup_wata"
|
||||
)
|
||||
|
||||
from .pal24 import start_pal24_payment
|
||||
dp.callback_query.register(
|
||||
start_pal24_payment,
|
||||
F.data == "topup_pal24"
|
||||
)
|
||||
from .pal24 import handle_pal24_method_selection
|
||||
dp.callback_query.register(
|
||||
handle_pal24_method_selection,
|
||||
F.data.startswith("pal24_method_"),
|
||||
)
|
||||
|
||||
from .platega import start_platega_payment, handle_platega_method_selection
|
||||
dp.callback_query.register(
|
||||
start_platega_payment,
|
||||
F.data == "topup_platega"
|
||||
)
|
||||
dp.callback_query.register(
|
||||
handle_platega_method_selection,
|
||||
F.data.startswith("platega_method_"),
|
||||
)
|
||||
|
||||
from .yookassa import check_yookassa_payment_status
|
||||
dp.callback_query.register(
|
||||
check_yookassa_payment_status,
|
||||
F.data.startswith("check_yookassa_")
|
||||
)
|
||||
|
||||
from .tribute import start_tribute_payment
|
||||
dp.callback_query.register(
|
||||
start_tribute_payment,
|
||||
F.data == "topup_tribute"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
request_support_topup,
|
||||
F.data == "topup_support"
|
||||
)
|
||||
|
||||
from .yookassa import check_yookassa_payment_status
|
||||
dp.callback_query.register(
|
||||
check_yookassa_payment_status,
|
||||
F.data.startswith("check_yookassa_")
|
||||
)
|
||||
|
||||
dp.message.register(
|
||||
process_topup_amount,
|
||||
BalanceStates.waiting_for_amount
|
||||
)
|
||||
|
||||
from .cryptobot import start_cryptobot_payment
|
||||
dp.callback_query.register(
|
||||
start_cryptobot_payment,
|
||||
F.data == "topup_cryptobot"
|
||||
)
|
||||
|
||||
from .cryptobot import check_cryptobot_payment_status
|
||||
dp.callback_query.register(
|
||||
check_cryptobot_payment_status,
|
||||
F.data.startswith("check_cryptobot_")
|
||||
)
|
||||
|
||||
from .heleket import start_heleket_payment, check_heleket_payment_status
|
||||
dp.callback_query.register(
|
||||
start_heleket_payment,
|
||||
F.data == "topup_heleket"
|
||||
)
|
||||
dp.callback_query.register(
|
||||
check_heleket_payment_status,
|
||||
F.data.startswith("check_heleket_")
|
||||
)
|
||||
|
||||
from .mulenpay import check_mulenpay_payment_status
|
||||
dp.callback_query.register(
|
||||
check_mulenpay_payment_status,
|
||||
F.data.startswith("check_mulenpay_")
|
||||
)
|
||||
|
||||
from .wata import check_wata_payment_status
|
||||
dp.callback_query.register(
|
||||
check_wata_payment_status,
|
||||
F.data.startswith("check_wata_")
|
||||
)
|
||||
|
||||
from .pal24 import check_pal24_payment_status
|
||||
dp.callback_query.register(
|
||||
check_pal24_payment_status,
|
||||
F.data.startswith("check_pal24_")
|
||||
)
|
||||
|
||||
from .platega import check_platega_payment_status
|
||||
dp.callback_query.register(
|
||||
check_platega_payment_status,
|
||||
F.data.startswith("check_platega_")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
handle_payment_methods_unavailable,
|
||||
F.data == "payment_methods_unavailable"
|
||||
)
|
||||
|
||||
# Регистрируем обработчик для кнопок быстрого выбора суммы
|
||||
dp.callback_query.register(
|
||||
handle_quick_amount_selection,
|
||||
F.data.startswith("quick_amount_")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
handle_topup_amount_callback,
|
||||
F.data.startswith("topup_amount|")
|
||||
)
|
||||
@@ -0,0 +1,255 @@
|
||||
import logging
|
||||
from aiogram import types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.keyboards.inline import get_back_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.utils.decorators import error_handler
|
||||
from app.states import BalanceStates
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_mulenpay_payment(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
mulenpay_name = settings.get_mulenpay_display_name()
|
||||
mulenpay_name_html = settings.get_mulenpay_display_name_html()
|
||||
|
||||
if not settings.is_mulenpay_enabled():
|
||||
await callback.answer(
|
||||
f"❌ Оплата через {mulenpay_name} временно недоступна",
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
message_template = texts.t(
|
||||
"MULENPAY_TOPUP_PROMPT",
|
||||
(
|
||||
"💳 <b>Оплата через {mulenpay_name_html}</b>\n\n"
|
||||
"Введите сумму для пополнения от 100 до 100 000 ₽.\n"
|
||||
"Оплата происходит через защищенную платформу {mulenpay_name}."
|
||||
),
|
||||
)
|
||||
message_text = message_template.format(
|
||||
mulenpay_name=mulenpay_name,
|
||||
mulenpay_name_html=mulenpay_name_html,
|
||||
)
|
||||
|
||||
keyboard = get_back_keyboard(db_user.language)
|
||||
|
||||
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
|
||||
from .main import get_quick_amount_buttons
|
||||
quick_amount_buttons = get_quick_amount_buttons(db_user.language, db_user)
|
||||
if quick_amount_buttons:
|
||||
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
|
||||
|
||||
await callback.message.edit_text(
|
||||
message_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
await state.set_state(BalanceStates.waiting_for_amount)
|
||||
await state.update_data(payment_method="mulenpay")
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_mulenpay_payment_amount(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
amount_kopeks: int,
|
||||
state: FSMContext,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
mulenpay_name = settings.get_mulenpay_display_name()
|
||||
mulenpay_name_html = settings.get_mulenpay_display_name_html()
|
||||
|
||||
if not settings.is_mulenpay_enabled():
|
||||
await message.answer(f"❌ Оплата через {mulenpay_name} временно недоступна")
|
||||
return
|
||||
|
||||
if amount_kopeks < settings.MULENPAY_MIN_AMOUNT_KOPEKS:
|
||||
await message.answer(
|
||||
f"Минимальная сумма пополнения: {settings.format_price(settings.MULENPAY_MIN_AMOUNT_KOPEKS)}"
|
||||
)
|
||||
return
|
||||
|
||||
if amount_kopeks > settings.MULENPAY_MAX_AMOUNT_KOPEKS:
|
||||
await message.answer(
|
||||
f"Максимальная сумма пополнения: {settings.format_price(settings.MULENPAY_MAX_AMOUNT_KOPEKS)}"
|
||||
)
|
||||
return
|
||||
|
||||
amount_rubles = amount_kopeks / 100
|
||||
|
||||
try:
|
||||
payment_service = PaymentService(message.bot)
|
||||
payment_result = await payment_service.create_mulenpay_payment(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=settings.get_balance_payment_description(amount_kopeks),
|
||||
language=db_user.language,
|
||||
)
|
||||
|
||||
if not payment_result or not payment_result.get("payment_url"):
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"MULENPAY_PAYMENT_ERROR",
|
||||
"❌ Ошибка создания платежа {mulenpay_name}. Попробуйте позже или обратитесь в поддержку.",
|
||||
).format(mulenpay_name=mulenpay_name)
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
payment_url = payment_result.get("payment_url")
|
||||
mulen_payment_id = payment_result.get("mulen_payment_id")
|
||||
local_payment_id = payment_result.get("local_payment_id")
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"MULENPAY_PAY_BUTTON",
|
||||
"💳 Оплатить через {mulenpay_name}",
|
||||
).format(mulenpay_name=mulenpay_name),
|
||||
url=payment_url,
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"),
|
||||
callback_data=f"check_mulenpay_{local_payment_id}",
|
||||
)
|
||||
],
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")],
|
||||
]
|
||||
)
|
||||
|
||||
payment_id_display = mulen_payment_id if mulen_payment_id is not None else local_payment_id
|
||||
|
||||
message_template = texts.t(
|
||||
"MULENPAY_PAYMENT_INSTRUCTIONS",
|
||||
(
|
||||
"💳 <b>Оплата через {mulenpay_name_html}</b>\n\n"
|
||||
"💰 Сумма: {amount}\n"
|
||||
"🆔 ID платежа: {payment_id}\n\n"
|
||||
"📱 <b>Инструкция:</b>\n"
|
||||
"1. Нажмите кнопку 'Оплатить через {mulenpay_name}'\n"
|
||||
"2. Следуйте подсказкам платежной системы\n"
|
||||
"3. Подтвердите перевод\n"
|
||||
"4. Средства зачислятся автоматически\n\n"
|
||||
"❓ Если возникнут проблемы, обратитесь в {support}"
|
||||
),
|
||||
)
|
||||
|
||||
message_text = message_template.format(
|
||||
amount=settings.format_price(amount_kopeks),
|
||||
payment_id=payment_id_display,
|
||||
support=settings.get_support_contact_display_html(),
|
||||
mulenpay_name=mulenpay_name,
|
||||
mulenpay_name_html=mulenpay_name_html,
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
message_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
|
||||
logger.info(
|
||||
"Создан %s платеж для пользователя %s: %s₽, ID: %s",
|
||||
mulenpay_name,
|
||||
db_user.telegram_id,
|
||||
amount_rubles,
|
||||
payment_id_display,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания {mulenpay_name} платежа: {e}")
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"MULENPAY_PAYMENT_ERROR",
|
||||
"❌ Ошибка создания платежа {mulenpay_name}. Попробуйте позже или обратитесь в поддержку.",
|
||||
).format(mulenpay_name=mulenpay_name)
|
||||
)
|
||||
await state.clear()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def check_mulenpay_payment_status(
|
||||
callback: types.CallbackQuery,
|
||||
db: AsyncSession
|
||||
):
|
||||
try:
|
||||
local_payment_id = int(callback.data.split('_')[-1])
|
||||
payment_service = PaymentService(callback.bot)
|
||||
status_info = await payment_service.get_mulenpay_payment_status(db, local_payment_id)
|
||||
|
||||
if not status_info:
|
||||
await callback.answer("❌ Платеж не найден", show_alert=True)
|
||||
return
|
||||
|
||||
payment = status_info["payment"]
|
||||
|
||||
status_labels = {
|
||||
"created": ("⏳", "Ожидает оплаты"),
|
||||
"processing": ("⌛", "Обрабатывается"),
|
||||
"success": ("✅", "Оплачен"),
|
||||
"canceled": ("❌", "Отменен"),
|
||||
"error": ("⚠️", "Ошибка"),
|
||||
"hold": ("🔒", "Холд"),
|
||||
"unknown": ("❓", "Неизвестно"),
|
||||
}
|
||||
|
||||
emoji, status_text = status_labels.get(payment.status, ("❓", "Неизвестно"))
|
||||
|
||||
mulenpay_name = settings.get_mulenpay_display_name()
|
||||
message_lines = [
|
||||
f"💳 Статус платежа {mulenpay_name}:\n\n",
|
||||
f"🆔 ID: {payment.mulen_payment_id or payment.id}\n",
|
||||
f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n",
|
||||
f"📊 Статус: {emoji} {status_text}\n",
|
||||
f"📅 Создан: {payment.created_at.strftime('%d.%m.%Y %H:%M')}\n",
|
||||
]
|
||||
|
||||
if payment.is_paid:
|
||||
message_lines.append("\n✅ Платеж успешно завершен! Средства уже на балансе.")
|
||||
elif payment.status in {"created", "processing"}:
|
||||
message_lines.append(
|
||||
"\n⏳ Платеж еще не завершен. Завершите оплату по ссылке и проверьте статус позже."
|
||||
)
|
||||
if payment.payment_url:
|
||||
message_lines.append(f"\n🔗 Ссылка на оплату: {payment.payment_url}")
|
||||
elif payment.status in {"canceled", "error"}:
|
||||
message_lines.append(
|
||||
f"\n❌ Платеж не был завершен. Попробуйте создать новый платеж или обратитесь в {settings.get_support_contact_display()}"
|
||||
)
|
||||
|
||||
message_text = "".join(message_lines)
|
||||
|
||||
if len(message_text) > 190:
|
||||
await callback.message.answer(message_text)
|
||||
await callback.answer("ℹ️ Статус платежа отправлен в чат", show_alert=True)
|
||||
else:
|
||||
await callback.answer(message_text, show_alert=True)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Ошибка проверки статуса {settings.get_mulenpay_display_name()}: {e}"
|
||||
)
|
||||
await callback.answer("❌ Ошибка проверки статуса", show_alert=True)
|
||||
@@ -0,0 +1,596 @@
|
||||
import html
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
from aiogram import types
|
||||
from aiogram.exceptions import TelegramBadRequest
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from app.database.models import User
|
||||
from app.keyboards.inline import get_back_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.utils.decorators import error_handler
|
||||
from app.states import BalanceStates
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_available_pal24_methods() -> list[str]:
|
||||
methods: list[str] = []
|
||||
if settings.is_pal24_sbp_button_visible():
|
||||
methods.append("sbp")
|
||||
if settings.is_pal24_card_button_visible():
|
||||
methods.append("card")
|
||||
if not methods:
|
||||
methods.append("sbp")
|
||||
return methods
|
||||
|
||||
|
||||
async def _send_pal24_payment_message(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
amount_kopeks: int,
|
||||
payment_method: str,
|
||||
state: FSMContext,
|
||||
) -> None:
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
try:
|
||||
payment_service = PaymentService(message.bot)
|
||||
payment_result = await payment_service.create_pal24_payment(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=settings.get_balance_payment_description(amount_kopeks),
|
||||
language=db_user.language,
|
||||
payment_method=payment_method,
|
||||
)
|
||||
|
||||
if not payment_result:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"PAL24_PAYMENT_ERROR",
|
||||
"❌ Ошибка создания платежа PayPalych. Попробуйте позже или обратитесь в поддержку.",
|
||||
)
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
sbp_url = (
|
||||
payment_result.get("sbp_url")
|
||||
or payment_result.get("transfer_url")
|
||||
)
|
||||
card_url = payment_result.get("card_url")
|
||||
fallback_url = (
|
||||
payment_result.get("link_page_url")
|
||||
or payment_result.get("link_url")
|
||||
)
|
||||
|
||||
if not (sbp_url or card_url or fallback_url):
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"PAL24_PAYMENT_ERROR",
|
||||
"❌ Ошибка создания платежа PayPalych. Попробуйте позже или обратитесь в поддержку.",
|
||||
)
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
if not sbp_url:
|
||||
sbp_url = fallback_url
|
||||
|
||||
bill_id = payment_result.get("bill_id")
|
||||
local_payment_id = payment_result.get("local_payment_id")
|
||||
|
||||
pay_buttons: list[list[types.InlineKeyboardButton]] = []
|
||||
steps: list[str] = []
|
||||
step_counter = 1
|
||||
|
||||
default_sbp_text = texts.t(
|
||||
"PAL24_SBP_PAY_BUTTON",
|
||||
"🏦 Оплатить через PayPalych (СБП)",
|
||||
)
|
||||
sbp_button_text = settings.get_pal24_sbp_button_text(default_sbp_text)
|
||||
|
||||
if sbp_url and settings.is_pal24_sbp_button_visible():
|
||||
pay_buttons.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=sbp_button_text,
|
||||
url=sbp_url,
|
||||
)
|
||||
]
|
||||
)
|
||||
steps.append(
|
||||
texts.t(
|
||||
"PAL24_INSTRUCTION_BUTTON",
|
||||
"{step}. Нажмите кнопку «{button}»",
|
||||
).format(step=step_counter, button=html.escape(sbp_button_text))
|
||||
)
|
||||
step_counter += 1
|
||||
|
||||
default_card_text = texts.t(
|
||||
"PAL24_CARD_PAY_BUTTON",
|
||||
"💳 Оплатить банковской картой (PayPalych)",
|
||||
)
|
||||
card_button_text = settings.get_pal24_card_button_text(default_card_text)
|
||||
|
||||
if card_url and card_url != sbp_url and settings.is_pal24_card_button_visible():
|
||||
pay_buttons.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=card_button_text,
|
||||
url=card_url,
|
||||
)
|
||||
]
|
||||
)
|
||||
steps.append(
|
||||
texts.t(
|
||||
"PAL24_INSTRUCTION_BUTTON",
|
||||
"{step}. Нажмите кнопку «{button}»",
|
||||
).format(step=step_counter, button=html.escape(card_button_text))
|
||||
)
|
||||
step_counter += 1
|
||||
|
||||
if not pay_buttons and fallback_url and settings.is_pal24_sbp_button_visible():
|
||||
pay_buttons.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=sbp_button_text,
|
||||
url=fallback_url,
|
||||
)
|
||||
]
|
||||
)
|
||||
steps.append(
|
||||
texts.t(
|
||||
"PAL24_INSTRUCTION_BUTTON",
|
||||
"{step}. Нажмите кнопку «{button}»",
|
||||
).format(step=step_counter, button=html.escape(sbp_button_text))
|
||||
)
|
||||
step_counter += 1
|
||||
|
||||
follow_template = texts.t(
|
||||
"PAL24_INSTRUCTION_FOLLOW",
|
||||
"{step}. Следуйте подсказкам платёжной системы",
|
||||
)
|
||||
steps.append(follow_template.format(step=step_counter))
|
||||
step_counter += 1
|
||||
|
||||
confirm_template = texts.t(
|
||||
"PAL24_INSTRUCTION_CONFIRM",
|
||||
"{step}. Подтвердите перевод",
|
||||
)
|
||||
steps.append(confirm_template.format(step=step_counter))
|
||||
step_counter += 1
|
||||
|
||||
success_template = texts.t(
|
||||
"PAL24_INSTRUCTION_COMPLETE",
|
||||
"{step}. Средства зачислятся автоматически",
|
||||
)
|
||||
steps.append(success_template.format(step=step_counter))
|
||||
|
||||
message_template = texts.t(
|
||||
"PAL24_PAYMENT_INSTRUCTIONS",
|
||||
(
|
||||
"🏦 <b>Оплата через PayPalych</b>\n\n"
|
||||
"💰 Сумма: {amount}\n"
|
||||
"🆔 ID счета: {bill_id}\n\n"
|
||||
"📱 <b>Инструкция:</b>\n{steps}\n\n"
|
||||
"❓ Если возникнут проблемы, обратитесь в {support}"
|
||||
),
|
||||
)
|
||||
|
||||
keyboard_rows = pay_buttons + [
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"),
|
||||
callback_data=f"check_pal24_{local_payment_id}",
|
||||
)
|
||||
],
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")],
|
||||
]
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
|
||||
|
||||
message_text = message_template.format(
|
||||
amount=settings.format_price(amount_kopeks),
|
||||
bill_id=bill_id,
|
||||
steps="\n".join(steps),
|
||||
support=settings.get_support_contact_display_html(),
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
message_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
|
||||
logger.info(
|
||||
"Создан PayPalych счет для пользователя %s: %s₽, ID: %s, метод: %s",
|
||||
db_user.telegram_id,
|
||||
amount_kopeks / 100,
|
||||
bill_id,
|
||||
payment_method,
|
||||
)
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка создания PayPalych платежа: {error}")
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"PAL24_PAYMENT_ERROR",
|
||||
"❌ Ошибка создания платежа PayPalych. Попробуйте позже или обратитесь в поддержку.",
|
||||
)
|
||||
)
|
||||
await state.clear()
|
||||
|
||||
@error_handler
|
||||
async def start_pal24_payment(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_pal24_enabled():
|
||||
await callback.answer("❌ Оплата через PayPalych временно недоступна", show_alert=True)
|
||||
return
|
||||
|
||||
# Формируем текст сообщения в зависимости от доступных способов оплаты
|
||||
if settings.is_pal24_sbp_button_visible() and settings.is_pal24_card_button_visible():
|
||||
payment_methods_text = "СБП и банковской картой"
|
||||
elif settings.is_pal24_sbp_button_visible():
|
||||
payment_methods_text = "СБП"
|
||||
elif settings.is_pal24_card_button_visible():
|
||||
payment_methods_text = "банковской картой"
|
||||
else:
|
||||
# Если обе кнопки отключены, используем общий текст
|
||||
payment_methods_text = "доступными способами"
|
||||
|
||||
message_text = texts.t(
|
||||
"PAL24_TOPUP_PROMPT",
|
||||
(
|
||||
f"🏦 <b>Оплата через PayPalych ({payment_methods_text})</b>\n\n"
|
||||
"Введите сумму для пополнения от 100 до 1 000 000 ₽.\n"
|
||||
f"Оплата проходит через PayPalych ({payment_methods_text})."
|
||||
),
|
||||
)
|
||||
|
||||
keyboard = get_back_keyboard(db_user.language)
|
||||
|
||||
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
|
||||
from .main import get_quick_amount_buttons
|
||||
quick_amount_buttons = get_quick_amount_buttons(db_user.language, db_user)
|
||||
if quick_amount_buttons:
|
||||
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
|
||||
|
||||
await callback.message.edit_text(
|
||||
message_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
await state.set_state(BalanceStates.waiting_for_amount)
|
||||
await state.update_data(payment_method="pal24")
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_pal24_payment_amount(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
amount_kopeks: int,
|
||||
state: FSMContext,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_pal24_enabled():
|
||||
await message.answer("❌ Оплата через PayPalych временно недоступна")
|
||||
return
|
||||
|
||||
if amount_kopeks < settings.PAL24_MIN_AMOUNT_KOPEKS:
|
||||
min_rubles = settings.PAL24_MIN_AMOUNT_KOPEKS / 100
|
||||
await message.answer(f"❌ Минимальная сумма для оплаты через PayPalych: {min_rubles:.0f} ₽")
|
||||
return
|
||||
|
||||
if amount_kopeks > settings.PAL24_MAX_AMOUNT_KOPEKS:
|
||||
max_rubles = settings.PAL24_MAX_AMOUNT_KOPEKS / 100
|
||||
await message.answer(
|
||||
f"❌ Максимальная сумма для оплаты через PayPalych: {max_rubles:,.0f} ₽".replace(',', ' ')
|
||||
)
|
||||
return
|
||||
|
||||
available_methods = _get_available_pal24_methods()
|
||||
|
||||
if len(available_methods) == 1:
|
||||
await _send_pal24_payment_message(
|
||||
message,
|
||||
db_user,
|
||||
db,
|
||||
amount_kopeks,
|
||||
available_methods[0],
|
||||
state,
|
||||
)
|
||||
return
|
||||
|
||||
await state.update_data(pal24_amount_kopeks=amount_kopeks)
|
||||
await state.set_state(BalanceStates.waiting_for_pal24_method)
|
||||
|
||||
method_buttons: list[list[types.InlineKeyboardButton]] = []
|
||||
if "sbp" in available_methods:
|
||||
method_buttons.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=settings.get_pal24_sbp_button_text(
|
||||
texts.t("PAL24_SBP_PAY_BUTTON", "🏦 Оплатить через PayPalych (СБП)")
|
||||
),
|
||||
callback_data="pal24_method_sbp",
|
||||
)
|
||||
]
|
||||
)
|
||||
if "card" in available_methods:
|
||||
method_buttons.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=settings.get_pal24_card_button_text(
|
||||
texts.t("PAL24_CARD_PAY_BUTTON", "💳 Оплатить банковской картой (PayPalych)")
|
||||
),
|
||||
callback_data="pal24_method_card",
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
method_buttons.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")])
|
||||
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"PAL24_SELECT_PAYMENT_METHOD",
|
||||
"Выберите способ оплаты PayPalych:",
|
||||
),
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=method_buttons),
|
||||
)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def handle_pal24_method_selection(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
):
|
||||
data = await state.get_data()
|
||||
amount_kopeks = data.get("pal24_amount_kopeks")
|
||||
if not amount_kopeks:
|
||||
texts = get_texts(db_user.language)
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"PAL24_PAYMENT_ERROR",
|
||||
"❌ Ошибка создания платежа PayPalych. Попробуйте позже или обратитесь в поддержку.",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
method = "sbp" if callback.data.endswith("_sbp") else "card"
|
||||
|
||||
await callback.answer()
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
await _send_pal24_payment_message(
|
||||
callback.message,
|
||||
db_user,
|
||||
db,
|
||||
int(amount_kopeks),
|
||||
method,
|
||||
state,
|
||||
)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def check_pal24_payment_status(
|
||||
callback: types.CallbackQuery,
|
||||
db: AsyncSession,
|
||||
):
|
||||
try:
|
||||
local_payment_id = int(callback.data.split('_')[-1])
|
||||
payment_service = PaymentService(callback.bot)
|
||||
status_info = await payment_service.get_pal24_payment_status(db, local_payment_id)
|
||||
|
||||
if not status_info:
|
||||
await callback.answer("❌ Платеж не найден", show_alert=True)
|
||||
return
|
||||
|
||||
payment = status_info["payment"]
|
||||
|
||||
status_labels = {
|
||||
"NEW": ("⏳", "Ожидает оплаты"),
|
||||
"PROCESS": ("⌛", "Обрабатывается"),
|
||||
"SUCCESS": ("✅", "Оплачен"),
|
||||
"FAIL": ("❌", "Отменен"),
|
||||
"UNDERPAID": ("⚠️", "Недоплата"),
|
||||
"OVERPAID": ("⚠️", "Переплата"),
|
||||
}
|
||||
|
||||
emoji, status_text = status_labels.get(payment.status, ("❓", "Неизвестно"))
|
||||
|
||||
metadata = payment.metadata_json or {}
|
||||
links_meta = metadata.get("links") if isinstance(metadata, dict) else None
|
||||
if not isinstance(links_meta, dict):
|
||||
links_meta = {}
|
||||
|
||||
links_info = status_info.get("links") or {}
|
||||
|
||||
def _extract_link(source: Any, keys: tuple[str, ...]) -> Optional[str]:
|
||||
stack: list[Any] = [source]
|
||||
while stack:
|
||||
current = stack.pop()
|
||||
if isinstance(current, dict):
|
||||
for key in keys:
|
||||
value = current.get(key)
|
||||
if value:
|
||||
return str(value)
|
||||
stack.extend(current.values())
|
||||
elif isinstance(current, list):
|
||||
stack.extend(current)
|
||||
return None
|
||||
|
||||
raw_response = metadata.get("raw_response") if isinstance(metadata, dict) else None
|
||||
remote_data = status_info.get("remote_data")
|
||||
transfer_keys = (
|
||||
"transfer_url",
|
||||
"transferUrl",
|
||||
"transfer_link",
|
||||
"transferLink",
|
||||
"transfer",
|
||||
"sbp_url",
|
||||
"sbpUrl",
|
||||
"sbp_link",
|
||||
"sbpLink",
|
||||
)
|
||||
card_keys = (
|
||||
"link_url",
|
||||
"linkUrl",
|
||||
"link",
|
||||
"card_url",
|
||||
"cardUrl",
|
||||
"card_link",
|
||||
"cardLink",
|
||||
"payment_url",
|
||||
"paymentUrl",
|
||||
"url",
|
||||
)
|
||||
|
||||
extra_sbp_link = (
|
||||
_extract_link(raw_response, transfer_keys)
|
||||
if raw_response
|
||||
else None
|
||||
)
|
||||
if not extra_sbp_link and remote_data:
|
||||
extra_sbp_link = _extract_link(remote_data, transfer_keys)
|
||||
|
||||
extra_card_link = (
|
||||
_extract_link(raw_response, card_keys)
|
||||
if raw_response
|
||||
else None
|
||||
)
|
||||
if not extra_card_link and remote_data:
|
||||
extra_card_link = _extract_link(remote_data, card_keys)
|
||||
|
||||
sbp_link = (
|
||||
links_info.get("sbp")
|
||||
or links_meta.get("sbp")
|
||||
or status_info.get("sbp_url")
|
||||
or extra_sbp_link
|
||||
or payment.link_url
|
||||
)
|
||||
card_link = (
|
||||
links_info.get("card")
|
||||
or links_meta.get("card")
|
||||
or status_info.get("card_url")
|
||||
or extra_card_link
|
||||
)
|
||||
|
||||
if not card_link and payment.link_page_url and payment.link_page_url != sbp_link:
|
||||
card_link = payment.link_page_url
|
||||
|
||||
message_lines = [
|
||||
"🏦 Статус платежа PayPalych:",
|
||||
"",
|
||||
f"🆔 ID счета: {payment.bill_id}",
|
||||
f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}",
|
||||
f"📊 Статус: {emoji} {status_text}",
|
||||
f"📅 Создан: {payment.created_at.strftime('%d.%m.%Y %H:%M')}",
|
||||
]
|
||||
|
||||
if payment.is_paid:
|
||||
message_lines.append("")
|
||||
message_lines.append("✅ Платеж успешно завершен! Средства уже на балансе.")
|
||||
elif payment.status in {"NEW", "PROCESS"}:
|
||||
message_lines.append("")
|
||||
message_lines.append("⏳ Платеж еще не завершен. Оплатите счет и проверьте статус позже.")
|
||||
if sbp_link:
|
||||
message_lines.append("")
|
||||
message_lines.append(f"🏦 СБП: {sbp_link}")
|
||||
if card_link and card_link != sbp_link:
|
||||
message_lines.append(f"💳 Банковская карта: {card_link}")
|
||||
elif payment.status in {"FAIL", "UNDERPAID", "OVERPAID"}:
|
||||
message_lines.append("")
|
||||
message_lines.append(
|
||||
f"❌ Платеж не завершен корректно. Обратитесь в {settings.get_support_contact_display()}"
|
||||
)
|
||||
|
||||
from app.localization.texts import get_texts
|
||||
db_user = getattr(callback, 'db_user', None)
|
||||
texts = get_texts(db_user.language if db_user else 'ru') if db_user else get_texts('ru')
|
||||
|
||||
pay_rows: list[list[types.InlineKeyboardButton]] = []
|
||||
|
||||
if not payment.is_paid and payment.status in {"NEW", "PROCESS"}:
|
||||
default_sbp_text = texts.t(
|
||||
"PAL24_SBP_PAY_BUTTON",
|
||||
"🏦 Оплатить через PayPalych (СБП)",
|
||||
)
|
||||
sbp_button_text = settings.get_pal24_sbp_button_text(default_sbp_text)
|
||||
|
||||
if sbp_link and settings.is_pal24_sbp_button_visible():
|
||||
pay_rows.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=sbp_button_text,
|
||||
url=sbp_link,
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
default_card_text = texts.t(
|
||||
"PAL24_CARD_PAY_BUTTON",
|
||||
"💳 Оплатить банковской картой (PayPalych)",
|
||||
)
|
||||
card_button_text = settings.get_pal24_card_button_text(default_card_text)
|
||||
|
||||
if card_link and settings.is_pal24_card_button_visible():
|
||||
if not pay_rows or pay_rows[-1][0].url != card_link:
|
||||
pay_rows.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=card_button_text,
|
||||
url=card_link,
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
keyboard_rows = pay_rows + [
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"),
|
||||
callback_data=f"check_pal24_{local_payment_id}",
|
||||
)
|
||||
],
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")],
|
||||
]
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
|
||||
|
||||
await callback.answer()
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
"\n".join(message_lines),
|
||||
reply_markup=keyboard,
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
except TelegramBadRequest as error:
|
||||
if "message is not modified" in str(error).lower():
|
||||
await callback.answer(texts.t("CHECK_STATUS_NO_CHANGES", "Статус не изменился"))
|
||||
else:
|
||||
raise
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка проверки статуса PayPalych: {e}")
|
||||
await callback.answer("❌ Ошибка проверки статуса", show_alert=True)
|
||||
@@ -0,0 +1,333 @@
|
||||
"""Handlers for Platega balance interactions."""
|
||||
|
||||
import logging
|
||||
from typing import List
|
||||
|
||||
from aiogram import types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.keyboards.inline import get_back_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.states import BalanceStates
|
||||
from app.utils.decorators import error_handler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_active_methods() -> List[int]:
|
||||
methods = settings.get_platega_active_methods()
|
||||
return [code for code in methods if code in {2, 10, 11, 12, 13}]
|
||||
|
||||
|
||||
async def _prompt_amount(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
method_code: int,
|
||||
) -> None:
|
||||
texts = get_texts(db_user.language)
|
||||
method_name = settings.get_platega_method_display_title(method_code)
|
||||
|
||||
min_amount_label = settings.format_price(settings.PLATEGA_MIN_AMOUNT_KOPEKS)
|
||||
max_amount_kopeks = settings.PLATEGA_MAX_AMOUNT_KOPEKS
|
||||
max_amount_label = (
|
||||
settings.format_price(max_amount_kopeks)
|
||||
if max_amount_kopeks and max_amount_kopeks > 0
|
||||
else ""
|
||||
)
|
||||
|
||||
default_prompt_body = (
|
||||
"Введите сумму для пополнения от {min_amount} до {max_amount}.\n"
|
||||
if max_amount_kopeks and max_amount_kopeks > 0
|
||||
else "Введите сумму для пополнения от {min_amount}.\n"
|
||||
)
|
||||
|
||||
prompt_template = texts.t(
|
||||
"PLATEGA_TOPUP_PROMPT",
|
||||
(
|
||||
"💳 <b>Оплата через Platega ({method_name})</b>\n\n"
|
||||
f"{default_prompt_body}"
|
||||
"Оплата происходит через Platega."
|
||||
),
|
||||
)
|
||||
|
||||
keyboard = get_back_keyboard(db_user.language)
|
||||
|
||||
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
|
||||
from .main import get_quick_amount_buttons
|
||||
|
||||
quick_amount_buttons = get_quick_amount_buttons(db_user.language, db_user)
|
||||
if quick_amount_buttons:
|
||||
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
|
||||
|
||||
await message.edit_text(
|
||||
prompt_template.format(
|
||||
method_name=method_name,
|
||||
min_amount=min_amount_label,
|
||||
max_amount=max_amount_label,
|
||||
),
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
await state.set_state(BalanceStates.waiting_for_amount)
|
||||
await state.update_data(payment_method="platega", platega_method=method_code)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_platega_payment(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_platega_enabled():
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"PLATEGA_TEMPORARILY_UNAVAILABLE",
|
||||
"❌ Оплата через Platega временно недоступна",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
active_methods = _get_active_methods()
|
||||
if not active_methods:
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"PLATEGA_METHODS_NOT_CONFIGURED",
|
||||
"⚠️ На стороне Platega нет доступных методов оплаты",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
await state.update_data(payment_method="platega")
|
||||
|
||||
if len(active_methods) == 1:
|
||||
await _prompt_amount(callback.message, db_user, state, active_methods[0])
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
method_buttons: list[list[types.InlineKeyboardButton]] = []
|
||||
for method_code in active_methods:
|
||||
label = settings.get_platega_method_display_title(method_code)
|
||||
method_buttons.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=label,
|
||||
callback_data=f"platega_method_{method_code}",
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
method_buttons.append(
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.t(
|
||||
"PLATEGA_SELECT_PAYMENT_METHOD",
|
||||
"Выберите способ оплаты Platega:",
|
||||
),
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=method_buttons),
|
||||
)
|
||||
await state.set_state(BalanceStates.waiting_for_platega_method)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def handle_platega_method_selection(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
):
|
||||
try:
|
||||
method_code = int(callback.data.rsplit("_", 1)[-1])
|
||||
except ValueError:
|
||||
await callback.answer("❌ Некорректный способ оплаты", show_alert=True)
|
||||
return
|
||||
|
||||
if method_code not in _get_active_methods():
|
||||
await callback.answer("⚠️ Этот способ сейчас недоступен", show_alert=True)
|
||||
return
|
||||
|
||||
await _prompt_amount(callback.message, db_user, state, method_code)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_platega_payment_amount(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
amount_kopeks: int,
|
||||
state: FSMContext,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_platega_enabled():
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"PLATEGA_TEMPORARILY_UNAVAILABLE",
|
||||
"❌ Оплата через Platega временно недоступна",
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
data = await state.get_data()
|
||||
method_code = int(data.get("platega_method", 0))
|
||||
if method_code not in _get_active_methods():
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"PLATEGA_METHOD_SELECTION_REQUIRED",
|
||||
"⚠️ Выберите способ оплаты Platega перед вводом суммы",
|
||||
)
|
||||
)
|
||||
await state.set_state(BalanceStates.waiting_for_platega_method)
|
||||
return
|
||||
|
||||
if amount_kopeks < settings.PLATEGA_MIN_AMOUNT_KOPEKS:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"PLATEGA_AMOUNT_TOO_LOW",
|
||||
"Минимальная сумма для оплаты через Platega: {amount}",
|
||||
).format(amount=settings.format_price(settings.PLATEGA_MIN_AMOUNT_KOPEKS))
|
||||
)
|
||||
return
|
||||
|
||||
if amount_kopeks > settings.PLATEGA_MAX_AMOUNT_KOPEKS:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"PLATEGA_AMOUNT_TOO_HIGH",
|
||||
"Максимальная сумма для оплаты через Platega: {amount}",
|
||||
).format(amount=settings.format_price(settings.PLATEGA_MAX_AMOUNT_KOPEKS))
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
payment_service = PaymentService(message.bot)
|
||||
payment_result = await payment_service.create_platega_payment(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=settings.get_balance_payment_description(amount_kopeks),
|
||||
language=db_user.language,
|
||||
payment_method_code=method_code,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.exception("Ошибка создания платежа Platega: %s", error)
|
||||
payment_result = None
|
||||
|
||||
if not payment_result or not payment_result.get("redirect_url"):
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"PLATEGA_PAYMENT_ERROR",
|
||||
"❌ Ошибка создания платежа Platega. Попробуйте позже или обратитесь в поддержку.",
|
||||
)
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
redirect_url = payment_result.get("redirect_url")
|
||||
local_payment_id = payment_result.get("local_payment_id")
|
||||
transaction_id = payment_result.get("transaction_id")
|
||||
method_title = settings.get_platega_method_display_title(method_code)
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"PLATEGA_PAY_BUTTON",
|
||||
"💳 Оплатить через {method}",
|
||||
).format(method=method_title),
|
||||
url=redirect_url,
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"),
|
||||
callback_data=f"check_platega_{local_payment_id}",
|
||||
)
|
||||
],
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")],
|
||||
]
|
||||
)
|
||||
|
||||
instructions_template = texts.t(
|
||||
"PLATEGA_PAYMENT_INSTRUCTIONS",
|
||||
(
|
||||
"💳 <b>Оплата через Platega ({method})</b>\n\n"
|
||||
"💰 Сумма: {amount}\n"
|
||||
"🆔 ID транзакции: {transaction}\n\n"
|
||||
"📱 <b>Инструкция:</b>\n"
|
||||
"1. Нажмите кнопку «Оплатить»\n"
|
||||
"2. Следуйте подсказкам платёжной системы\n"
|
||||
"3. Подтвердите перевод\n"
|
||||
"4. Средства зачислятся автоматически\n\n"
|
||||
"❓ Если возникнут проблемы, обратитесь в {support}"
|
||||
),
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
instructions_template.format(
|
||||
method=method_title,
|
||||
amount=settings.format_price(amount_kopeks),
|
||||
transaction=transaction_id or local_payment_id,
|
||||
support=settings.get_support_contact_display_html(),
|
||||
),
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def check_platega_payment_status(
|
||||
callback: types.CallbackQuery,
|
||||
db: AsyncSession,
|
||||
):
|
||||
try:
|
||||
local_payment_id = int(callback.data.split("_")[-1])
|
||||
except ValueError:
|
||||
await callback.answer("❌ Некорректный идентификатор платежа", show_alert=True)
|
||||
return
|
||||
|
||||
payment_service = PaymentService(callback.bot)
|
||||
|
||||
try:
|
||||
status_info = await payment_service.get_platega_payment_status(db, local_payment_id)
|
||||
except Exception as error:
|
||||
logger.exception("Ошибка проверки статуса Platega: %s", error)
|
||||
await callback.answer("⚠️ Ошибка проверки статуса", show_alert=True)
|
||||
return
|
||||
|
||||
if not status_info:
|
||||
await callback.answer("⚠️ Платёж не найден", show_alert=True)
|
||||
return
|
||||
|
||||
payment = status_info.get("payment")
|
||||
status = status_info.get("status")
|
||||
is_paid = status_info.get("is_paid")
|
||||
|
||||
language = "ru"
|
||||
user = getattr(payment, "user", None)
|
||||
if user and getattr(user, "language", None):
|
||||
language = user.language
|
||||
|
||||
texts = get_texts(language)
|
||||
|
||||
if is_paid:
|
||||
await callback.answer(texts.t("PLATEGA_PAYMENT_ALREADY_CONFIRMED", "✅ Платёж уже зачислен"), show_alert=True)
|
||||
else:
|
||||
await callback.answer(
|
||||
texts.t("PLATEGA_PAYMENT_STATUS", "Текущий статус платежа: {status}").format(status=status),
|
||||
show_alert=True,
|
||||
)
|
||||
@@ -0,0 +1,104 @@
|
||||
import logging
|
||||
from aiogram import types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.keyboards.inline import get_back_keyboard, get_payment_methods_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.states import BalanceStates
|
||||
from app.utils.decorators import error_handler
|
||||
from app.external.telegram_stars import TelegramStarsService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_stars_payment(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.TELEGRAM_STARS_ENABLED:
|
||||
await callback.answer("❌ Пополнение через Stars временно недоступно", show_alert=True)
|
||||
return
|
||||
|
||||
# Формируем текст сообщения в зависимости от настройки
|
||||
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
|
||||
message_text = (
|
||||
f"⭐ <b>Пополнение через Telegram Stars</b>\n\n"
|
||||
f"Выберите сумму пополнения или введите вручную:"
|
||||
)
|
||||
else:
|
||||
message_text = texts.TOP_UP_AMOUNT
|
||||
|
||||
# Создаем клавиатуру
|
||||
keyboard = get_back_keyboard(db_user.language)
|
||||
|
||||
# Если включен быстрый выбор суммы и не отключены кнопки, добавляем кнопки
|
||||
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
|
||||
from .main import get_quick_amount_buttons
|
||||
quick_amount_buttons = get_quick_amount_buttons(db_user.language, db_user)
|
||||
if quick_amount_buttons:
|
||||
# Вставляем кнопки быстрого выбора перед кнопкой "Назад"
|
||||
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
|
||||
|
||||
await callback.message.edit_text(
|
||||
message_text,
|
||||
reply_markup=keyboard
|
||||
)
|
||||
|
||||
await state.set_state(BalanceStates.waiting_for_amount)
|
||||
await state.update_data(payment_method="stars")
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_stars_payment_amount(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
amount_kopeks: int,
|
||||
state: FSMContext
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.TELEGRAM_STARS_ENABLED:
|
||||
await message.answer("⚠️ Оплата Stars временно недоступна")
|
||||
return
|
||||
|
||||
try:
|
||||
amount_rubles = amount_kopeks / 100
|
||||
stars_amount = TelegramStarsService.calculate_stars_from_rubles(amount_rubles)
|
||||
stars_rate = settings.get_stars_rate()
|
||||
|
||||
payment_service = PaymentService(message.bot)
|
||||
invoice_link = await payment_service.create_stars_invoice(
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=f"Пополнение баланса на {texts.format_price(amount_kopeks)}",
|
||||
payload=f"balance_{db_user.id}_{amount_kopeks}"
|
||||
)
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="⭐ Оплатить", url=invoice_link)],
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]
|
||||
])
|
||||
|
||||
await message.answer(
|
||||
f"⭐ <b>Оплата через Telegram Stars</b>\n\n"
|
||||
f"💰 Сумма: {texts.format_price(amount_kopeks)}\n"
|
||||
f"⭐ К оплате: {stars_amount} звезд\n"
|
||||
f"📊 Курс: {stars_rate}₽ за звезду\n\n"
|
||||
f"Нажмите кнопку ниже для оплаты:",
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания Stars invoice: {e}")
|
||||
await message.answer("⚠️ Ошибка создания платежа")
|
||||
@@ -0,0 +1,59 @@
|
||||
import logging
|
||||
from aiogram import types
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.keyboards.inline import get_back_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
from app.utils.decorators import error_handler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_tribute_payment(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.TRIBUTE_ENABLED:
|
||||
await callback.answer("❌ Оплата картой временно недоступна", show_alert=True)
|
||||
return
|
||||
|
||||
try:
|
||||
from app.services.tribute_service import TributeService
|
||||
|
||||
tribute_service = TributeService(callback.bot)
|
||||
payment_url = await tribute_service.create_payment_link(
|
||||
user_id=db_user.telegram_id,
|
||||
amount_kopeks=0,
|
||||
description="Пополнение баланса VPN"
|
||||
)
|
||||
|
||||
if not payment_url:
|
||||
await callback.answer("❌ Ошибка создания платежа", show_alert=True)
|
||||
return
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="💳 Перейти к оплате", url=payment_url)],
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]
|
||||
])
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"💳 <b>Пополнение банковской картой</b>\n\n"
|
||||
f"• Введите любую сумму от 100₽\n"
|
||||
f"• Безопасная оплата через Tribute\n"
|
||||
f"• Мгновенное зачисление на баланс\n"
|
||||
f"• Принимаем карты Visa, MasterCard, МИР\n\n"
|
||||
f"• 🚨 НЕ ОТПРАВЛЯТЬ ПЛАТЕЖ АНОНИМНО!\n\n"
|
||||
f"Нажмите кнопку для перехода к оплате:",
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания Tribute платежа: {e}")
|
||||
await callback.answer("❌ Ошибка создания платежа", show_alert=True)
|
||||
|
||||
await callback.answer()
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user