Compare commits
912 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b377c52fd9 | |||
| 7bbf9fbc63 | |||
| 31e3ccd24c | |||
| 0d0646770d | |||
| 17732a0370 | |||
| cd8be32671 | |||
| afea054c8f | |||
| f321ded9c0 | |||
| 3fce64858c | |||
| 1ab1ff90bf | |||
| 719664208e | |||
| 6524f66da2 | |||
| 17ac3da3c4 | |||
| e85c40f8cd | |||
| ecde2fb8f0 | |||
| 99648a956e | |||
| 2478ff7c3d | |||
| 2385814d77 | |||
| 51dfc3a1a2 | |||
| 443a826402 | |||
| 06db393488 | |||
| 0bcb804118 | |||
| 735e16afeb | |||
| a88e3c80ad | |||
| 1110d0c781 | |||
| 62e7ecba01 | |||
| c905fa6000 | |||
| 768e0b6a73 | |||
| 83efc214fe | |||
| 29e177d396 | |||
| 2fbdbf5ab0 | |||
| 422844d78d | |||
| f37eb9a1bd | |||
| 1c38b31e60 | |||
| 43dd0fd92c | |||
| a506c6be00 | |||
| ff7b190527 | |||
| 527c5b4498 | |||
| bada41ecd6 | |||
| cf60ae2967 | |||
| 47c7d45793 | |||
| 4ab5928b61 | |||
| fb857d792b | |||
| 59080f7392 | |||
| c619dbcae2 | |||
| 91de6d03fc | |||
| 1fc04d842f | |||
| e22beb7229 | |||
| 74999fe99d | |||
| 134e7fb0e1 | |||
| c743fc81a5 | |||
| 579e4f2a69 | |||
| b9b695799c | |||
| 5cf19c76e6 | |||
| eafb243882 | |||
| 5ed9a0d4fb | |||
| 63e1127353 | |||
| ab4661b5c6 | |||
| 52bf2a9589 | |||
| 68d2350dfd | |||
| 7d512d214a | |||
| 2cde38c63b | |||
| 24dc8d2a5e | |||
| 1522d35f2d | |||
| 2b0d8a2a88 | |||
| 9217f4116f | |||
| 2e45a93bd7 | |||
| d77fd81e16 | |||
| bdb8cab1c9 | |||
| d394565fe9 | |||
| 122d12db20 | |||
| 2071a680d3 | |||
| e4b4a54797 | |||
| 59c54c9b39 | |||
| 0d2b1dfdc9 | |||
| 605f202191 | |||
| be787a85bf | |||
| ff41ea9abb | |||
| cfbcc3082f | |||
| 2ad893badf | |||
| e78177b2fc | |||
| daa472570c | |||
| db7b6734fd | |||
| c0e0756b9a | |||
| 5b45d4354f | |||
| fb2773fee4 | |||
| ae7feeb726 | |||
| 70568f82c5 | |||
| 2c3ffc8c8a | |||
| 6f87563789 | |||
| 7005052156 | |||
| 29ae7089aa | |||
| 1068c1387a | |||
| d31632534b | |||
| 3272b4bb05 | |||
| dd177101f7 | |||
| 36571c4275 | |||
| 66f8577448 | |||
| 9ca3320a02 | |||
| 9ed4f086b0 | |||
| da855a7c89 | |||
| 7be404b918 | |||
| b71e58c8d2 | |||
| 1696e6f884 | |||
| 7093d368d3 | |||
| 905cea68b4 | |||
| dc5442223d | |||
| 3b03c253cc | |||
| db79cc9eb0 | |||
| 1b94d9e700 | |||
| 39a7c92cd4 | |||
| 81ebec676c | |||
| 25ea5c60fd | |||
| 29877fc93b | |||
| 5986c00fab | |||
| ecc4a6147d | |||
| 16bc1d4198 | |||
| 0f814be1b7 | |||
| 97179360c0 | |||
| 2aa5927433 | |||
| 1c696c69e3 | |||
| b531959982 | |||
| ead0fc99d3 | |||
| c09ae7436c | |||
| 1ea76575ce | |||
| 25447edc9e | |||
| a59858227f | |||
| ccc2f4efec | |||
| a18f6caa9b | |||
| 92eaf45311 | |||
| 61cf495fc5 | |||
| 0c545490b6 | |||
| 2d5afe5d75 | |||
| 50dc5a0fd1 | |||
| 4dc8b4c091 | |||
| 4db9e85062 | |||
| fca8d6da97 | |||
| 4fe67a9c74 | |||
| 30a1a31978 | |||
| c2b68e1afa | |||
| cea7260a85 | |||
| e3c0caabcf | |||
| aeaa4f8e0d | |||
| e226ac8637 | |||
| 85403da528 | |||
| bdd873382c | |||
| 9d750d9eb0 | |||
| 87b83f59c6 | |||
| 8ff6f99229 | |||
| 94da3c35b0 | |||
| d35a8bb74c | |||
| c635d88764 | |||
| 5009703676 | |||
| e88a5989b6 | |||
| 02747381dc | |||
| e74fda954c | |||
| 8587f03f67 | |||
| 4707cdf60c | |||
| 0879b8b218 | |||
| 1d91382b8e | |||
| 3768b18a39 | |||
| 1eeeb39779 | |||
| 570af82dfd | |||
| bc3893b934 | |||
| 16d91638bc | |||
| eb18b3a0f9 | |||
| a8e2b62f4b | |||
| fb8d2b3ee4 | |||
| 2321667ecb | |||
| 113304b212 | |||
| 0300044b00 | |||
| 931abfe7a5 | |||
| 1d96f80f60 | |||
| 4e50419171 | |||
| 7208a52c94 | |||
| 63fdfe4a42 | |||
| e0e2edf816 | |||
| 522a8779d6 | |||
| be32010d63 | |||
| 7e920fa30f | |||
| 1b376baeca | |||
| 91a756a33e | |||
| 970dc549df | |||
| 65120f0bad | |||
| 9cb559ff39 | |||
| 8f1882f24c | |||
| 8542a39305 | |||
| 646ac4cfa1 | |||
| abdf296767 | |||
| a1b6d9bb61 | |||
| cf19e4e1f7 | |||
| 35412e9f21 | |||
| 6aed7d355b | |||
| 9c08ce6948 | |||
| 862352139e | |||
| d465ccb3ac | |||
| b57f185258 | |||
| ffbb3fb8be | |||
| f01dbff000 | |||
| 31adcfded4 | |||
| 78f963bf5e | |||
| 357d94d1b0 | |||
| 0fb4a2c235 | |||
| 2f7184627a | |||
| d55e9db62a | |||
| 57adfaf4f3 | |||
| 4165eaea7a | |||
| 3b5d5a18a1 | |||
| eef41c4bca | |||
| 987c3c93c2 | |||
| 7d24e8d704 | |||
| 819f09a68e | |||
| 2f9d00343b | |||
| 9b7ac47f16 | |||
| 0d5638f778 | |||
| 7836720db3 | |||
| dcb90d6139 | |||
| 96c420e917 | |||
| 9d63635502 | |||
| c4c5d330be | |||
| 977950b97f | |||
| 6f6b9fa039 | |||
| 6713921887 | |||
| 2d42152f54 | |||
| 08ca947b2b | |||
| b04157c913 | |||
| f284351c51 | |||
| b607993854 | |||
| 246659032d | |||
| 3dc72b00e7 | |||
| 033d0da5e0 | |||
| 991f0b43e1 | |||
| c9524cb703 | |||
| 76d4a2124c | |||
| 4fa230c07f | |||
| d580a78403 | |||
| 312cc728a9 | |||
| b1820c651d | |||
| 0c284b9e99 | |||
| 72170b35f5 | |||
| 9058a9c5d3 | |||
| be0934c5e5 | |||
| cab1946382 | |||
| fd247bc4f4 | |||
| adb39c6ef4 | |||
| 48eaa6b072 | |||
| 972614511f | |||
| 34aec0323b | |||
| 931eeb3568 | |||
| 2628012097 | |||
| 7f899a7e41 | |||
| 6dbbe5950e | |||
| f93c51a677 | |||
| da11ec6f94 | |||
| 23d1830644 | |||
| e3d8d21b66 | |||
| 2c12a4773c | |||
| 7f60196033 | |||
| b59c581e91 | |||
| 81505c8c1d | |||
| d6a49e8331 | |||
| 7ecd95aec0 | |||
| 1471320606 | |||
| 3d0b874cb4 | |||
| 5d173c806a | |||
| 6d167d2922 | |||
| 173cc374bb | |||
| 960aa44b00 | |||
| 54a19a9c50 | |||
| f6f330db4a | |||
| aa36549bb3 | |||
| 78209c8623 | |||
| cddb8d6332 | |||
| 092b9f63b2 | |||
| 7dd67e36b3 | |||
| 565c08366b | |||
| 34b5a9ab3a | |||
| 181ef1501b | |||
| cd6913cb84 | |||
| 84357a1e87 | |||
| 3bec6620b6 | |||
| 3bbcc1b560 | |||
| b8662b8bf6 | |||
| 63e4296197 | |||
| 3cbe09ddd5 | |||
| 31c67d1565 | |||
| 95ba739958 | |||
| e39c358d5c | |||
| a12ffb1d6c | |||
| 6d9bd9915c | |||
| d97c8531a3 | |||
| 94ed282381 | |||
| fec374edba | |||
| 58d899aab8 | |||
| 1bc2581669 | |||
| b0273dc8ae | |||
| 948e4791f4 | |||
| a49e52cc92 | |||
| 6dc5879ffa | |||
| aa7e461c44 | |||
| 49db5f5eed | |||
| c6bedc6a06 | |||
| 4259ba1cb5 | |||
| 40d2ec6718 | |||
| 5724906517 | |||
| 1099c5224c | |||
| bd46b4cf6d | |||
| a232d21edd | |||
| dbe247ba6f | |||
| c3c2b8137b | |||
| 87bf65c809 | |||
| 4e12ab3458 | |||
| 25b853d629 | |||
| e42bddb868 | |||
| 9644135dd7 | |||
| 59d4b353a6 | |||
| f925efbfb4 | |||
| 319941d33a | |||
| a39e3554d8 | |||
| 382e29d3dd | |||
| 05d1ae0560 | |||
| 9a27e6db31 | |||
| a1623d94b1 | |||
| f83ff26332 | |||
| fe03b587db | |||
| afd7b6d7ec | |||
| f89e326a19 | |||
| 0866c2ea4b | |||
| 147ef6b22b | |||
| 76ba19da17 | |||
| 90fb0a21e2 | |||
| 684f286fcd | |||
| d2bbeb8624 | |||
| 72d5bae531 | |||
| 6d468e9ada | |||
| 355fef846e | |||
| 424fff4ac2 | |||
| d04f2fc718 | |||
| 56fffc2415 | |||
| c27f144b76 | |||
| 34bb87c7ba | |||
| 24edfb6c3f | |||
| 824d54b7dc | |||
| 71082f436c | |||
| 4dd81702ce | |||
| 4f76f53d55 | |||
| 048d208bc1 | |||
| 344852b852 | |||
| 78a7eafcb6 | |||
| 5a7b3d5962 | |||
| cefdfc54cc | |||
| b6cf361737 | |||
| 4f20e0e4cb | |||
| d87fb47e88 | |||
| 82958801b5 | |||
| d071269b8c | |||
| 18f31c565c | |||
| 2f88b07f05 | |||
| c8ecec47a0 | |||
| 06feb3fff5 | |||
| f7f8ea87cf | |||
| e99f3d9a37 | |||
| 07ebc435cf | |||
| 335be66980 | |||
| c805cfd6d8 | |||
| 004dac5b7e | |||
| 7e9cc530e7 | |||
| 69b824572a | |||
| ab4785f8df | |||
| 1642be8bd6 | |||
| 8175bc8bfe | |||
| 4660ca5756 | |||
| 6bf41a72d0 | |||
| 6d0b003591 | |||
| 334db53868 | |||
| 76b1f9b036 | |||
| f0cdd5dc90 | |||
| d9cda3a6d6 | |||
| 51392d1918 | |||
| 89bfdc8ed6 | |||
| 0225fa155b | |||
| fd410096ea | |||
| b5853ec3b6 | |||
| 2f19c76357 | |||
| 6658af6268 | |||
| fad77f8c80 | |||
| 172924df0e | |||
| 7ff73e8492 | |||
| ce554cb2a8 | |||
| 165d25ef5f | |||
| 5ed3780f83 | |||
| a0d40ad432 | |||
| 3e69efe589 | |||
| 015c2da297 | |||
| 2b91808b0c | |||
| b93240393f | |||
| 89341baa62 | |||
| cbe630cab0 | |||
| 9de34900a2 | |||
| aec04f0085 | |||
| 0fe3c217f7 | |||
| 958ec489a2 | |||
| df9985802b | |||
| c66849db10 | |||
| 8a1da85f3e | |||
| 0335f40b47 | |||
| 8ac1183670 | |||
| bcc761f9d3 | |||
| 1eb4e18c17 | |||
| 056c13bc23 | |||
| 2bdb7643f8 | |||
| 8b8f1b91f3 | |||
| 5ed2f0c958 | |||
| 454dc9321b | |||
| de91d3282f | |||
| e0bedc8e78 | |||
| 4a48818bc3 | |||
| ff7388766c | |||
| 6b36dc4df1 | |||
| fe847e35f0 | |||
| 4c2cb63cf9 | |||
| d3c994083e | |||
| cce3b0c13b | |||
| 6c208581d9 | |||
| c307278231 | |||
| 9eab802000 | |||
| 13ea3768b5 | |||
| ed5a92ab96 | |||
| 48265f1cd4 | |||
| 3b9568fcc1 | |||
| 79cfcbcece | |||
| 6d5aceb4ca | |||
| 905dbcc779 | |||
| f33dfdf031 | |||
| 59cd74d307 | |||
| 90209ebef1 | |||
| ab43e74ab7 | |||
| 4244962337 | |||
| ba79d03e38 | |||
| 8e4e2ddd1a | |||
| 38853cdd5a | |||
| f837c0c244 | |||
| 8a7b9cc651 | |||
| 3bf31055e7 | |||
| 3c5bf4fa22 | |||
| 4990ddf9e4 | |||
| de00612965 | |||
| 43f5629c8c | |||
| 448799a3ed | |||
| b2b5f104b5 | |||
| 5f71eaa926 | |||
| 54155b5649 | |||
| 8dc778654d | |||
| 5175cccab6 | |||
| 39ae095d9b | |||
| db3ac254ba | |||
| bff9ebf078 | |||
| 42ddadec5b | |||
| c6c1599e14 | |||
| 77b2d645c5 | |||
| 3875335cd7 | |||
| 0a53b85b8a | |||
| 82b6a8bf70 | |||
| 2a72deadd6 | |||
| afefcc9c07 | |||
| 82c79c1306 | |||
| 0e1296e0ea | |||
| 9dd6b54c6e | |||
| 8a5710aff3 | |||
| ce36fba54f | |||
| f601bedb48 | |||
| 801921ff74 | |||
| 67da390371 | |||
| d400cd7b49 | |||
| bf0ba22790 | |||
| f82a713110 | |||
| fedcf2569a | |||
| 1882909b3e | |||
| 226d3f2766 | |||
| 6982d27378 | |||
| 27ef75214e | |||
| 13dba5a303 | |||
| d7f91c8358 | |||
| ee9f0b7382 | |||
| e16eba10d9 | |||
| 1771cc4d13 | |||
| 3bda0a2001 | |||
| 877b1cde11 | |||
| 5faf7015ac | |||
| 55f386d7e8 | |||
| e6a310dc32 | |||
| ccd8f86e96 | |||
| b6d4373933 | |||
| 266620904d | |||
| 479af5741a | |||
| 79c110ff41 | |||
| 3d78974af7 | |||
| 57c5c679ee | |||
| 2781236011 | |||
| 4a002b7db1 | |||
| 8b2668087b | |||
| 3ec9e71de7 | |||
| da7a9cc3c5 | |||
| b5471b7720 | |||
| 6a4ce3dd38 | |||
| df086b09c7 | |||
| 01132a7bc7 | |||
| 182667ecb8 | |||
| c8f4cca340 | |||
| ac9fcd8d30 | |||
| c08c903e8f | |||
| 69bb399b63 | |||
| 463c5385d6 | |||
| 41dfe39518 | |||
| f44c9b6903 | |||
| 1f35d45dc6 | |||
| ef8f6625bf | |||
| 7101555da0 | |||
| e15b18fb41 | |||
| b80eeea089 | |||
| cb61014d9c | |||
| 5b3353433b | |||
| f1d45343e9 | |||
| b40a812f3a | |||
| ac00434645 | |||
| 964c33c772 | |||
| ec875837a4 | |||
| db0e169a41 | |||
| a6dcf26c20 | |||
| 5081debee7 | |||
| 0ceff44c30 | |||
| 136f29c1eb | |||
| d0eab3f7aa | |||
| d7ad9d7033 | |||
| 1a87d438fe | |||
| aec01ce0d4 | |||
| a33a893d1a | |||
| 37c9b931ca | |||
| 22e7f150b3 | |||
| 688882237f | |||
| c14d7ab0af | |||
| e12cc9f248 | |||
| 6ff0460607 | |||
| 8d5a002996 | |||
| 31bdf8a0ae | |||
| 1364158e6c | |||
| d7931a2afa | |||
| b032c8f354 | |||
| 1306c24fa3 | |||
| 38deb70f81 | |||
| c1e015fb6e | |||
| 0730173e5b | |||
| 968f18b6e4 | |||
| 7eea35f111 | |||
| 6920e3a0fb | |||
| fddf8ef5eb | |||
| ad268329be | |||
| 1804c28f05 | |||
| f967c29bd7 | |||
| 06a00e367c | |||
| abaf279533 | |||
| 6d4430c639 | |||
| 911df7a05c | |||
| f106ce8216 | |||
| dcff6947dd | |||
| 4966e39eb9 | |||
| 4abb8cb1a3 | |||
| 04f4e6bf6e | |||
| 3d1fbc70f8 | |||
| 3089c1704b | |||
| 20eff6170f | |||
| 038c34e52a | |||
| 77f1a764d5 | |||
| 641da949a9 | |||
| 3f0b24c1ec | |||
| c34fdd10a0 | |||
| 72b5305b87 | |||
| 099391eb5f | |||
| 322d457652 | |||
| 5b722c5210 | |||
| a80a85c2a4 | |||
| f84885cc8a | |||
| 12898b7eab | |||
| 20a6fa1bcf | |||
| 94199413c2 | |||
| 826accba51 | |||
| 1cc687ac15 | |||
| e4bb0430fb | |||
| 603b9a1f46 | |||
| 557af5994d | |||
| 808818ca2b | |||
| b563796091 | |||
| 6a3e9d92b5 | |||
| cda2392411 | |||
| 04419fdff7 | |||
| 713146dd6b | |||
| 7d41ab44be | |||
| 98f6f93487 | |||
| 3752b7b067 | |||
| 2f33e55144 | |||
| c0b282a189 | |||
| e1bcb1ba91 | |||
| 3d68db0a51 | |||
| 8d7f0eea0f | |||
| 4aaf0ddd25 | |||
| db2f0c93f2 | |||
| 3f8e8993b2 | |||
| e453521098 | |||
| 8d3cd50098 | |||
| f80912e444 | |||
| 484d2f7e34 | |||
| 842fb697e6 | |||
| 3ac3a92e26 | |||
| 7648707ca2 | |||
| 7e466ef464 | |||
| 28321df4d2 | |||
| 6adf70b2da | |||
| 2d204275da | |||
| ebee8348ca | |||
| 06954c1711 | |||
| 5e04e2a020 | |||
| 08d69fb47f | |||
| 3306e02902 | |||
| 14dceaa39f | |||
| 5442f288d4 | |||
| 5bf4aeb31e | |||
| 7356921eeb | |||
| f24337fb41 | |||
| 69a38dad25 | |||
| aa3459b846 | |||
| 4d695be7d5 | |||
| b8fcbc7661 | |||
| 96042782d9 | |||
| a625eaae4f | |||
| 869fe06831 | |||
| a5fbd7400f | |||
| 995d66483b | |||
| 5c77bd7a0f | |||
| 04697fd4cb | |||
| c9f2dffabf | |||
| fe4e6acb53 | |||
| e24b911283 | |||
| b551def340 | |||
| 5e9a462261 | |||
| 3a3bd9d499 | |||
| 75dbd2b4fc | |||
| b4ef52caa4 | |||
| ae99358ae9 | |||
| 08bea704de | |||
| 18e2e7841a | |||
| 652b6dabde | |||
| c9a9816daa | |||
| 49c0f3fc10 | |||
| cb43acab31 | |||
| f59b215645 | |||
| 3efa24bab3 | |||
| bd2e93a6a5 | |||
| 978f68e7be | |||
| 28fc36dca4 | |||
| 1660b24f98 | |||
| acf27a1023 | |||
| ce82c2c009 | |||
| e6ebc6722d | |||
| 02e5401327 | |||
| c3bb63ffed | |||
| 88369eec50 | |||
| 83ca51cd5b | |||
| f9dad615ee | |||
| ba049ca017 | |||
| 585baaf63c | |||
| 04197817fe | |||
| b2ee6c766a | |||
| d35ee58aa6 | |||
| 815a1d9136 | |||
| b7775b72dc | |||
| ba54819f9c | |||
| 266340aad1 | |||
| 8f434525eb | |||
| efa1b11db5 | |||
| d0ce193edb | |||
| 92d872236f | |||
| a11f492801 | |||
| c8162505ed | |||
| 076290e0c1 | |||
| 2f5674fcd7 | |||
| b9058e115a | |||
| 673afccb8c | |||
| 1badb39c49 | |||
| 23ff40cd2c | |||
| bf72f241d8 | |||
| 12ae871653 | |||
| 8a362db783 | |||
| b1e2146254 | |||
| 68bc8eb57c | |||
| 9957259881 | |||
| b3f3eba575 | |||
| a798f1143e | |||
| cb5126aff8 | |||
| 8b35428055 | |||
| 5424d8c314 | |||
| df7411138e | |||
| 4545bef7ea | |||
| d7eb1e776a | |||
| f8fc382143 | |||
| e67b8e448e | |||
| bca8bab433 | |||
| 2fd0f6aa4e | |||
| 864a4ed700 | |||
| 2879996455 | |||
| 4b4fced442 | |||
| 8859e73890 | |||
| d79866819c | |||
| 5a62f91ca2 | |||
| def594bbb5 | |||
| 641ff86bf6 | |||
| 2e59330e95 | |||
| 3c96c2affd | |||
| 541f64d5bc | |||
| e82a1ccf6d | |||
| 015be30a27 | |||
| 6817b9e256 | |||
| 59248011c2 | |||
| 39d007ff3e | |||
| 94b211e2a7 | |||
| c84dbf82fc | |||
| 9ad684c8c9 | |||
| d147be0316 | |||
| 9281523e96 | |||
| fd3466b75c | |||
| dcfd54a7cb | |||
| 5c2e5dfaab | |||
| df112f3659 | |||
| a90d2d9367 | |||
| 5c34656476 | |||
| 8a8337f538 | |||
| 42b6c80a48 | |||
| b30c73c300 | |||
| 363ccce56d | |||
| 0005d59da1 | |||
| 38c6adfdb4 | |||
| 4fb72ae6e3 | |||
| 05bcac502e | |||
| 769d3a0b30 | |||
| 5ffce175dc | |||
| 1a2f0fcbe8 | |||
| fd1e728396 | |||
| ec41d65501 | |||
| 5212877801 | |||
| bc9003c336 | |||
| fcdeff1ee5 | |||
| bcc35d6e22 | |||
| b850e81897 | |||
| 4d9e42c3f1 | |||
| 834a0478ae | |||
| 0e968987fb | |||
| acd2cff9ca | |||
| 69dbd6a2df | |||
| 497a8ee5b5 | |||
| dd8d7f6920 | |||
| b9089e693f | |||
| b815abf2b1 | |||
| 95a32e8574 | |||
| cd04f3b622 | |||
| 6a4140e3e2 | |||
| f80b058380 | |||
| 6a61b09575 | |||
| 759bfe1bdb | |||
| 0936d4a7f6 | |||
| c7bebae14a | |||
| 8ee287f8cd | |||
| 6f99b83c61 | |||
| 1a3c6fafa3 | |||
| be2ec091a6 | |||
| d4dc0b76ba | |||
| 2dfd0e6452 | |||
| 680c22c017 | |||
| 8c9efd5127 | |||
| 4663097a24 | |||
| dc51a55c98 | |||
| 275f249bbd | |||
| 7a9264b173 | |||
| 32d58b04b9 | |||
| 7ca96195a7 | |||
| 5752b5e7c6 | |||
| e6f577697b | |||
| f4a776319e | |||
| 2649e12f64 | |||
| 4a5cacda38 | |||
| 79161eaae4 | |||
| 289cbe966e | |||
| 7ccfb66690 | |||
| 536525c9c0 | |||
| 4186159a61 | |||
| 6349b2f442 | |||
| bfbefeb1e2 | |||
| f9f07f360c | |||
| 770b31d3d0 | |||
| ae710f41fc | |||
| f86b8614b1 | |||
| 61b2fcc2aa | |||
| 928e3e98f8 | |||
| 7dc5e4ab94 | |||
| 5ebe1072c9 | |||
| 20727b1017 | |||
| f4eeb9a503 | |||
| 1f664a9083 | |||
| 330d1cb6fe | |||
| 8e53b81b3d | |||
| 69ca37bc6e | |||
| 26daf9f6c8 | |||
| 34aae0dd26 | |||
| 0551a6e23c | |||
| 92cc602892 | |||
| 555b887952 | |||
| 848c9f71a2 | |||
| 4477e03d83 | |||
| 9ba61a0879 | |||
| d7f05ae409 | |||
| bf2d5e48e5 | |||
| 93bf21e55b | |||
| fea44f5ad4 | |||
| 923b36a8b9 | |||
| a7fea86c99 | |||
| fbe56c15ac | |||
| 213f82b9a4 | |||
| d72ea6b7f9 | |||
| c507634398 | |||
| c9ea2b15e9 | |||
| ab5313a381 | |||
| 351d714f2d | |||
| d52c87b2b7 | |||
| d9f9f3dca1 | |||
| 44d46feb0a | |||
| 9e78509284 | |||
| f4ab174d32 | |||
| fc65e2de4c | |||
| ba335fe784 | |||
| 5214f55f46 | |||
| a6849242ff | |||
| 9d5329d9d1 | |||
| bbd353ff38 | |||
| 11d3e637c1 | |||
| 0ba1127469 | |||
| 25478ced20 | |||
| 57b95671ea | |||
| eecf2b4183 | |||
| 5a97fc2fa1 | |||
| 26b486cdd9 | |||
| ba05c5ce92 | |||
| 372d628908 | |||
| ceac29d5e3 | |||
| 3ee108fce8 | |||
| de541ea1c3 | |||
| 6d65e15266 | |||
| aa7d98630d | |||
| 8b77cdae2c | |||
| c93dbec7a0 | |||
| fa21549cac | |||
| c10d6780ba | |||
| 770f19e846 | |||
| dbb9757a3c | |||
| 77456efb75 | |||
| c165cca323 | |||
| 6970340e62 | |||
| 9217352685 | |||
| a539d69854 | |||
| e96fe1ecd8 | |||
| 5499ad62dc | |||
| 6495384bcf | |||
| 5c55662e2c | |||
| b78c01cae9 | |||
| 2405dc5c1b | |||
| da1cc4fe5a | |||
| 000b0c0592 | |||
| 3a400d9f8b | |||
| 2f0a9dc4f3 | |||
| 3a361628aa | |||
| 833df518d0 | |||
| 084a3cd16f | |||
| 694aeccc31 | |||
| 5f01783dcb | |||
| c53e9af744 | |||
| 220196fb7a | |||
| e9b4d8e444 | |||
| d86c29a5d3 | |||
| 8510597ddb | |||
| f8edfd7746 | |||
| 776fc3aadc | |||
| b85646af85 | |||
| e0f2243f49 | |||
| ab981dce0d | |||
| 6f871edc9d | |||
| 3d3bb3badb | |||
| 6deab7dd8c | |||
| 861ffe5424 | |||
| 06ccf4b275 | |||
| 7ed91b13eb | |||
| 319f49435a | |||
| 23761a74f2 | |||
| 8620aaedb1 | |||
| aaffc26a90 | |||
| ef450955e6 | |||
| 5e404cc082 | |||
| c669c5951a | |||
| b68c1c751a | |||
| 0c0e219691 | |||
| 8eb6a8c460 | |||
| bc52fd2711 | |||
| 6da408fe15 | |||
| 15fe45d113 | |||
| 3e26832e74 | |||
| 1afcd84e0e |
+113
-11
@@ -13,6 +13,15 @@ SUPPORT_USERNAME=@support
|
||||
# Имя пользователя бота (опционально, автоопределяется)
|
||||
# BOT_USERNAME=
|
||||
|
||||
# ===== СЕТЬ И ПРОКСИ =====
|
||||
# URL SOCKS5 прокси-сервера для маршрутизации трафика бота к Telegram API
|
||||
# Формат: socks5://user:password@host:port или socks5://host:port
|
||||
# PROXY_URL=socks5://127.0.0.1:1080
|
||||
|
||||
# Альтернативный URL сервера Telegram Bot API (для регионов где api.telegram.org заблокирован)
|
||||
# Примеры: Cloudflare Worker, self-hosted telegram-bot-api (tdlib), любой совместимый прокси
|
||||
# TELEGRAM_API_URL=https://your-telegram-proxy.workers.dev
|
||||
|
||||
# ===== СИСТЕМА ПОДДЕРЖКИ =====
|
||||
# Включить меню поддержки в интерфейсе
|
||||
SUPPORT_MENU_ENABLED=true
|
||||
@@ -194,6 +203,9 @@ REMNAWAVE_WEBHOOK_PATH=/remnawave-webhook
|
||||
# Сгенерируйте: openssl rand -hex 32
|
||||
# ВАЖНО: этот же секрет указывается в панели Remnawave при создании вебхука
|
||||
REMNAWAVE_WEBHOOK_SECRET=
|
||||
# Уведомления администраторам о потере/восстановлении связи с нодами
|
||||
# false = не отправлять события node.connection_lost / node.connection_restored
|
||||
REMNAWAVE_WEBHOOK_NOTIFY_NODE_CONNECTION_STATUS=true
|
||||
|
||||
# ===== УВЕДОМЛЕНИЯ ОТ ВЕБХУКОВ (что получают пользователи) =====
|
||||
# Глобальный переключатель уведомлений пользователям от вебхуков
|
||||
@@ -242,6 +254,18 @@ WEBHOOK_NOTIFY_DEVICES=true
|
||||
# - Подходит для продажи готовых пакетов услуг
|
||||
SALES_MODE=tariffs
|
||||
|
||||
# Управление сменой тарифа (для SALES_MODE=tariffs)
|
||||
# UPGRADE / DOWNGRADE:
|
||||
# true / true = все направления разрешены
|
||||
# true / false = только повышение (на более дорогой тариф)
|
||||
# false / true = только понижение (на более дешёвый тариф)
|
||||
# false / false = смена тарифа полностью отключена
|
||||
TARIFF_SWITCH_UPGRADE_ENABLED=true
|
||||
TARIFF_SWITCH_DOWNGRADE_ENABLED=true
|
||||
|
||||
# Сброс привязанных устройств при продлении подписки (однократно при каждом продлении)
|
||||
RESET_DEVICES_ON_RENEWAL=false
|
||||
|
||||
# ===== ТРИАЛ ПОДПИСКА =====
|
||||
TRIAL_DURATION_DAYS=3
|
||||
TRIAL_TRAFFIC_LIMIT_GB=10
|
||||
@@ -369,6 +393,8 @@ REFERRAL_MINIMUM_TOPUP_KOPEKS=10000
|
||||
REFERRAL_FIRST_TOPUP_BONUS_KOPEKS=10000
|
||||
REFERRAL_INVITER_BONUS_KOPEKS=10000
|
||||
REFERRAL_COMMISSION_PERCENT=25
|
||||
# Макс. кол-во платежей реферала, с которых начисляется комиссия (0 = без лимита)
|
||||
REFERRAL_MAX_COMMISSION_PAYMENTS=0
|
||||
# Показывать раздел партнёрки в кабинете
|
||||
REFERRAL_PARTNER_SECTION_VISIBLE=true
|
||||
|
||||
@@ -489,11 +515,11 @@ YOOKASSA_WEBHOOK_PORT=8082
|
||||
YOOKASSA_MIN_AMOUNT_KOPEKS=5000
|
||||
YOOKASSA_MAX_AMOUNT_KOPEKS=1000000
|
||||
|
||||
# Быстрый выбор суммы пополнения через YooKassa
|
||||
YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED=true
|
||||
# Рекуррентные платежи YooKassa (автосохранение карты для автоплатежей)
|
||||
YOOKASSA_RECURRENT_ENABLED=false
|
||||
# true = карта сохраняется обязательно, false = пользователь решает (чекбокс на стороне YooKassa)
|
||||
YOOKASSA_RECURRENT_REQUIRED=true
|
||||
|
||||
# Отключить отображение кнопок выбора суммы пополнения (оставить только ввод вручную)
|
||||
DISABLE_TOPUP_BUTTONS=false
|
||||
# Отключить пополнение баланса через поддержку
|
||||
SUPPORT_TOPUP_ENABLED=true
|
||||
|
||||
@@ -512,6 +538,7 @@ NALOGO_STORAGE_PATH=./nalogo_tokens.json # Путь к файлу с токен
|
||||
NALOGO_QUEUE_CHECK_INTERVAL=300 # Интервал проверки очереди чеков (секунды)
|
||||
NALOGO_QUEUE_RECEIPT_DELAY=3 # Задержка между отправкой чеков (секунды)
|
||||
NALOGO_QUEUE_MAX_ATTEMPTS=10 # Максимум попыток отправки одного чека
|
||||
# NALOGO_PROXY_URL=socks5://127.0.0.1:1080 # SOCKS прокси для nalog.ru (если не задан — используется PROXY_URL)
|
||||
|
||||
# ===== НАСТРОЙКИ ОПИСАНИЙ ПЛАТЕЖЕЙ =====
|
||||
# Эти настройки позволяют изменить описания платежей,
|
||||
@@ -603,7 +630,7 @@ PLATEGA_RETURN_URL=
|
||||
PLATEGA_FAILED_URL=
|
||||
PLATEGA_CURRENCY=RUB
|
||||
# Список ID активных методов из кабинета Platega (через запятую)
|
||||
PLATEGA_ACTIVE_METHODS=2,10,11,12,13
|
||||
PLATEGA_ACTIVE_METHODS=2,11,12,13
|
||||
PLATEGA_MIN_AMOUNT_KOPEKS=100
|
||||
PLATEGA_MAX_AMOUNT_KOPEKS=100000000
|
||||
PLATEGA_WEBHOOK_PATH=/platega-webhook
|
||||
@@ -655,6 +682,84 @@ KASSA_AI_WEBHOOK_PORT=8089
|
||||
# Способ оплаты: 44 = СБП (QR), 36 = Карты РФ, 43 = SberPay
|
||||
KASSA_AI_PAYMENT_SYSTEM_ID=44
|
||||
|
||||
# ===== RIOPAY (api.riopay.online) =====
|
||||
RIOPAY_ENABLED=false
|
||||
RIOPAY_API_TOKEN=
|
||||
# Ключ для HMAC-SHA512 верификации вебхуков (если не указан, используется RIOPAY_API_TOKEN)
|
||||
RIOPAY_WEBHOOK_SECRET=
|
||||
RIOPAY_DISPLAY_NAME=RioPay
|
||||
RIOPAY_CURRENCY=RUB
|
||||
RIOPAY_MIN_AMOUNT_KOPEKS=10000
|
||||
RIOPAY_MAX_AMOUNT_KOPEKS=100000000
|
||||
RIOPAY_WEBHOOK_PATH=/riopay-webhook
|
||||
# URL для редиректа после оплаты (опционально)
|
||||
RIOPAY_SUCCESS_URL=
|
||||
RIOPAY_FAIL_URL=
|
||||
|
||||
# ===== SEVERPAY (severpay.io) =====
|
||||
SEVERPAY_ENABLED=false
|
||||
# Merchant ID
|
||||
SEVERPAY_MID=
|
||||
# Секретный токен для HMAC-SHA256
|
||||
SEVERPAY_TOKEN=
|
||||
SEVERPAY_DISPLAY_NAME=SeverPay
|
||||
SEVERPAY_CURRENCY=RUB
|
||||
SEVERPAY_MIN_AMOUNT_KOPEKS=10000
|
||||
SEVERPAY_MAX_AMOUNT_KOPEKS=10000000
|
||||
SEVERPAY_WEBHOOK_PATH=/severpay-webhook
|
||||
# URL возврата после оплаты
|
||||
# SEVERPAY_RETURN_URL=
|
||||
# Время жизни платежа в минутах (30-4320)
|
||||
SEVERPAY_LIFETIME=1440
|
||||
|
||||
# ===== PAYPEAR (api.paypear.ru) =====
|
||||
PAYPEAR_ENABLED=false
|
||||
# Shop ID для HTTP Basic Auth
|
||||
PAYPEAR_SHOP_ID=
|
||||
# Secret Key для HTTP Basic Auth
|
||||
PAYPEAR_SECRET_KEY=
|
||||
PAYPEAR_DISPLAY_NAME=PayPear
|
||||
PAYPEAR_CURRENCY=RUB
|
||||
PAYPEAR_MIN_AMOUNT_KOPEKS=10000
|
||||
PAYPEAR_MAX_AMOUNT_KOPEKS=10000000
|
||||
PAYPEAR_WEBHOOK_PATH=/paypear-webhook
|
||||
# URL возврата после оплаты
|
||||
# PAYPEAR_RETURN_URL=
|
||||
# Время жизни платежа в минутах
|
||||
PAYPEAR_PAYMENT_LIFETIME_MINUTES=60
|
||||
|
||||
# ===== ROLLYPAY (rollypay.io) =====
|
||||
ROLLYPAY_ENABLED=false
|
||||
# API ключ (X-API-Key header)
|
||||
ROLLYPAY_API_KEY=
|
||||
# Секрет для HMAC-SHA256 верификации вебхуков
|
||||
ROLLYPAY_SIGNING_SECRET=
|
||||
ROLLYPAY_DISPLAY_NAME=RollyPay
|
||||
ROLLYPAY_CURRENCY=RUB
|
||||
ROLLYPAY_MIN_AMOUNT_KOPEKS=10000
|
||||
ROLLYPAY_MAX_AMOUNT_KOPEKS=10000000
|
||||
ROLLYPAY_WEBHOOK_PATH=/rollypay-webhook
|
||||
# URL возврата после оплаты
|
||||
# ROLLYPAY_RETURN_URL=
|
||||
|
||||
# ===== AURAPAY (aurapay.tech) =====
|
||||
AURAPAY_ENABLED=false
|
||||
# API ключ (X-ApiKey header)
|
||||
AURAPAY_API_KEY=
|
||||
# UUID магазина (X-ShopId header)
|
||||
AURAPAY_SHOP_ID=
|
||||
# Секретный ключ #2 для HMAC-SHA256 верификации вебхуков
|
||||
AURAPAY_SECRET_KEY=
|
||||
AURAPAY_DISPLAY_NAME=AuraPay
|
||||
AURAPAY_CURRENCY=RUB
|
||||
AURAPAY_MIN_AMOUNT_KOPEKS=10000
|
||||
AURAPAY_MAX_AMOUNT_KOPEKS=10000000
|
||||
AURAPAY_WEBHOOK_PATH=/aurapay-webhook
|
||||
# URL возврата после оплаты
|
||||
# AURAPAY_RETURN_URL=
|
||||
# Время жизни инвойса в минутах
|
||||
AURAPAY_PAYMENT_LIFETIME_MINUTES=60
|
||||
|
||||
# ===== WATA =====
|
||||
WATA_ENABLED=false
|
||||
WATA_BASE_URL=https://api.wata.pro
|
||||
@@ -881,6 +986,9 @@ DEBUG=false
|
||||
WEBHOOK_URL=
|
||||
WEBHOOK_PATH=/webhook
|
||||
WEBHOOK_SECRET_TOKEN=
|
||||
# IP адрес сервера для setWebhook — Telegram будет использовать его напрямую без DNS резолва домена
|
||||
# Необходимо в регионах где Telegram не может резолвить домены (РФ и др.)
|
||||
# WEBHOOK_IP=
|
||||
WEBHOOK_DROP_PENDING_UPDATES=true
|
||||
WEBHOOK_MAX_QUEUE_SIZE=1024
|
||||
WEBHOOK_WORKERS=4
|
||||
@@ -921,10 +1029,4 @@ WEB_API_TOKEN_HASH_ALGORITHM=sha256
|
||||
# Логирование запросов
|
||||
WEB_API_REQUEST_LOGGING=true
|
||||
|
||||
# Внешний админ-токен (для интеграции с другими ботами/системами)
|
||||
# Токен для доступа через API другого бота
|
||||
# EXTERNAL_ADMIN_TOKEN=
|
||||
# ID бота, от которого принимается токен
|
||||
# EXTERNAL_ADMIN_TOKEN_BOT_ID=
|
||||
|
||||
MINIAPP_STATIC_PATH=miniapp
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 850 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 822 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.0 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 76 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
@@ -26,36 +26,38 @@ jobs:
|
||||
- name: Get version info
|
||||
id: version
|
||||
run: |
|
||||
echo "short_sha=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
|
||||
|
||||
SHORT_SHA=$(git rev-parse --short HEAD)
|
||||
echo "short_sha=$SHORT_SHA" >> $GITHUB_OUTPUT
|
||||
echo "build_date=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> $GITHUB_OUTPUT
|
||||
|
||||
# Определяем версию и теги
|
||||
|
||||
# Read base version from release-please manifest (single source of truth)
|
||||
BASE_VERSION=$(jq -r '."."' .release-please-manifest.json)
|
||||
|
||||
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="v3.7.0-$(git rev-parse --short HEAD)" # x-release-please-version
|
||||
VERSION="v${BASE_VERSION}-${SHORT_SHA}"
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||
echo "🚀 Собираем версию из main: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
|
||||
VERSION="v3.7.0-dev-$(git rev-parse --short HEAD)" # x-release-please-version
|
||||
VERSION="v${BASE_VERSION}-dev-${SHORT_SHA}"
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:dev,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||
echo "🧪 Собираем dev версию: $VERSION"
|
||||
else
|
||||
VERSION="v3.7.0-pr-$(git rev-parse --short HEAD)" # x-release-please-version
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:pr-$(git rev-parse --short HEAD)"
|
||||
VERSION="v${BASE_VERSION}-pr-${SHORT_SHA}"
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:pr-${SHORT_SHA}"
|
||||
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 "Коммит: $SHORT_SHA"
|
||||
echo "Теги: $TAGS"
|
||||
echo "Push: ${{ github.event_name != 'pull_request' }}"
|
||||
echo "==========================="
|
||||
|
||||
@@ -42,25 +42,28 @@ jobs:
|
||||
- name: Get version info
|
||||
id: version
|
||||
run: |
|
||||
echo "short_sha=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
|
||||
SHORT_SHA=$(git rev-parse --short HEAD)
|
||||
echo "short_sha=$SHORT_SHA" >> $GITHUB_OUTPUT
|
||||
echo "build_date=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> $GITHUB_OUTPUT
|
||||
|
||||
|
||||
# Read base version from release-please manifest (single source of truth)
|
||||
BASE_VERSION=$(jq -r '."."' .release-please-manifest.json)
|
||||
|
||||
if [[ $GITHUB_REF == refs/tags/* ]]; then
|
||||
VERSION=${GITHUB_REF#refs/tags/}
|
||||
echo "🏷️ Building release version: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/main ]]; then
|
||||
VERSION="v3.7.0-$(git rev-parse --short HEAD)" # x-release-please-version
|
||||
VERSION="v${BASE_VERSION}-${SHORT_SHA}"
|
||||
echo "🚀 Building main version: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
|
||||
VERSION="v3.7.0-dev-$(git rev-parse --short HEAD)" # x-release-please-version
|
||||
VERSION="v${BASE_VERSION}-dev-${SHORT_SHA}"
|
||||
echo "🧪 Building dev version: $VERSION"
|
||||
else
|
||||
VERSION="v3.7.0-pr-$(git rev-parse --short HEAD)" # x-release-please-version
|
||||
VERSION="v${BASE_VERSION}-pr-${SHORT_SHA}"
|
||||
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"
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "3.23.1"
|
||||
".": "3.49.0"
|
||||
}
|
||||
|
||||
+1279
File diff suppressed because it is too large
Load Diff
+19
-32
@@ -197,28 +197,17 @@ async def create_subscription(
|
||||
### Документация кода
|
||||
|
||||
```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
|
||||
from app.services.pricing_engine import PricingEngine
|
||||
|
||||
pricing = PricingEngine.calculate_renewal_price(
|
||||
subscription=subscription,
|
||||
period_days=30,
|
||||
user=user,
|
||||
)
|
||||
# pricing.final_total — стоимость в копейках
|
||||
# pricing.original_total — цена до скидок
|
||||
# pricing.promo_group_discount — скидка промогруппы
|
||||
# pricing.promo_offer_discount — скидка промо-оффера
|
||||
```
|
||||
|
||||
### Обработка ошибок
|
||||
@@ -341,20 +330,18 @@ python main.py
|
||||
### Тестирование компонентов
|
||||
|
||||
```python
|
||||
# tests/test_subscription_service.py
|
||||
# tests/services/test_pricing_engine.py
|
||||
import pytest
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
from app.services.pricing_engine import PricingEngine
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_calculate_price():
|
||||
price = await SubscriptionService.calculate_subscription_price(
|
||||
def test_calculate_renewal_price():
|
||||
pricing = PricingEngine.calculate_renewal_price(
|
||||
subscription=mock_subscription,
|
||||
period_days=30,
|
||||
traffic_gb=100,
|
||||
devices_count=3,
|
||||
servers_count=1
|
||||
user=mock_user,
|
||||
)
|
||||
assert price > 0
|
||||
assert isinstance(price, int)
|
||||
assert pricing.final_total > 0
|
||||
assert isinstance(pricing.final_total, int)
|
||||
```
|
||||
|
||||
### Integration тесты
|
||||
|
||||
+4
-3
@@ -15,11 +15,11 @@ WORKDIR /app
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
|
||||
--mount=type=bind,source=uv.lock,target=uv.lock \
|
||||
uv sync --locked --no-dev
|
||||
uv sync --frozen --no-dev
|
||||
|
||||
FROM python:3.13-slim
|
||||
|
||||
ARG VERSION="v3.23.1" # x-release-please-version
|
||||
ARG VERSION="v3.49.0" # x-release-please-version
|
||||
ARG BUILD_DATE
|
||||
ARG VCS_REF
|
||||
|
||||
@@ -33,7 +33,8 @@ WORKDIR /app
|
||||
|
||||
COPY --chown=app:app . .
|
||||
|
||||
RUN mkdir -p logs data && chown app:app logs data
|
||||
RUN mkdir -p logs data uploads/images uploads/videos uploads/thumbnails locales && \
|
||||
chown -R app:app logs data uploads locales
|
||||
|
||||
USER app
|
||||
|
||||
|
||||
+47
-6
@@ -60,12 +60,14 @@ from app.handlers.admin import (
|
||||
welcome_text as admin_welcome_text,
|
||||
)
|
||||
from app.handlers.channel_member import register_handlers as register_channel_member_handlers
|
||||
from app.handlers.gift_activation import register_handlers as register_gift_activation_handlers
|
||||
from app.handlers.stars_payments import register_stars_handlers
|
||||
from app.middlewares.auth import AuthMiddleware
|
||||
from app.middlewares.blacklist import BlacklistMiddleware
|
||||
from app.middlewares.button_stats import ButtonStatsMiddleware
|
||||
from app.middlewares.chat_type_filter import ChatTypeFilterMiddleware
|
||||
from app.middlewares.context_binding import ContextVarsMiddleware
|
||||
from app.middlewares.display_name_restriction import DisplayNameRestrictionMiddleware
|
||||
from app.middlewares.global_error import GlobalErrorMiddleware
|
||||
from app.middlewares.logging import LoggingMiddleware
|
||||
from app.middlewares.maintenance import MaintenanceMiddleware
|
||||
@@ -95,10 +97,21 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
except Exception as e:
|
||||
logger.warning('Кеш не инициализирован', error=e)
|
||||
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
from app.bot_factory import create_bot
|
||||
|
||||
bot = Bot(token=settings.BOT_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
|
||||
bot = create_bot()
|
||||
|
||||
proxy_url = settings.get_proxy_url()
|
||||
nalogo_proxy_url = settings.get_nalogo_proxy_url()
|
||||
|
||||
if proxy_url or nalogo_proxy_url:
|
||||
from app.utils.proxy import mask_proxy_url
|
||||
|
||||
if proxy_url:
|
||||
logger.info('Proxy configured', proxy_url=mask_proxy_url(proxy_url))
|
||||
if nalogo_proxy_url:
|
||||
source = 'NALOGO_PROXY_URL' if settings.NALOGO_PROXY_URL else 'PROXY_URL (fallback)'
|
||||
logger.info('Nalogo proxy configured', proxy_url=mask_proxy_url(nalogo_proxy_url), source=source)
|
||||
|
||||
maintenance_service.set_bot(bot)
|
||||
logger.info('Бот установлен в maintenance_service')
|
||||
@@ -121,11 +134,11 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
chat_type_filter = ChatTypeFilterMiddleware()
|
||||
dp.message.middleware(chat_type_filter)
|
||||
dp.callback_query.middleware(chat_type_filter)
|
||||
dp.message.middleware(LoggingMiddleware())
|
||||
dp.callback_query.middleware(LoggingMiddleware())
|
||||
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())
|
||||
blacklist_middleware = BlacklistMiddleware()
|
||||
@@ -150,8 +163,12 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
dp.message.middleware(AuthMiddleware())
|
||||
dp.callback_query.middleware(AuthMiddleware())
|
||||
dp.pre_checkout_query.middleware(AuthMiddleware())
|
||||
display_name_restriction = DisplayNameRestrictionMiddleware()
|
||||
dp.message.middleware(display_name_restriction)
|
||||
dp.callback_query.middleware(display_name_restriction)
|
||||
dp.message.middleware(SubscriptionStatusMiddleware())
|
||||
dp.callback_query.middleware(SubscriptionStatusMiddleware())
|
||||
dp.pre_checkout_query.middleware(SubscriptionStatusMiddleware())
|
||||
start.register_handlers(dp)
|
||||
menu.register_handlers(dp)
|
||||
subscription.register_handlers(dp)
|
||||
@@ -199,6 +216,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
admin_blocked_users.register_handlers(dp)
|
||||
admin_required_channels.register_handlers(dp)
|
||||
register_channel_member_handlers(dp)
|
||||
register_gift_activation_handlers(dp)
|
||||
common.register_handlers(dp)
|
||||
register_stars_handlers(dp)
|
||||
user_contests.register_handlers(dp)
|
||||
@@ -246,7 +264,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
elif settings.is_cabinet_mode():
|
||||
logger.info('🏠 Режим Cabinet активен, базовый URL', MINIAPP_CUSTOM_URL=settings.MINIAPP_CUSTOM_URL)
|
||||
|
||||
# Load per-section button styles cache
|
||||
# Load per-section button styles cache and menu layout cache
|
||||
if settings.is_cabinet_mode():
|
||||
try:
|
||||
from app.utils.button_styles_cache import load_button_styles_cache
|
||||
@@ -255,12 +273,35 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
except Exception as e:
|
||||
logger.warning('Failed to load button styles cache', error=e)
|
||||
|
||||
try:
|
||||
from app.utils.menu_layout_cache import load_menu_layout_cache
|
||||
|
||||
await load_menu_layout_cache()
|
||||
except Exception as e:
|
||||
logger.warning('Failed to load menu layout cache', error=e)
|
||||
|
||||
try:
|
||||
from app.services.remnawave_retry_queue import remnawave_retry_queue
|
||||
|
||||
await remnawave_retry_queue.start()
|
||||
logger.info('RemnaWave retry queue запущен')
|
||||
except Exception as e:
|
||||
logger.error('Ошибка запуска RemnaWave retry queue', error=e)
|
||||
|
||||
logger.info('Бот успешно настроен')
|
||||
|
||||
return bot, dp
|
||||
|
||||
|
||||
async def shutdown_bot():
|
||||
try:
|
||||
from app.services.remnawave_retry_queue import remnawave_retry_queue
|
||||
|
||||
await remnawave_retry_queue.stop()
|
||||
logger.info('RemnaWave retry queue остановлен')
|
||||
except Exception as e:
|
||||
logger.error('Ошибка остановки RemnaWave retry queue', error=e)
|
||||
|
||||
try:
|
||||
await maintenance_service.stop_monitoring()
|
||||
logger.info('Мониторинг техработ остановлен')
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Factory for creating Bot instances with proxy and custom API server support."""
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
def create_bot(token: str | None = None, **kwargs) -> Bot:
|
||||
"""Create a Bot instance with SOCKS5 proxy and/or custom Telegram API server."""
|
||||
proxy_url = settings.get_proxy_url()
|
||||
telegram_api_url = settings.get_telegram_api_url()
|
||||
session = None
|
||||
if proxy_url or telegram_api_url:
|
||||
from aiogram.client.session.aiohttp import AiohttpSession
|
||||
from aiogram.client.telegram import TelegramAPIServer
|
||||
|
||||
session_kwargs: dict = {}
|
||||
if proxy_url:
|
||||
session_kwargs['proxy'] = proxy_url
|
||||
if telegram_api_url:
|
||||
session_kwargs['api'] = TelegramAPIServer.from_base(telegram_api_url)
|
||||
|
||||
session = AiohttpSession(**session_kwargs)
|
||||
|
||||
kwargs.setdefault('default', DefaultBotProperties(parse_mode=ParseMode.HTML))
|
||||
return Bot(token=token or settings.BOT_TOKEN, session=session, **kwargs)
|
||||
@@ -2,21 +2,24 @@
|
||||
|
||||
from .jwt_handler import (
|
||||
create_access_token,
|
||||
create_auto_login_token,
|
||||
create_refresh_token,
|
||||
decode_token,
|
||||
get_token_payload,
|
||||
)
|
||||
from .password_utils import hash_password, verify_password
|
||||
from .telegram_auth import validate_telegram_init_data, validate_telegram_login_widget
|
||||
from .telegram_auth import validate_telegram_init_data, validate_telegram_login_widget, validate_telegram_oidc_token
|
||||
|
||||
|
||||
__all__ = [
|
||||
'create_access_token',
|
||||
'create_auto_login_token',
|
||||
'create_refresh_token',
|
||||
'decode_token',
|
||||
'get_token_payload',
|
||||
'hash_password',
|
||||
'validate_telegram_init_data',
|
||||
'validate_telegram_login_widget',
|
||||
'validate_telegram_oidc_token',
|
||||
'verify_password',
|
||||
]
|
||||
|
||||
@@ -123,6 +123,18 @@ def get_token_payload(token: str, expected_type: str = 'access') -> dict[str, An
|
||||
return payload
|
||||
|
||||
|
||||
def create_auto_login_token(user_id: int, ttl_hours: int = 72) -> str:
|
||||
"""Short-lived JWT for auto-login from guest purchase success page."""
|
||||
expires = datetime.now(UTC) + timedelta(hours=ttl_hours)
|
||||
payload = {
|
||||
'sub': str(user_id),
|
||||
'type': 'auto_login',
|
||||
'exp': expires,
|
||||
'iat': datetime.now(UTC),
|
||||
}
|
||||
return jwt.encode(payload, settings.get_cabinet_jwt_secret(), algorithm=JWT_ALGORITHM)
|
||||
|
||||
|
||||
def get_refresh_token_expires_at() -> datetime:
|
||||
"""Get the expiration datetime for a new refresh token."""
|
||||
expire_days = settings.get_cabinet_refresh_token_expire_days()
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
"""Telegram authentication validation for cabinet."""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
from urllib.parse import parse_qsl
|
||||
|
||||
import httpx
|
||||
import jwt as pyjwt
|
||||
import structlog
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
# Maximum allowed clock skew (seconds) for auth_date — tolerates minor drift between Telegram servers and ours.
|
||||
_MAX_CLOCK_SKEW_SECONDS = 300
|
||||
|
||||
@@ -41,7 +49,17 @@ def validate_telegram_login_widget(data: dict[str, Any], max_age_seconds: int =
|
||||
auth_time = datetime.fromtimestamp(int(auth_date), tz=UTC)
|
||||
age = (datetime.now(UTC) - auth_time).total_seconds()
|
||||
if age > max_age_seconds or age < -_MAX_CLOCK_SKEW_SECONDS:
|
||||
logger.warning(
|
||||
'Telegram widget auth rejected: too old',
|
||||
age_hours=round(age / 3600, 1),
|
||||
max_age_hours=round(max_age_seconds / 3600, 1),
|
||||
)
|
||||
return False
|
||||
if age > 86400:
|
||||
logger.info(
|
||||
'Telegram widget auth accepted with stale auth_date',
|
||||
age_hours=round(age / 3600, 1),
|
||||
)
|
||||
except (ValueError, TypeError, OSError):
|
||||
return False
|
||||
|
||||
@@ -88,7 +106,17 @@ def validate_telegram_init_data(init_data: str, max_age_seconds: int = 86400) ->
|
||||
auth_time = datetime.fromtimestamp(int(auth_date), tz=UTC)
|
||||
age = (datetime.now(UTC) - auth_time).total_seconds()
|
||||
if age > max_age_seconds or age < -_MAX_CLOCK_SKEW_SECONDS:
|
||||
logger.warning(
|
||||
'Telegram initData rejected: too old',
|
||||
age_hours=round(age / 3600, 1),
|
||||
max_age_hours=round(max_age_seconds / 3600, 1),
|
||||
)
|
||||
return None
|
||||
if age > 86400:
|
||||
logger.info(
|
||||
'Telegram initData accepted with stale auth_date (Telegram caching bug)',
|
||||
age_hours=round(age / 3600, 1),
|
||||
)
|
||||
except (ValueError, TypeError, OSError):
|
||||
return None
|
||||
|
||||
@@ -129,3 +157,118 @@ def extract_telegram_user_from_init_data(init_data: str) -> dict[str, Any] | Non
|
||||
User data dict with id, first_name, last_name, username, etc. or None if invalid
|
||||
"""
|
||||
return validate_telegram_init_data(init_data)
|
||||
|
||||
|
||||
# JWKS cache (module-level, refreshed periodically)
|
||||
_jwks_cache: dict[str, Any] = {}
|
||||
_jwks_cache_expiry: datetime | None = None
|
||||
_JWKS_CACHE_TTL_SECONDS = 3600 # 1 hour
|
||||
_JWKS_URL = 'https://oauth.telegram.org/.well-known/jwks.json'
|
||||
_OIDC_ISSUER = 'https://oauth.telegram.org'
|
||||
|
||||
_jwks_lock = asyncio.Lock()
|
||||
_jwks_last_force_refresh: datetime | None = None
|
||||
_JWKS_FORCE_REFRESH_COOLDOWN_SECONDS = 30
|
||||
|
||||
|
||||
def _build_public_keys(jwks_data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Build public key mapping from JWKS data."""
|
||||
public_keys: dict[str, Any] = {}
|
||||
for key_data in jwks_data.get('keys', []):
|
||||
kid = key_data.get('kid')
|
||||
if kid:
|
||||
public_keys[kid] = pyjwt.algorithms.RSAAlgorithm.from_jwk(key_data)
|
||||
return public_keys
|
||||
|
||||
|
||||
async def _get_jwks(force: bool = False) -> dict[str, Any]:
|
||||
"""Fetch and cache Telegram OIDC JWKS keys."""
|
||||
global _jwks_cache, _jwks_cache_expiry
|
||||
|
||||
now = datetime.now(UTC)
|
||||
if not force and _jwks_cache and _jwks_cache_expiry and now < _jwks_cache_expiry:
|
||||
return _jwks_cache
|
||||
|
||||
async with _jwks_lock:
|
||||
# Double-check after acquiring lock
|
||||
now = datetime.now(UTC)
|
||||
if not force and _jwks_cache and _jwks_cache_expiry and now < _jwks_cache_expiry:
|
||||
return _jwks_cache
|
||||
|
||||
proxy = settings.PROXY_URL if hasattr(settings, 'PROXY_URL') and settings.PROXY_URL else None
|
||||
async with httpx.AsyncClient(timeout=10, proxy=proxy) as client:
|
||||
response = await client.get(_JWKS_URL)
|
||||
response.raise_for_status()
|
||||
_jwks_cache = response.json()
|
||||
_jwks_cache_expiry = now + timedelta(seconds=_JWKS_CACHE_TTL_SECONDS)
|
||||
return _jwks_cache
|
||||
|
||||
|
||||
async def _force_refresh_jwks(kid: str) -> dict[str, Any] | None:
|
||||
"""Force JWKS refresh with cooldown protection. Returns refreshed JWKS or None if on cooldown."""
|
||||
global _jwks_cache_expiry, _jwks_last_force_refresh
|
||||
|
||||
async with _jwks_lock:
|
||||
now = datetime.now(UTC)
|
||||
if (
|
||||
_jwks_last_force_refresh
|
||||
and (now - _jwks_last_force_refresh).total_seconds() < _JWKS_FORCE_REFRESH_COOLDOWN_SECONDS
|
||||
):
|
||||
logger.warning('Telegram OIDC: JWKS force refresh on cooldown', kid=kid)
|
||||
return None
|
||||
_jwks_last_force_refresh = now
|
||||
_jwks_cache_expiry = None
|
||||
|
||||
return await _get_jwks(force=True)
|
||||
|
||||
|
||||
async def validate_telegram_oidc_token(id_token: str, client_id: str) -> dict[str, Any] | None:
|
||||
"""
|
||||
Validate a Telegram OIDC id_token using JWKS.
|
||||
|
||||
Args:
|
||||
id_token: JWT id_token from Telegram OIDC flow
|
||||
client_id: Expected audience (bot's numeric ID as string)
|
||||
|
||||
Returns:
|
||||
Decoded claims dict if valid, None otherwise.
|
||||
Claims include: sub, id, name, preferred_username, picture, iss, aud, exp, iat
|
||||
"""
|
||||
try:
|
||||
# Build public keys from JWKS
|
||||
jwks_data = await _get_jwks()
|
||||
public_keys = _build_public_keys(jwks_data)
|
||||
|
||||
# Decode header to get kid
|
||||
unverified_header = pyjwt.get_unverified_header(id_token)
|
||||
kid = unverified_header.get('kid')
|
||||
|
||||
# If kid not found, force JWKS refresh (key rotation) with cooldown
|
||||
if kid and kid not in public_keys:
|
||||
refreshed = await _force_refresh_jwks(kid)
|
||||
if refreshed:
|
||||
public_keys = _build_public_keys(refreshed)
|
||||
|
||||
if not kid or kid not in public_keys:
|
||||
logger.warning('Telegram OIDC: unknown kid in id_token', kid=kid)
|
||||
return None
|
||||
|
||||
claims = pyjwt.decode(
|
||||
id_token,
|
||||
key=public_keys[kid],
|
||||
algorithms=['RS256'],
|
||||
audience=client_id,
|
||||
issuer=_OIDC_ISSUER,
|
||||
options={'require': ['exp', 'iat', 'iss', 'aud', 'sub']},
|
||||
)
|
||||
return claims
|
||||
|
||||
except pyjwt.ExpiredSignatureError:
|
||||
logger.warning('Telegram OIDC: id_token expired')
|
||||
return None
|
||||
except pyjwt.InvalidTokenError as e:
|
||||
logger.warning('Telegram OIDC: invalid id_token', error=str(e))
|
||||
return None
|
||||
except httpx.HTTPError as e:
|
||||
logger.error('Telegram OIDC: failed to fetch JWKS', error=str(e))
|
||||
return None
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""FastAPI dependencies for cabinet module."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from fastapi import Depends, HTTPException, Request, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
@@ -176,6 +178,15 @@ async def get_current_cabinet_user(
|
||||
},
|
||||
)
|
||||
|
||||
# Throttled update of cabinet_last_login (at most every 5 minutes)
|
||||
now = datetime.now(UTC)
|
||||
if not user.cabinet_last_login or (now - user.cabinet_last_login).total_seconds() > 300:
|
||||
try:
|
||||
user.cabinet_last_login = now
|
||||
await db.commit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return user
|
||||
|
||||
|
||||
|
||||
@@ -7,10 +7,18 @@ from .admin_apps import router as admin_apps_router
|
||||
from .admin_audit_log import router as admin_audit_log_router
|
||||
from .admin_ban_system import router as admin_ban_system_router
|
||||
from .admin_broadcasts import router as admin_broadcasts_router
|
||||
from .admin_bulk_actions import router as admin_bulk_actions_router
|
||||
from .admin_button_styles import router as admin_button_styles_router
|
||||
from .admin_campaigns import router as admin_campaigns_router
|
||||
from .admin_channels import router as admin_channels_router
|
||||
from .admin_email_templates import router as admin_email_templates_router
|
||||
from .admin_info_pages import router as admin_info_pages_router
|
||||
from .admin_landings import router as admin_landings_router
|
||||
from .admin_menu_layout import router as admin_menu_layout_router
|
||||
from .admin_news import router as admin_news_router
|
||||
from .admin_news_categories import router as admin_news_categories_router
|
||||
from .admin_news_media import router as admin_news_media_router
|
||||
from .admin_news_tags import router as admin_news_tags_router
|
||||
from .admin_partners import router as admin_partners_router
|
||||
from .admin_payment_methods import router as admin_payment_methods_router
|
||||
from .admin_payments import router as admin_payments_router
|
||||
@@ -18,6 +26,7 @@ from .admin_pinned_messages import router as admin_pinned_messages_router
|
||||
from .admin_policies import router as admin_policies_router
|
||||
from .admin_promo_offers import router as admin_promo_offers_router
|
||||
from .admin_promocodes import promo_groups_router as admin_promo_groups_router, router as admin_promocodes_router
|
||||
from .admin_referral_network import router as admin_referral_network_router
|
||||
from .admin_remnawave import router as admin_remnawave_router
|
||||
from .admin_roles import router as admin_roles_router
|
||||
from .admin_sales_stats import router as admin_sales_stats_router
|
||||
@@ -25,6 +34,7 @@ from .admin_servers import router as admin_servers_router
|
||||
from .admin_settings import router as admin_settings_router
|
||||
from .admin_stats import router as admin_stats_router
|
||||
from .admin_tariffs import router as admin_tariffs_router
|
||||
from .admin_tasks import router as admin_tasks_router
|
||||
from .admin_tickets import router as admin_tickets_router
|
||||
from .admin_traffic import router as admin_traffic_router
|
||||
from .admin_updates import router as admin_updates_router
|
||||
@@ -35,8 +45,12 @@ from .auth import router as auth_router
|
||||
from .balance import router as balance_router
|
||||
from .branding import router as branding_router
|
||||
from .contests import router as contests_router
|
||||
from .gift import router as gift_router
|
||||
from .info import router as info_router
|
||||
from .info_pages import router as info_pages_router
|
||||
from .landing import router as landing_router
|
||||
from .media import router as media_router
|
||||
from .news import router as news_router
|
||||
from .notifications import router as notifications_router
|
||||
from .oauth import router as oauth_router
|
||||
from .partner_application import router as partner_application_router
|
||||
@@ -45,18 +59,27 @@ from .promo import router as promo_router
|
||||
from .promocode import router as promocode_router
|
||||
from .referral import router as referral_router
|
||||
from .subscription import router as subscription_router
|
||||
from .subscription_modules.multi_tariff import router as multi_tariff_subscription_router
|
||||
from .ticket_notifications import (
|
||||
admin_router as admin_ticket_notifications_router,
|
||||
router as ticket_notifications_router,
|
||||
)
|
||||
from .tickets import router as tickets_router
|
||||
from .user_tasks import router as user_tasks_router
|
||||
from .websocket import router as websocket_router
|
||||
from .wheel import router as wheel_router
|
||||
from .withdrawal import router as withdrawal_router
|
||||
|
||||
|
||||
# Conditional imports
|
||||
try:
|
||||
from .apple_iap import router as apple_iap_router
|
||||
except ImportError:
|
||||
apple_iap_router = None
|
||||
|
||||
|
||||
# Main cabinet router
|
||||
router = APIRouter(prefix='/cabinet', tags=['Cabinet'])
|
||||
router = APIRouter(prefix='/cabinet', tags=['Cabinet'], redirect_slashes=False)
|
||||
|
||||
# Include all sub-routers
|
||||
router.include_router(auth_router)
|
||||
@@ -64,8 +87,14 @@ router.include_router(oauth_router)
|
||||
router.include_router(account_linking_router)
|
||||
router.include_router(merge_router)
|
||||
router.include_router(subscription_router)
|
||||
router.include_router(multi_tariff_subscription_router)
|
||||
router.include_router(balance_router)
|
||||
router.include_router(referral_router)
|
||||
|
||||
# Apple IAP routes
|
||||
if apple_iap_router is not None:
|
||||
router.include_router(apple_iap_router)
|
||||
|
||||
router.include_router(partner_application_router)
|
||||
router.include_router(withdrawal_router)
|
||||
# Notifications router MUST be before tickets router to avoid route conflict
|
||||
@@ -78,11 +107,18 @@ router.include_router(promo_router)
|
||||
router.include_router(notifications_router)
|
||||
router.include_router(info_router)
|
||||
router.include_router(branding_router)
|
||||
router.include_router(landing_router)
|
||||
router.include_router(media_router)
|
||||
router.include_router(news_router)
|
||||
router.include_router(info_pages_router)
|
||||
router.include_router(user_tasks_router)
|
||||
|
||||
# Wheel routes
|
||||
router.include_router(wheel_router)
|
||||
|
||||
# Gift routes
|
||||
router.include_router(gift_router)
|
||||
|
||||
# Admin routes (notifications router MUST be before tickets router to avoid route conflict)
|
||||
router.include_router(admin_ticket_notifications_router)
|
||||
router.include_router(admin_tickets_router)
|
||||
@@ -91,6 +127,7 @@ router.include_router(admin_wheel_router)
|
||||
router.include_router(admin_tariffs_router)
|
||||
router.include_router(admin_servers_router)
|
||||
router.include_router(admin_stats_router)
|
||||
router.include_router(admin_referral_network_router)
|
||||
router.include_router(admin_sales_stats_router)
|
||||
router.include_router(admin_ban_system_router)
|
||||
router.include_router(admin_broadcasts_router)
|
||||
@@ -100,7 +137,9 @@ router.include_router(admin_campaigns_router)
|
||||
router.include_router(admin_partners_router)
|
||||
router.include_router(admin_withdrawals_router)
|
||||
router.include_router(admin_users_router)
|
||||
router.include_router(admin_bulk_actions_router)
|
||||
router.include_router(admin_payment_methods_router)
|
||||
router.include_router(admin_landings_router)
|
||||
router.include_router(admin_payments_router)
|
||||
router.include_router(admin_promo_offers_router)
|
||||
router.include_router(admin_remnawave_router)
|
||||
@@ -109,11 +148,20 @@ router.include_router(admin_updates_router)
|
||||
router.include_router(admin_traffic_router)
|
||||
router.include_router(admin_pinned_messages_router)
|
||||
router.include_router(admin_button_styles_router)
|
||||
router.include_router(admin_menu_layout_router)
|
||||
router.include_router(admin_channels_router)
|
||||
router.include_router(admin_apps_router)
|
||||
router.include_router(admin_roles_router)
|
||||
router.include_router(admin_policies_router)
|
||||
router.include_router(admin_audit_log_router)
|
||||
# Categories/tags/media routers MUST be before the main news router
|
||||
# to avoid /admin/news/{article_id} catching /admin/news/categories etc.
|
||||
router.include_router(admin_news_categories_router)
|
||||
router.include_router(admin_news_tags_router)
|
||||
router.include_router(admin_news_media_router)
|
||||
router.include_router(admin_news_router)
|
||||
router.include_router(admin_info_pages_router)
|
||||
router.include_router(admin_tasks_router)
|
||||
|
||||
# WebSocket route
|
||||
router.include_router(websocket_router)
|
||||
|
||||
@@ -5,6 +5,7 @@ Router 1 (`router`): JWT-protected endpoints for linking/unlinking OAuth provide
|
||||
Router 2 (`merge_router`): Public endpoints for merge preview and execution.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
from datetime import UTC, datetime
|
||||
from typing import Literal, NotRequired, TypedDict
|
||||
|
||||
@@ -14,6 +15,8 @@ from pydantic import BaseModel, Field, model_validator
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.system_setting import get_setting_value
|
||||
from app.database.crud.user import (
|
||||
OAUTH_PROVIDER_COLUMNS,
|
||||
clear_user_oauth_provider_id,
|
||||
@@ -24,7 +27,7 @@ from app.database.crud.user import (
|
||||
)
|
||||
from app.database.models import User
|
||||
from app.services.account_merge_service import compute_auth_methods, execute_merge, get_merge_preview
|
||||
from app.utils.cache import RateLimitCache
|
||||
from app.utils.cache import RateLimitCache, TokenReplayCache
|
||||
|
||||
from ..auth.merge_service import (
|
||||
MERGE_TOKEN_TTL_SECONDS,
|
||||
@@ -38,7 +41,11 @@ from ..auth.oauth_providers import (
|
||||
get_provider,
|
||||
validate_oauth_state,
|
||||
)
|
||||
from ..auth.telegram_auth import validate_telegram_init_data, validate_telegram_login_widget
|
||||
from ..auth.telegram_auth import (
|
||||
validate_telegram_init_data,
|
||||
validate_telegram_login_widget,
|
||||
validate_telegram_oidc_token,
|
||||
)
|
||||
from ..dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
from ..ip_utils import get_client_ip
|
||||
from ..schemas.auth import UserResponse
|
||||
@@ -70,8 +77,6 @@ class OAuthStateData(TypedDict):
|
||||
|
||||
def _get_active_providers() -> list[str]:
|
||||
"""Вернуть список активных провайдеров аутентификации (только включённые)."""
|
||||
from app.config import settings
|
||||
|
||||
providers: list[str] = ['telegram']
|
||||
if settings.is_cabinet_email_auth_enabled():
|
||||
providers.append('email')
|
||||
@@ -117,10 +122,12 @@ class UnlinkResponse(BaseModel):
|
||||
|
||||
|
||||
class LinkTelegramRequest(BaseModel):
|
||||
"""Request for linking Telegram account. Supply EITHER init_data OR widget fields."""
|
||||
"""Request for linking Telegram account. Supply EITHER init_data, id_token, OR widget fields."""
|
||||
|
||||
# Mini App: Telegram WebApp initData
|
||||
init_data: str | None = Field(None, max_length=4096, description='Telegram WebApp initData string')
|
||||
# OIDC: id_token from Telegram Login popup
|
||||
id_token: str | None = Field(None, max_length=4096, description='Telegram OIDC id_token (JWT)')
|
||||
# Login Widget fields
|
||||
id: int | None = Field(None, description='Telegram user ID from Login Widget')
|
||||
first_name: str | None = Field(None, max_length=256, description="User's first name")
|
||||
@@ -133,11 +140,13 @@ class LinkTelegramRequest(BaseModel):
|
||||
@model_validator(mode='after')
|
||||
def check_exclusive(self) -> 'LinkTelegramRequest':
|
||||
has_init = self.init_data is not None
|
||||
has_oidc = self.id_token is not None
|
||||
has_widget = self.id is not None or self.hash is not None or self.auth_date is not None
|
||||
if has_init and has_widget:
|
||||
raise ValueError('Provide either init_data or Login Widget fields, not both')
|
||||
if not has_init and not has_widget:
|
||||
raise ValueError('Provide either init_data or Login Widget fields (id, auth_date, hash)')
|
||||
modes = sum([has_init, has_oidc, has_widget])
|
||||
if modes > 1:
|
||||
raise ValueError('Provide exactly one of: init_data, id_token, or Login Widget fields')
|
||||
if modes == 0:
|
||||
raise ValueError('Provide one of: init_data, id_token, or Login Widget fields (id, auth_date, hash)')
|
||||
if has_widget and not (self.id is not None and self.auth_date is not None and self.hash is not None):
|
||||
raise ValueError('Login Widget mode requires id, auth_date, and hash fields')
|
||||
return self
|
||||
@@ -449,10 +458,20 @@ async def unlink_provider(
|
||||
@router.post('/link/telegram', response_model=LinkCallbackResponse)
|
||||
async def link_telegram(
|
||||
request: LinkTelegramRequest,
|
||||
raw_request: Request,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> LinkCallbackResponse:
|
||||
"""Link Telegram account via WebApp initData or Login Widget."""
|
||||
"""Link Telegram account via WebApp initData, OIDC id_token, or Login Widget."""
|
||||
# Rate limit
|
||||
client_ip = get_client_ip(raw_request)
|
||||
if await RateLimitCache.is_ip_rate_limited(client_ip, 'link_telegram', limit=10, window=60, fail_closed=True):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail='Too many requests',
|
||||
headers={'Retry-After': '60'},
|
||||
)
|
||||
|
||||
# 1. Already has Telegram linked?
|
||||
if user.telegram_id:
|
||||
raise HTTPException(
|
||||
@@ -468,7 +487,8 @@ async def link_telegram(
|
||||
|
||||
if request.init_data:
|
||||
# Mini App flow: validate initData
|
||||
user_data = validate_telegram_init_data(request.init_data)
|
||||
# Generous max_age: Telegram Desktop/iOS cache initData with stale auth_date
|
||||
user_data = validate_telegram_init_data(request.init_data, max_age_seconds=86400 * 30)
|
||||
if not user_data or not user_data.get('id'):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
@@ -478,6 +498,53 @@ async def link_telegram(
|
||||
telegram_username = user_data.get('username')
|
||||
telegram_first_name = user_data.get('first_name')
|
||||
telegram_last_name = user_data.get('last_name')
|
||||
elif request.id_token:
|
||||
# OIDC flow: validate id_token via JWKS
|
||||
oidc_enabled_val = await get_setting_value(db, 'TELEGRAM_OIDC_ENABLED')
|
||||
oidc_client_id_val = await get_setting_value(db, 'TELEGRAM_OIDC_CLIENT_ID')
|
||||
oidc_client_id = oidc_client_id_val or settings.TELEGRAM_OIDC_CLIENT_ID
|
||||
oidc_enabled = (
|
||||
oidc_enabled_val.lower() == 'true' if oidc_enabled_val is not None else settings.TELEGRAM_OIDC_ENABLED
|
||||
) and bool(oidc_client_id)
|
||||
|
||||
if not oidc_enabled:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Telegram OIDC is not configured',
|
||||
)
|
||||
|
||||
claims = await validate_telegram_oidc_token(request.id_token, oidc_client_id)
|
||||
if not claims:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail='Invalid or expired Telegram OIDC token',
|
||||
)
|
||||
|
||||
# Replay detection
|
||||
token_hash = hashlib.sha256(request.id_token.encode()).hexdigest()
|
||||
token_ttl = max(int(claims.get('exp', 0) - datetime.now(UTC).timestamp()), 60)
|
||||
if await TokenReplayCache.is_token_replayed(token_hash, ttl=min(token_ttl, 600)):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail='Invalid or expired Telegram OIDC token',
|
||||
)
|
||||
|
||||
try:
|
||||
telegram_id = int(claims.get('id', claims.get('sub', 0)))
|
||||
except (ValueError, TypeError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail='Invalid user ID in OIDC claims',
|
||||
) from exc
|
||||
if not telegram_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail='Missing user ID in OIDC claims',
|
||||
)
|
||||
|
||||
telegram_username = claims.get('preferred_username')
|
||||
telegram_first_name = claims.get('name', claims.get('given_name', ''))
|
||||
telegram_last_name = claims.get('family_name')
|
||||
elif request.id is not None and request.hash is not None and request.auth_date is not None:
|
||||
# Login Widget flow: validate widget hash
|
||||
widget_data = {
|
||||
@@ -494,7 +561,8 @@ async def link_telegram(
|
||||
if request.photo_url is not None:
|
||||
widget_data['photo_url'] = request.photo_url
|
||||
|
||||
if not validate_telegram_login_widget(widget_data):
|
||||
# Generous max_age: Telegram caches auth data with stale auth_date
|
||||
if not validate_telegram_login_widget(widget_data, max_age_seconds=86400 * 30):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Invalid or expired Telegram Login Widget data',
|
||||
@@ -506,7 +574,7 @@ async def link_telegram(
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Provide either init_data (Mini App) or Login Widget fields (id, auth_date, hash)',
|
||||
detail='Provide init_data (Mini App), id_token (OIDC), or Login Widget fields (id, auth_date, hash)',
|
||||
)
|
||||
|
||||
# 3. Check if telegram_id is linked to ANOTHER user
|
||||
@@ -554,6 +622,24 @@ async def link_telegram(
|
||||
telegram_id=telegram_id,
|
||||
user_id=user.id,
|
||||
)
|
||||
# BUG-1 fix: Sync all subscriptions with RemnaWave panel so it knows the new telegram_id
|
||||
try:
|
||||
from app.services.remnawave_resync_service import resync_user_subscriptions_with_panel
|
||||
|
||||
resync_result = await resync_user_subscriptions_with_panel(db, user)
|
||||
logger.info(
|
||||
'Post-TG-link resync completed',
|
||||
user_id=user.id,
|
||||
telegram_id=telegram_id,
|
||||
synced=resync_result['synced'],
|
||||
failed=resync_result['failed'],
|
||||
)
|
||||
except Exception as resync_error:
|
||||
logger.error(
|
||||
'Post-TG-link resync failed (non-fatal)',
|
||||
user_id=user.id,
|
||||
error=resync_error,
|
||||
)
|
||||
return LinkCallbackResponse(success=True, message='linked')
|
||||
|
||||
|
||||
@@ -799,6 +885,25 @@ async def execute_merge_endpoint(
|
||||
detail='Failed to load merged user',
|
||||
)
|
||||
|
||||
# BUG-7 fix: Resync merged user's subscriptions with RemnaWave panel
|
||||
try:
|
||||
from app.services.remnawave_resync_service import resync_user_subscriptions_with_panel
|
||||
|
||||
resync_result = await resync_user_subscriptions_with_panel(db, merged_user)
|
||||
logger.info(
|
||||
'Post-merge resync completed',
|
||||
primary_user_id=primary_user_id,
|
||||
secondary_user_id=secondary_user_id,
|
||||
synced=resync_result['synced'],
|
||||
failed=resync_result['failed'],
|
||||
)
|
||||
except Exception as resync_error:
|
||||
logger.error(
|
||||
'Post-merge resync failed (non-fatal)',
|
||||
primary_user_id=primary_user_id,
|
||||
error=resync_error,
|
||||
)
|
||||
|
||||
# 5. Create auth tokens for the merged user
|
||||
try:
|
||||
auth_response = await _create_auth_response(merged_user, db)
|
||||
|
||||
@@ -141,6 +141,7 @@ def _serialize_broadcast(broadcast: BroadcastHistory) -> BroadcastResponse:
|
||||
created_at=broadcast.created_at,
|
||||
completed_at=broadcast.completed_at,
|
||||
progress_percent=progress,
|
||||
category=getattr(broadcast, 'category', 'system') or 'system',
|
||||
channel=getattr(broadcast, 'channel', 'telegram') or 'telegram',
|
||||
email_subject=getattr(broadcast, 'email_subject', None),
|
||||
email_html_content=getattr(broadcast, 'email_html_content', None),
|
||||
@@ -411,6 +412,13 @@ async def create_broadcast(
|
||||
|
||||
media_payload = request.media
|
||||
|
||||
# Validate caption length for media messages (Telegram limit: 1024 chars)
|
||||
if media_payload and len(message_text) > 1024:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Текст слишком длинный для сообщения с медиа. Максимум 1024 символов, сейчас {len(message_text)}. Сократите текст или уберите медиафайл.',
|
||||
)
|
||||
|
||||
# Create broadcast record
|
||||
broadcast = BroadcastHistory(
|
||||
target_type=request.target,
|
||||
@@ -425,6 +433,7 @@ async def create_broadcast(
|
||||
status='queued',
|
||||
admin_id=admin.id,
|
||||
admin_name=admin.username or f'Admin #{admin.id}',
|
||||
category=request.category,
|
||||
)
|
||||
db.add(broadcast)
|
||||
await db.commit()
|
||||
@@ -446,6 +455,8 @@ async def create_broadcast(
|
||||
selected_buttons=request.selected_buttons,
|
||||
media=media_config,
|
||||
initiator_name=admin.username or f'Admin #{admin.id}',
|
||||
custom_buttons=[btn.model_dump() for btn in request.custom_buttons] if request.custom_buttons else None,
|
||||
category=request.category,
|
||||
)
|
||||
|
||||
# Start broadcast
|
||||
@@ -618,6 +629,7 @@ async def create_combined_broadcast(
|
||||
status='queued',
|
||||
admin_id=admin.id,
|
||||
admin_name=admin_name,
|
||||
category=request.category,
|
||||
channel=request.channel,
|
||||
email_subject=request.email_subject.strip() if request.email_subject else None,
|
||||
email_html_content=request.email_html_content.strip() if request.email_html_content else None,
|
||||
@@ -644,6 +656,8 @@ async def create_combined_broadcast(
|
||||
selected_buttons=request.selected_buttons,
|
||||
media=media_config,
|
||||
initiator_name=admin_name,
|
||||
custom_buttons=[btn.model_dump() for btn in request.custom_buttons] if request.custom_buttons else None,
|
||||
category=request.category,
|
||||
)
|
||||
|
||||
await broadcast_service.start_broadcast(broadcast.id, telegram_config)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -37,7 +37,7 @@ TEMPLATE_TYPES = [
|
||||
'zh': '余额充值通知',
|
||||
'ua': 'Сповіщення про поповнення балансу',
|
||||
},
|
||||
'context_vars': ['amount', 'balance'],
|
||||
'context_vars': ['formatted_amount', 'formatted_balance', 'amount_rubles', 'new_balance_rubles'],
|
||||
},
|
||||
{
|
||||
'type': 'balance_change',
|
||||
@@ -48,7 +48,7 @@ TEMPLATE_TYPES = [
|
||||
'zh': '余额变动通知',
|
||||
'ua': 'Сповіщення про зміну балансу',
|
||||
},
|
||||
'context_vars': ['amount', 'balance'],
|
||||
'context_vars': ['formatted_amount', 'formatted_balance', 'amount_rubles', 'new_balance_rubles'],
|
||||
},
|
||||
{
|
||||
'type': 'subscription_expiring',
|
||||
@@ -96,7 +96,7 @@ TEMPLATE_TYPES = [
|
||||
'zh': '订阅已续期通知',
|
||||
'ua': 'Сповіщення про продовження підписки',
|
||||
},
|
||||
'context_vars': ['new_end_date', 'tariff_name'],
|
||||
'context_vars': ['new_expires_at', 'tariff_name', 'traffic_limit_gb', 'device_limit'],
|
||||
},
|
||||
{
|
||||
'type': 'subscription_activated',
|
||||
@@ -112,7 +112,7 @@ TEMPLATE_TYPES = [
|
||||
'zh': '订阅已激活通知',
|
||||
'ua': 'Сповіщення про активацію підписки',
|
||||
},
|
||||
'context_vars': ['tariff_name', 'end_date'],
|
||||
'context_vars': ['expires_at', 'tariff_name', 'traffic_limit_gb', 'device_limit'],
|
||||
},
|
||||
{
|
||||
'type': 'autopay_success',
|
||||
@@ -128,7 +128,7 @@ TEMPLATE_TYPES = [
|
||||
'zh': '自动续费成功通知',
|
||||
'ua': 'Сповіщення про успішний автоплатіж',
|
||||
},
|
||||
'context_vars': ['amount', 'balance', 'new_end_date'],
|
||||
'context_vars': ['formatted_amount', 'amount_rubles', 'new_expires_at'],
|
||||
},
|
||||
{
|
||||
'type': 'autopay_failed',
|
||||
@@ -160,7 +160,7 @@ TEMPLATE_TYPES = [
|
||||
'zh': '自动续费余额不足通知',
|
||||
'ua': 'Сповіщення про нестачу коштів для автоплатежу',
|
||||
},
|
||||
'context_vars': ['required_amount', 'balance'],
|
||||
'context_vars': ['required_amount', 'current_balance'],
|
||||
},
|
||||
{
|
||||
'type': 'daily_debit',
|
||||
@@ -171,7 +171,7 @@ TEMPLATE_TYPES = [
|
||||
'zh': '每日扣费通知',
|
||||
'ua': 'Сповіщення про добове списання',
|
||||
},
|
||||
'context_vars': ['amount', 'balance'],
|
||||
'context_vars': ['formatted_amount', 'formatted_balance', 'amount_rubles', 'new_balance_rubles'],
|
||||
},
|
||||
{
|
||||
'type': 'daily_insufficient_funds',
|
||||
@@ -187,7 +187,7 @@ TEMPLATE_TYPES = [
|
||||
'zh': '每日扣费余额不足通知',
|
||||
'ua': 'Сповіщення про нестачу коштів для добового списання',
|
||||
},
|
||||
'context_vars': ['required_amount', 'balance'],
|
||||
'context_vars': ['required_amount', 'current_balance'],
|
||||
},
|
||||
{
|
||||
'type': 'ban_notification',
|
||||
@@ -236,7 +236,7 @@ TEMPLATE_TYPES = [
|
||||
'zh': '推荐奖励通知',
|
||||
'ua': 'Сповіщення про нарахування реферального бонусу',
|
||||
},
|
||||
'context_vars': ['amount', 'referral_name'],
|
||||
'context_vars': ['formatted_bonus', 'bonus_rubles', 'referral_name'],
|
||||
},
|
||||
{
|
||||
'type': 'referral_registered',
|
||||
@@ -258,7 +258,7 @@ TEMPLATE_TYPES = [
|
||||
'zh': '流量重置通知',
|
||||
'ua': 'Сповіщення про скидання трафіку',
|
||||
},
|
||||
'context_vars': ['traffic_limit'],
|
||||
'context_vars': ['reset_gb', 'current_limit_gb'],
|
||||
},
|
||||
{
|
||||
'type': 'payment_received',
|
||||
@@ -269,7 +269,7 @@ TEMPLATE_TYPES = [
|
||||
'zh': '收到付款通知',
|
||||
'ua': 'Сповіщення про отримання платежу',
|
||||
},
|
||||
'context_vars': ['amount', 'payment_method'],
|
||||
'context_vars': ['formatted_amount', 'payment_method'],
|
||||
},
|
||||
{
|
||||
'type': 'email_verification',
|
||||
@@ -298,6 +298,77 @@ TEMPLATE_TYPES = [
|
||||
},
|
||||
'context_vars': ['username', 'reset_url', 'expire_hours'],
|
||||
},
|
||||
{
|
||||
'type': 'guest_subscription_delivered',
|
||||
'label': {
|
||||
'ru': 'Быстрая покупка: подписка доставлена',
|
||||
'en': 'Quick Purchase: Subscription Delivered',
|
||||
'zh': '快捷购买:订阅已交付',
|
||||
'ua': 'Швидка покупка: підписка доставлена',
|
||||
},
|
||||
'description': {
|
||||
'ru': 'Письмо покупателю после успешной оплаты через лендинг',
|
||||
'en': 'Email to buyer after successful landing page payment',
|
||||
'zh': '通过落地页成功付款后发送给买家的邮件',
|
||||
'ua': 'Лист покупцю після успішної оплати через лендінг',
|
||||
},
|
||||
'context_vars': ['tariff_name', 'period_days', 'cabinet_url', 'cabinet_email', 'cabinet_password'],
|
||||
},
|
||||
{
|
||||
'type': 'guest_activation_required',
|
||||
'label': {
|
||||
'ru': 'Быстрая покупка: требуется активация',
|
||||
'en': 'Quick Purchase: Activation Required',
|
||||
'zh': '快捷购买:需要激活',
|
||||
'ua': 'Швидка покупка: потрібна активація',
|
||||
},
|
||||
'description': {
|
||||
'ru': 'Письмо когда у покупателя уже есть активная подписка',
|
||||
'en': 'Email when buyer already has an active subscription',
|
||||
'zh': '买家已有活跃订阅时发送的邮件',
|
||||
'ua': 'Лист коли у покупця вже є активна підписка',
|
||||
},
|
||||
'context_vars': ['tariff_name', 'period_days', 'success_page_url', 'gift_message', 'is_gift'],
|
||||
},
|
||||
{
|
||||
'type': 'guest_gift_received',
|
||||
'label': {
|
||||
'ru': 'Быстрая покупка: подарок получен',
|
||||
'en': 'Quick Purchase: Gift Received',
|
||||
'zh': '快捷购买:收到礼物',
|
||||
'ua': 'Швидка покупка: подарунок отримано',
|
||||
},
|
||||
'description': {
|
||||
'ru': 'Письмо получателю подарочной подписки',
|
||||
'en': 'Email to gift subscription recipient',
|
||||
'zh': '发送给礼物订阅接收者的邮件',
|
||||
'ua': 'Лист отримувачу подарункової підписки',
|
||||
},
|
||||
'context_vars': [
|
||||
'tariff_name',
|
||||
'period_days',
|
||||
'cabinet_url',
|
||||
'gift_message',
|
||||
'cabinet_email',
|
||||
'cabinet_password',
|
||||
],
|
||||
},
|
||||
{
|
||||
'type': 'guest_cabinet_credentials',
|
||||
'label': {
|
||||
'ru': 'Быстрая покупка: данные для входа',
|
||||
'en': 'Quick Purchase: Login Credentials',
|
||||
'zh': '快捷购买:登录凭据',
|
||||
'ua': 'Швидка покупка: дані для входу',
|
||||
},
|
||||
'description': {
|
||||
'ru': 'Письмо с логином и паролем для личного кабинета',
|
||||
'en': 'Email with login credentials for the cabinet',
|
||||
'zh': '包含个人中心登录信息的邮件',
|
||||
'ua': 'Лист з логіном та паролем для особистого кабінету',
|
||||
},
|
||||
'context_vars': ['tariff_name', 'period_days', 'cabinet_url', 'cabinet_email', 'cabinet_password'],
|
||||
},
|
||||
]
|
||||
|
||||
SAMPLE_CONTEXTS: dict[str, dict[str, Any]] = {
|
||||
@@ -315,26 +386,70 @@ SAMPLE_CONTEXTS: dict[str, dict[str, Any]] = {
|
||||
},
|
||||
'subscription_expiring': {'days_left': 3, 'expires_at': '2025-01-30'},
|
||||
'subscription_expired': {},
|
||||
'subscription_renewed': {'new_end_date': '2025-02-28', 'tariff_name': 'Premium'},
|
||||
'subscription_activated': {'tariff_name': 'Premium', 'end_date': '2025-02-28'},
|
||||
'autopay_success': {'formatted_amount': '300.00 ₽', 'formatted_balance': '200.00 ₽', 'new_end_date': '2025-02-28'},
|
||||
'subscription_renewed': {
|
||||
'new_expires_at': '2025-02-28',
|
||||
'tariff_name': 'Premium',
|
||||
'traffic_limit_gb': 100,
|
||||
'device_limit': 3,
|
||||
},
|
||||
'subscription_activated': {
|
||||
'expires_at': '2025-02-28',
|
||||
'tariff_name': 'Premium',
|
||||
'traffic_limit_gb': 100,
|
||||
'device_limit': 3,
|
||||
},
|
||||
'autopay_success': {'formatted_amount': '300.00 ₽', 'amount_rubles': 300, 'new_expires_at': '2025-02-28'},
|
||||
'autopay_failed': {'reason': 'Card declined'},
|
||||
'autopay_insufficient_funds': {'formatted_required': '300.00 ₽', 'formatted_balance': '50.00 ₽'},
|
||||
'daily_debit': {'formatted_amount': '10.00 ₽', 'formatted_balance': '490.00 ₽'},
|
||||
'daily_insufficient_funds': {'formatted_required': '10.00 ₽', 'formatted_balance': '5.00 ₽'},
|
||||
'autopay_insufficient_funds': {'required_amount': '300.00 ₽', 'current_balance': '50.00 ₽'},
|
||||
'daily_debit': {
|
||||
'formatted_amount': '10.00 ₽',
|
||||
'formatted_balance': '490.00 ₽',
|
||||
'amount_rubles': 10,
|
||||
'new_balance_rubles': 490,
|
||||
},
|
||||
'daily_insufficient_funds': {'required_amount': '10.00 ₽', 'current_balance': '5.00 ₽'},
|
||||
'ban_notification': {'reason': 'Violation of terms of service'},
|
||||
'unban_notification': {},
|
||||
'warning_notification': {'message': 'Please review our terms of service'},
|
||||
'referral_bonus': {'formatted_amount': '100.00 ₽', 'referral_name': 'John'},
|
||||
'referral_bonus': {'formatted_bonus': '100.00 ₽', 'bonus_rubles': 100, 'referral_name': 'John'},
|
||||
'referral_registered': {'referral_name': 'John'},
|
||||
'traffic_reset': {'traffic_limit': '100 GB'},
|
||||
'payment_received': {'formatted_amount': '500.00 ₽', 'payment_method': 'YooKassa'},
|
||||
'traffic_reset': {'reset_gb': 50, 'current_limit_gb': 100},
|
||||
'payment_received': {'formatted_amount': '500.00 ₽', 'amount_rubles': 500, 'payment_method': 'YooKassa'},
|
||||
'email_verification': {
|
||||
'username': 'John',
|
||||
'verification_url': 'https://example.com/verify?token=abc123',
|
||||
'expire_hours': 24,
|
||||
},
|
||||
'password_reset': {'username': 'John', 'reset_url': 'https://example.com/reset?token=abc123', 'expire_hours': 1},
|
||||
'guest_subscription_delivered': {
|
||||
'tariff_name': 'Premium',
|
||||
'period_days': 30,
|
||||
'cabinet_url': 'https://example.com/cabinet',
|
||||
'cabinet_email': 'user@example.com',
|
||||
'cabinet_password': 'SecurePass123',
|
||||
},
|
||||
'guest_activation_required': {
|
||||
'tariff_name': 'Premium',
|
||||
'period_days': 30,
|
||||
'success_page_url': 'https://example.com/cabinet/buy/success/abc123',
|
||||
'is_gift': True,
|
||||
'gift_message': 'Happy birthday!',
|
||||
},
|
||||
'guest_gift_received': {
|
||||
'tariff_name': 'Premium',
|
||||
'period_days': 30,
|
||||
'cabinet_url': 'https://example.com/cabinet',
|
||||
'gift_message': 'Happy birthday!',
|
||||
'cabinet_email': 'recipient@example.com',
|
||||
'cabinet_password': 'SecurePass123',
|
||||
},
|
||||
'guest_cabinet_credentials': {
|
||||
'tariff_name': 'Premium',
|
||||
'period_days': 30,
|
||||
'cabinet_url': 'https://example.com/cabinet',
|
||||
'cabinet_email': 'user@example.com',
|
||||
'cabinet_password': 'SecurePass123',
|
||||
},
|
||||
}
|
||||
|
||||
AVAILABLE_LANGUAGES = ['ru', 'en', 'zh', 'ua', 'fa']
|
||||
@@ -557,8 +672,8 @@ async def preview_template(
|
||||
language = data.language if data.language in AVAILABLE_LANGUAGES else 'ru'
|
||||
|
||||
if data.body_html:
|
||||
# Preview custom content wrapped in base template
|
||||
rendered_html = templates_instance._get_base_template(data.body_html, language)
|
||||
# Preview custom content — auto-detects styled vs simple HTML
|
||||
rendered_html = templates_instance._wrap_override_template(data.body_html, language)
|
||||
subject = data.subject or notification_type
|
||||
else:
|
||||
# Preview default template
|
||||
@@ -618,14 +733,13 @@ async def send_test_email(
|
||||
sample_context = SAMPLE_CONTEXTS.get(notification_type, {})
|
||||
templates_instance = EmailNotificationTemplates()
|
||||
|
||||
# Check for DB override
|
||||
from ..services.email_template_overrides import get_template_override
|
||||
# Check for DB override (get_rendered_override substitutes sample context vars)
|
||||
from ..services.email_template_overrides import get_rendered_override
|
||||
|
||||
override = await get_template_override(notification_type, language, db)
|
||||
rendered = await get_rendered_override(notification_type, language, sample_context, db)
|
||||
|
||||
if override:
|
||||
subject = override['subject']
|
||||
body_html = templates_instance._get_base_template(override['body_html'], language)
|
||||
if rendered:
|
||||
subject, body_html = rendered
|
||||
else:
|
||||
try:
|
||||
from app.services.notification_delivery_service import NotificationType
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
"""Admin routes for managing info pages in cabinet."""
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.crud.info_pages import (
|
||||
clear_replaces_tab,
|
||||
create_info_page,
|
||||
delete_info_page,
|
||||
get_all_info_pages,
|
||||
get_info_page_by_id,
|
||||
reorder_info_pages,
|
||||
update_info_page,
|
||||
)
|
||||
from app.database.models import User
|
||||
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
from ..schemas.info_pages import (
|
||||
InfoPageCreateRequest,
|
||||
InfoPageListItem,
|
||||
InfoPageResponse,
|
||||
InfoPageUpdateRequest,
|
||||
ReorderRequest,
|
||||
)
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/info-pages', tags=['Cabinet Admin Info Pages'])
|
||||
|
||||
|
||||
@router.get('', response_model=list[InfoPageListItem])
|
||||
async def list_all_info_pages(
|
||||
page_type: str | None = Query(None, pattern=r'^(page|faq)$'),
|
||||
admin: User = Depends(require_permission('info_pages:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> list[InfoPageListItem]:
|
||||
"""Get all info pages (admin view, includes inactive)."""
|
||||
try:
|
||||
pages = await get_all_info_pages(db, include_inactive=True, page_type=page_type)
|
||||
return [InfoPageListItem.model_validate(p) for p in pages]
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception('Failed to list info pages')
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to load info pages',
|
||||
)
|
||||
|
||||
|
||||
@router.get('/{page_id}', response_model=InfoPageResponse)
|
||||
async def get_info_page_detail(
|
||||
page_id: int,
|
||||
admin: User = Depends(require_permission('info_pages:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> InfoPageResponse:
|
||||
"""Get a single info page by ID (admin view)."""
|
||||
page = await get_info_page_by_id(db, page_id)
|
||||
if not page:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Info page not found',
|
||||
)
|
||||
return InfoPageResponse.model_validate(page)
|
||||
|
||||
|
||||
@router.post('', response_model=InfoPageResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_page(
|
||||
request: InfoPageCreateRequest,
|
||||
admin: User = Depends(require_permission('info_pages:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> InfoPageResponse:
|
||||
"""Create a new info page."""
|
||||
try:
|
||||
if request.replaces_tab:
|
||||
await clear_replaces_tab(db, request.replaces_tab)
|
||||
|
||||
page = await create_info_page(
|
||||
db,
|
||||
slug=request.slug,
|
||||
title=request.title,
|
||||
content=request.content,
|
||||
page_type=request.page_type,
|
||||
is_active=request.is_active,
|
||||
sort_order=request.sort_order,
|
||||
icon=request.icon,
|
||||
replaces_tab=request.replaces_tab,
|
||||
)
|
||||
except IntegrityError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail='An info page with this slug already exists',
|
||||
)
|
||||
except Exception:
|
||||
logger.exception('Failed to create info page')
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to create info page',
|
||||
)
|
||||
|
||||
return InfoPageResponse.model_validate(page)
|
||||
|
||||
|
||||
@router.put('/{page_id}', response_model=InfoPageResponse)
|
||||
async def update_page(
|
||||
page_id: int,
|
||||
request: InfoPageUpdateRequest,
|
||||
admin: User = Depends(require_permission('info_pages:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> InfoPageResponse:
|
||||
"""Update an existing info page."""
|
||||
existing = await get_info_page_by_id(db, page_id)
|
||||
if not existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Info page not found',
|
||||
)
|
||||
|
||||
try:
|
||||
update_data = request.model_dump(exclude_unset=True)
|
||||
|
||||
replaces_tab = update_data.get('replaces_tab')
|
||||
if replaces_tab is not None:
|
||||
await clear_replaces_tab(db, replaces_tab, exclude_page_id=page_id)
|
||||
|
||||
page = await update_info_page(db, page_id, **update_data)
|
||||
except IntegrityError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail='An info page with this slug already exists',
|
||||
)
|
||||
except Exception:
|
||||
logger.exception('Failed to update info page', page_id=page_id)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to update info page',
|
||||
)
|
||||
|
||||
if not page:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Info page not found after update',
|
||||
)
|
||||
return InfoPageResponse.model_validate(page)
|
||||
|
||||
|
||||
@router.delete('/{page_id}', status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def remove_page(
|
||||
page_id: int,
|
||||
admin: User = Depends(require_permission('info_pages:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> None:
|
||||
"""Delete an info page."""
|
||||
existing = await get_info_page_by_id(db, page_id)
|
||||
if not existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Info page not found',
|
||||
)
|
||||
|
||||
try:
|
||||
await delete_info_page(db, page_id)
|
||||
except Exception:
|
||||
logger.exception('Failed to delete info page', page_id=page_id)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to delete info page',
|
||||
)
|
||||
|
||||
|
||||
@router.post('/reorder', status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def reorder_pages(
|
||||
request: ReorderRequest,
|
||||
admin: User = Depends(require_permission('info_pages:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> None:
|
||||
"""Bulk update sort_order for info pages."""
|
||||
try:
|
||||
await reorder_info_pages(db, request.items)
|
||||
except Exception:
|
||||
logger.exception('Failed to reorder info pages')
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to reorder info pages',
|
||||
)
|
||||
|
||||
|
||||
@router.post('/{page_id}/toggle-active', response_model=InfoPageResponse)
|
||||
async def toggle_active(
|
||||
page_id: int,
|
||||
admin: User = Depends(require_permission('info_pages:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> InfoPageResponse:
|
||||
"""Toggle the active status of an info page."""
|
||||
existing = await get_info_page_by_id(db, page_id)
|
||||
if not existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Info page not found',
|
||||
)
|
||||
|
||||
try:
|
||||
page = await update_info_page(db, page_id, is_active=not existing.is_active)
|
||||
except Exception:
|
||||
logger.exception('Failed to toggle info page active status', page_id=page_id)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to toggle active status',
|
||||
)
|
||||
|
||||
if not page:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Info page not found after toggle',
|
||||
)
|
||||
return InfoPageResponse.model_validate(page)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,398 @@
|
||||
"""Admin routes for cabinet menu layout configuration (rows + custom URL buttons).
|
||||
|
||||
Serves a MERGED view combining ``CABINET_MENU_LAYOUT`` (row arrangement, custom buttons)
|
||||
and ``CABINET_BUTTON_STYLES`` (per-section style/emoji/enabled/labels) to the frontend.
|
||||
On save, splits the payload back into two SystemSetting keys.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
from typing import Literal
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import User
|
||||
from app.utils.button_styles_cache import (
|
||||
ALLOWED_STYLE_VALUES,
|
||||
BOT_LOCALES,
|
||||
BUTTON_STYLES_KEY,
|
||||
DEFAULT_BUTTON_STYLES,
|
||||
get_cached_button_styles,
|
||||
load_button_styles_cache,
|
||||
)
|
||||
from app.utils.menu_layout_cache import (
|
||||
BUILTIN_SECTIONS,
|
||||
DEFAULT_MENU_LAYOUT,
|
||||
MENU_LAYOUT_KEY,
|
||||
VALID_CUSTOM_BUTTON_STYLES,
|
||||
get_cached_menu_layout,
|
||||
load_menu_layout_cache,
|
||||
)
|
||||
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/menu-layout', tags=['Admin Menu Layout'])
|
||||
|
||||
# ---- Constants ---------------------------------------------------------------
|
||||
|
||||
MAX_ROWS = 20
|
||||
MAX_BUTTONS_PER_ROW = 8 # Telegram inline keyboard limit
|
||||
MAX_LABEL_LENGTH = 100
|
||||
URL_PATTERN = re.compile(r'^(https?://|tg://)')
|
||||
|
||||
|
||||
# ---- Schemas -----------------------------------------------------------------
|
||||
|
||||
|
||||
class ButtonConfig(BaseModel):
|
||||
"""Configuration for a single button (built-in or custom URL)."""
|
||||
|
||||
id: str = Field(max_length=100)
|
||||
type: Literal['builtin', 'custom']
|
||||
style: str = Field(default='primary', max_length=20)
|
||||
icon_custom_emoji_id: str = Field(default='', max_length=100)
|
||||
enabled: bool = True
|
||||
labels: dict[str, str] = Field(default_factory=dict, max_length=10)
|
||||
url: str | None = Field(default=None, max_length=2048)
|
||||
open_in: Literal['external', 'webapp'] = 'external'
|
||||
|
||||
|
||||
class RowConfig(BaseModel):
|
||||
"""Configuration for a single row of buttons."""
|
||||
|
||||
id: str = Field(max_length=100)
|
||||
max_per_row: int = Field(default=2, ge=1, le=3)
|
||||
buttons: list[ButtonConfig] = Field(default_factory=list, max_length=MAX_BUTTONS_PER_ROW)
|
||||
|
||||
|
||||
class MenuConfigResponse(BaseModel):
|
||||
"""Full merged menu configuration returned to the frontend."""
|
||||
|
||||
rows: list[RowConfig]
|
||||
|
||||
|
||||
class MenuConfigUpdateRequest(BaseModel):
|
||||
"""Full menu configuration submitted by the frontend."""
|
||||
|
||||
rows: list[RowConfig] = Field(max_length=MAX_ROWS)
|
||||
|
||||
|
||||
# ---- Helpers -----------------------------------------------------------------
|
||||
|
||||
|
||||
async def _get_setting_value(db: AsyncSession, key: str) -> str | None:
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.database.models import SystemSetting
|
||||
|
||||
result = await db.execute(select(SystemSetting).where(SystemSetting.key == key))
|
||||
setting = result.scalar_one_or_none()
|
||||
return setting.value if setting else None
|
||||
|
||||
|
||||
async def _upsert_setting(db: AsyncSession, key: str, value: str) -> None:
|
||||
"""Insert or update a SystemSetting without committing."""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.database.models import SystemSetting
|
||||
|
||||
result = await db.execute(select(SystemSetting).where(SystemSetting.key == key))
|
||||
setting = result.scalar_one_or_none()
|
||||
if setting:
|
||||
setting.value = value
|
||||
else:
|
||||
setting = SystemSetting(key=key, value=value)
|
||||
db.add(setting)
|
||||
|
||||
|
||||
def _build_merged_response(
|
||||
layout: dict[str, object],
|
||||
button_styles: dict[str, dict],
|
||||
) -> MenuConfigResponse:
|
||||
"""Merge layout rows with button_styles into a unified response.
|
||||
|
||||
Built-in buttons get style/emoji/enabled/labels from ``button_styles``.
|
||||
Custom URL buttons get all config from layout's ``custom_buttons``.
|
||||
"""
|
||||
custom_buttons: dict[str, dict] = layout.get('custom_buttons', {})
|
||||
|
||||
# Collect row entries sorted numerically (row_1, row_2, ..., row_10, ...)
|
||||
row_keys = sorted(
|
||||
(k for k in layout if k.startswith('row_')),
|
||||
key=lambda k: int(k.split('_', 1)[1]) if k.split('_', 1)[1].isdigit() else 0,
|
||||
)
|
||||
|
||||
rows: list[RowConfig] = []
|
||||
for row_key in row_keys:
|
||||
row_data = layout[row_key]
|
||||
if not isinstance(row_data, dict):
|
||||
continue
|
||||
|
||||
raw_buttons: list[str] = row_data.get('buttons', [])
|
||||
max_per_row: int = row_data.get('max_per_row', 2)
|
||||
row_id: str = row_data.get('id', row_key)
|
||||
|
||||
merged_buttons: list[ButtonConfig] = []
|
||||
for btn_id in raw_buttons:
|
||||
if btn_id in BUILTIN_SECTIONS:
|
||||
# Built-in: pull style data from button_styles cache
|
||||
style_cfg = button_styles.get(btn_id, {})
|
||||
merged_buttons.append(
|
||||
ButtonConfig(
|
||||
id=btn_id,
|
||||
type='builtin',
|
||||
style=style_cfg.get('style', 'primary'),
|
||||
icon_custom_emoji_id=style_cfg.get('icon_custom_emoji_id', ''),
|
||||
enabled=style_cfg.get('enabled', True),
|
||||
labels=style_cfg.get('labels', {}),
|
||||
),
|
||||
)
|
||||
elif btn_id.startswith('custom_') and btn_id in custom_buttons:
|
||||
# Custom URL button: pull config from layout's custom_buttons
|
||||
cb = custom_buttons[btn_id]
|
||||
merged_buttons.append(
|
||||
ButtonConfig(
|
||||
id=btn_id,
|
||||
type='custom',
|
||||
style=cb.get('style', 'primary'),
|
||||
icon_custom_emoji_id=cb.get('icon_custom_emoji_id', ''),
|
||||
enabled=cb.get('enabled', True),
|
||||
labels=cb.get('labels', {}),
|
||||
url=cb.get('url'),
|
||||
open_in=cb.get('open_in', 'external'),
|
||||
),
|
||||
)
|
||||
|
||||
rows.append(
|
||||
RowConfig(
|
||||
id=row_id,
|
||||
max_per_row=max_per_row,
|
||||
buttons=merged_buttons,
|
||||
),
|
||||
)
|
||||
|
||||
return MenuConfigResponse(rows=rows)
|
||||
|
||||
|
||||
def _split_update(
|
||||
rows: list[RowConfig],
|
||||
) -> tuple[dict[str, object], dict[str, dict]]:
|
||||
"""Split a flat list of RowConfig back into layout_data and button_styles_updates.
|
||||
|
||||
Returns:
|
||||
(layout_data, button_styles_updates)
|
||||
- layout_data: rows + custom_buttons for ``CABINET_MENU_LAYOUT``
|
||||
- button_styles_updates: ``{section: {style, icon_custom_emoji_id, enabled, labels}}``
|
||||
for built-in sections only
|
||||
"""
|
||||
layout_data: dict[str, object] = {}
|
||||
custom_buttons: dict[str, dict] = {}
|
||||
button_styles_updates: dict[str, dict] = {}
|
||||
|
||||
for idx, row in enumerate(rows, start=1):
|
||||
row_key = f'row_{idx}'
|
||||
button_ids: list[str] = []
|
||||
|
||||
for btn in row.buttons:
|
||||
button_ids.append(btn.id)
|
||||
|
||||
if btn.type == 'builtin' and btn.id in BUILTIN_SECTIONS:
|
||||
button_styles_updates[btn.id] = {
|
||||
'style': btn.style,
|
||||
'icon_custom_emoji_id': btn.icon_custom_emoji_id,
|
||||
'enabled': btn.enabled,
|
||||
'labels': btn.labels,
|
||||
}
|
||||
elif btn.type == 'custom' and btn.id.startswith('custom_'):
|
||||
custom_buttons[btn.id] = {
|
||||
'id': btn.id,
|
||||
'url': btn.url or '',
|
||||
'style': btn.style,
|
||||
'icon_custom_emoji_id': btn.icon_custom_emoji_id,
|
||||
'enabled': btn.enabled,
|
||||
'labels': btn.labels,
|
||||
'open_in': btn.open_in,
|
||||
}
|
||||
|
||||
layout_data[row_key] = {
|
||||
'id': row.id or row_key,
|
||||
'buttons': button_ids,
|
||||
'max_per_row': row.max_per_row,
|
||||
}
|
||||
|
||||
layout_data['custom_buttons'] = custom_buttons
|
||||
return layout_data, button_styles_updates
|
||||
|
||||
|
||||
def _validate_update_payload(rows: list[RowConfig]) -> None:
|
||||
"""Validate the full update payload. Raises HTTPException on failure."""
|
||||
if len(rows) > MAX_ROWS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Too many rows: {len(rows)}. Maximum allowed: {MAX_ROWS}.',
|
||||
)
|
||||
|
||||
# Check for duplicate button IDs across all rows
|
||||
seen_ids: set[str] = set()
|
||||
for row in rows:
|
||||
for btn in row.buttons:
|
||||
if btn.id in seen_ids:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Duplicate button ID: "{btn.id}". Each button can only appear once.',
|
||||
)
|
||||
seen_ids.add(btn.id)
|
||||
|
||||
for row in rows:
|
||||
if len(row.buttons) > MAX_BUTTONS_PER_ROW:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Row "{row.id}" has {len(row.buttons)} buttons. Maximum per row: {MAX_BUTTONS_PER_ROW}.',
|
||||
)
|
||||
|
||||
for btn in row.buttons:
|
||||
# Validate button type consistency
|
||||
if btn.type == 'builtin' and btn.id not in BUILTIN_SECTIONS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Unknown built-in section: "{btn.id}".',
|
||||
)
|
||||
|
||||
if btn.type == 'custom' and not btn.id.startswith('custom_'):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Custom button id must start with "custom_": "{btn.id}".',
|
||||
)
|
||||
|
||||
# Validate URL for custom buttons
|
||||
if btn.type == 'custom':
|
||||
if not btn.url or not URL_PATTERN.match(btn.url):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Custom button "{btn.id}" must have a URL starting with http://, https://, or tg://.',
|
||||
)
|
||||
if btn.open_in == 'webapp' and not btn.url.startswith('https://'):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Custom button "{btn.id}" with webapp mode requires an https:// URL.',
|
||||
)
|
||||
|
||||
# Validate style
|
||||
all_allowed = ALLOWED_STYLE_VALUES | VALID_CUSTOM_BUTTON_STYLES
|
||||
if btn.style not in all_allowed:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Invalid style "{btn.style}" for button "{btn.id}". '
|
||||
f'Allowed: {", ".join(sorted(all_allowed))}.',
|
||||
)
|
||||
|
||||
# Validate labels
|
||||
for locale_key, label_val in btn.labels.items():
|
||||
if locale_key not in BOT_LOCALES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Invalid locale "{locale_key}" for button "{btn.id}". '
|
||||
f'Allowed: {", ".join(BOT_LOCALES)}.',
|
||||
)
|
||||
if not isinstance(label_val, str):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Label value for locale "{locale_key}" must be a string.',
|
||||
)
|
||||
if len(label_val.strip()) > MAX_LABEL_LENGTH:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Label for locale "{locale_key}" on button "{btn.id}" '
|
||||
f'exceeds {MAX_LABEL_LENGTH} characters.',
|
||||
)
|
||||
|
||||
|
||||
# ---- Routes ------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get('', response_model=MenuConfigResponse)
|
||||
async def get_menu_layout(
|
||||
_admin: User = Depends(require_permission('settings:read')),
|
||||
):
|
||||
"""Return merged menu layout config (rows + button styles). Admin only."""
|
||||
layout = get_cached_menu_layout()
|
||||
button_styles = get_cached_button_styles()
|
||||
return _build_merged_response(layout, button_styles)
|
||||
|
||||
|
||||
@router.put('', response_model=MenuConfigResponse)
|
||||
async def update_menu_layout(
|
||||
payload: MenuConfigUpdateRequest,
|
||||
admin: User = Depends(require_permission('settings:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Save full menu layout config. Splits into layout + button styles. Admin only."""
|
||||
_validate_update_payload(payload.rows)
|
||||
|
||||
layout_data, button_styles_updates = _split_update(payload.rows)
|
||||
|
||||
# Save layout to CABINET_MENU_LAYOUT (without committing)
|
||||
await _upsert_setting(db, MENU_LAYOUT_KEY, json.dumps(layout_data))
|
||||
|
||||
# Merge button styles updates with existing styles (don't overwrite sections not in request)
|
||||
if button_styles_updates:
|
||||
raw = await _get_setting_value(db, BUTTON_STYLES_KEY)
|
||||
current_styles: dict[str, dict] = {}
|
||||
if raw:
|
||||
try:
|
||||
current_styles = json.loads(raw)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
current_styles = {}
|
||||
|
||||
for section, updates in button_styles_updates.items():
|
||||
current_styles[section] = updates
|
||||
|
||||
await _upsert_setting(db, BUTTON_STYLES_KEY, json.dumps(current_styles))
|
||||
|
||||
# Single atomic commit for both settings
|
||||
await db.commit()
|
||||
|
||||
# Refresh caches after commit
|
||||
await load_button_styles_cache()
|
||||
await load_menu_layout_cache()
|
||||
|
||||
logger.info(
|
||||
'Admin updated menu layout',
|
||||
telegram_id=admin.telegram_id,
|
||||
rows_count=len(payload.rows),
|
||||
custom_buttons_count=len(layout_data.get('custom_buttons', {})),
|
||||
)
|
||||
|
||||
# Return merged response from fresh caches
|
||||
layout = get_cached_menu_layout()
|
||||
button_styles = get_cached_button_styles()
|
||||
return _build_merged_response(layout, button_styles)
|
||||
|
||||
|
||||
@router.post('/reset', response_model=MenuConfigResponse)
|
||||
async def reset_menu_layout(
|
||||
admin: User = Depends(require_permission('settings:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Reset menu layout AND button styles to defaults. Admin only."""
|
||||
await _upsert_setting(db, MENU_LAYOUT_KEY, json.dumps(DEFAULT_MENU_LAYOUT))
|
||||
await _upsert_setting(db, BUTTON_STYLES_KEY, json.dumps(DEFAULT_BUTTON_STYLES))
|
||||
|
||||
# Single atomic commit for both settings
|
||||
await db.commit()
|
||||
|
||||
# Refresh caches after commit
|
||||
await load_button_styles_cache()
|
||||
await load_menu_layout_cache()
|
||||
|
||||
logger.info('Admin reset menu layout and button styles to defaults', telegram_id=admin.telegram_id)
|
||||
|
||||
layout = get_cached_menu_layout()
|
||||
button_styles = get_cached_button_styles()
|
||||
return _build_merged_response(layout, button_styles)
|
||||
@@ -0,0 +1,343 @@
|
||||
"""Admin routes for managing news articles in cabinet."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.crud.news import (
|
||||
create_news_article,
|
||||
delete_news_article,
|
||||
get_all_news,
|
||||
get_all_news_count,
|
||||
get_news_article_by_id,
|
||||
unfeature_all_news,
|
||||
update_news_article,
|
||||
)
|
||||
from app.database.crud.news_categories import get_category_by_id
|
||||
from app.database.crud.news_tags import get_tag_by_id
|
||||
from app.database.models import NewsArticle, User
|
||||
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
from ..schemas.news import (
|
||||
NewsArticleListItem,
|
||||
NewsArticleResponse,
|
||||
NewsCreateRequest,
|
||||
NewsListResponse,
|
||||
NewsToggleResponse,
|
||||
NewsUpdateRequest,
|
||||
)
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/news', tags=['Cabinet Admin News'])
|
||||
|
||||
|
||||
def _article_to_detail(article: NewsArticle) -> dict[str, Any]:
|
||||
"""Convert NewsArticle ORM instance to full detail dict.
|
||||
|
||||
Expects the ``author`` relationship to be eagerly loaded.
|
||||
"""
|
||||
author_name: str | None = None
|
||||
if article.author:
|
||||
author_name = article.author.first_name or article.author.username or f'#{article.author.id}'
|
||||
|
||||
return {
|
||||
'id': article.id,
|
||||
'title': article.title,
|
||||
'slug': article.slug,
|
||||
'content': article.content,
|
||||
'excerpt': article.excerpt,
|
||||
'category': article.category,
|
||||
'category_color': article.category_color,
|
||||
'tag': article.tag,
|
||||
'category_id': article.category_id,
|
||||
'tag_id': article.tag_id,
|
||||
'featured_image_url': article.featured_image_url,
|
||||
'is_published': article.is_published,
|
||||
'is_featured': article.is_featured,
|
||||
'published_at': article.published_at,
|
||||
'read_time_minutes': article.read_time_minutes,
|
||||
'views_count': article.views_count,
|
||||
'author_name': author_name,
|
||||
'created_at': article.created_at,
|
||||
'updated_at': article.updated_at,
|
||||
}
|
||||
|
||||
|
||||
@router.get('', response_model=NewsListResponse)
|
||||
async def list_all_news(
|
||||
admin: User = Depends(require_permission('news:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
) -> NewsListResponse:
|
||||
"""Get all news articles (admin view, includes unpublished)."""
|
||||
try:
|
||||
articles = await get_all_news(db, limit=limit, offset=offset)
|
||||
total = await get_all_news_count(db)
|
||||
|
||||
items = [NewsArticleListItem.model_validate(a) for a in articles]
|
||||
|
||||
return NewsListResponse(items=items, total=total)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception('Failed to list all news')
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to load news articles',
|
||||
)
|
||||
|
||||
|
||||
@router.get('/{article_id}', response_model=NewsArticleResponse)
|
||||
async def get_article_detail(
|
||||
article_id: int,
|
||||
admin: User = Depends(require_permission('news:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> NewsArticleResponse:
|
||||
"""Get a single news article by ID (admin view)."""
|
||||
article = await get_news_article_by_id(db, article_id)
|
||||
if not article:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Article not found',
|
||||
)
|
||||
|
||||
return NewsArticleResponse(**_article_to_detail(article))
|
||||
|
||||
|
||||
@router.post('', response_model=NewsArticleResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_article(
|
||||
request: NewsCreateRequest,
|
||||
admin: User = Depends(require_permission('news:create')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> NewsArticleResponse:
|
||||
"""Create a new news article."""
|
||||
try:
|
||||
# Resolve category from FK -- sync legacy string fields from the managed entity
|
||||
category_name = request.category
|
||||
category_color = request.category_color
|
||||
if request.category_id is not None:
|
||||
cat = await get_category_by_id(db, request.category_id)
|
||||
if not cat:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f'Category with id={request.category_id} not found',
|
||||
)
|
||||
category_name = cat.name
|
||||
category_color = cat.color
|
||||
|
||||
# Resolve tag from FK -- sync legacy string field from the managed entity
|
||||
tag_name = request.tag
|
||||
if request.tag_id is not None:
|
||||
tag_obj = await get_tag_by_id(db, request.tag_id)
|
||||
if not tag_obj:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f'Tag with id={request.tag_id} not found',
|
||||
)
|
||||
tag_name = tag_obj.name
|
||||
|
||||
if request.is_featured:
|
||||
await unfeature_all_news(db)
|
||||
article = await create_news_article(
|
||||
db,
|
||||
title=request.title,
|
||||
slug=request.slug,
|
||||
content=request.content,
|
||||
excerpt=request.excerpt,
|
||||
category=category_name,
|
||||
category_color=category_color,
|
||||
tag=tag_name,
|
||||
category_id=request.category_id,
|
||||
tag_id=request.tag_id,
|
||||
featured_image_url=request.featured_image_url,
|
||||
is_published=request.is_published,
|
||||
is_featured=request.is_featured,
|
||||
read_time_minutes=request.read_time_minutes,
|
||||
created_by=admin.id,
|
||||
)
|
||||
except IntegrityError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail='An article with this slug already exists',
|
||||
)
|
||||
except Exception:
|
||||
logger.exception('Failed to create news article')
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to create article',
|
||||
)
|
||||
|
||||
# Reload with author relationship
|
||||
article = await get_news_article_by_id(db, article.id)
|
||||
if not article:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to reload article after creation',
|
||||
)
|
||||
return NewsArticleResponse(**_article_to_detail(article))
|
||||
|
||||
|
||||
@router.put('/{article_id}', response_model=NewsArticleResponse)
|
||||
async def update_article(
|
||||
article_id: int,
|
||||
request: NewsUpdateRequest,
|
||||
admin: User = Depends(require_permission('news:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> NewsArticleResponse:
|
||||
"""Update an existing news article."""
|
||||
article = await get_news_article_by_id(db, article_id)
|
||||
if not article:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Article not found',
|
||||
)
|
||||
|
||||
try:
|
||||
update_data = request.model_dump(exclude_unset=True)
|
||||
|
||||
# Resolve category from FK -- sync legacy string fields from the managed entity
|
||||
if 'category_id' in update_data and update_data['category_id'] is not None:
|
||||
cat = await get_category_by_id(db, update_data['category_id'])
|
||||
if not cat:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f'Category with id={update_data["category_id"]} not found',
|
||||
)
|
||||
update_data['category'] = cat.name
|
||||
update_data['category_color'] = cat.color
|
||||
|
||||
# Resolve tag from FK -- sync legacy string field from the managed entity
|
||||
if 'tag_id' in update_data and update_data['tag_id'] is not None:
|
||||
tag_obj = await get_tag_by_id(db, update_data['tag_id'])
|
||||
if not tag_obj:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f'Tag with id={update_data["tag_id"]} not found',
|
||||
)
|
||||
update_data['tag'] = tag_obj.name
|
||||
|
||||
if update_data.get('is_featured'):
|
||||
await unfeature_all_news(db)
|
||||
article = await update_news_article(db, article, **update_data)
|
||||
except IntegrityError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail='An article with this slug already exists',
|
||||
)
|
||||
except Exception:
|
||||
logger.exception('Failed to update news article', article_id=article_id)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to update article',
|
||||
)
|
||||
|
||||
# Reload with author relationship (update used bulk UPDATE, author not populated)
|
||||
article = await get_news_article_by_id(db, article.id)
|
||||
if not article:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to reload article after update',
|
||||
)
|
||||
return NewsArticleResponse(**_article_to_detail(article))
|
||||
|
||||
|
||||
@router.delete('/{article_id}', status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def remove_article(
|
||||
article_id: int,
|
||||
admin: User = Depends(require_permission('news:delete')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> None:
|
||||
"""Delete a news article."""
|
||||
article = await get_news_article_by_id(db, article_id)
|
||||
if not article:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Article not found',
|
||||
)
|
||||
|
||||
try:
|
||||
await delete_news_article(db, article)
|
||||
except Exception:
|
||||
logger.exception('Failed to delete news article', article_id=article_id)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to delete article',
|
||||
)
|
||||
|
||||
|
||||
@router.post('/{article_id}/publish', response_model=NewsToggleResponse)
|
||||
async def toggle_publish(
|
||||
article_id: int,
|
||||
admin: User = Depends(require_permission('news:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> NewsToggleResponse:
|
||||
"""Toggle the published status of a news article."""
|
||||
article = await get_news_article_by_id(db, article_id)
|
||||
if not article:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Article not found',
|
||||
)
|
||||
|
||||
new_published = not article.is_published
|
||||
|
||||
update_kwargs: dict[str, Any] = {'is_published': new_published}
|
||||
# Auto-set published_at on first publish
|
||||
if new_published and article.published_at is None:
|
||||
update_kwargs['published_at'] = datetime.now(UTC)
|
||||
|
||||
try:
|
||||
article = await update_news_article(db, article, **update_kwargs)
|
||||
return NewsToggleResponse(
|
||||
id=article.id,
|
||||
is_published=article.is_published,
|
||||
is_featured=article.is_featured,
|
||||
published_at=article.published_at,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception('Failed to toggle publish', article_id=article_id)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to toggle publish status',
|
||||
)
|
||||
|
||||
|
||||
@router.post('/{article_id}/feature', response_model=NewsToggleResponse)
|
||||
async def toggle_featured(
|
||||
article_id: int,
|
||||
admin: User = Depends(require_permission('news:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> NewsToggleResponse:
|
||||
"""Toggle the featured status of a news article."""
|
||||
article = await get_news_article_by_id(db, article_id)
|
||||
if not article:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Article not found',
|
||||
)
|
||||
|
||||
try:
|
||||
new_featured = not article.is_featured
|
||||
# Only one article can be featured at a time — unfeature all others first
|
||||
if new_featured:
|
||||
await unfeature_all_news(db)
|
||||
article = await update_news_article(db, article, is_featured=new_featured)
|
||||
return NewsToggleResponse(
|
||||
id=article.id,
|
||||
is_published=article.is_published,
|
||||
is_featured=article.is_featured,
|
||||
published_at=article.published_at,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception('Failed to toggle featured', article_id=article_id)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to toggle featured status',
|
||||
)
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Admin routes for managing news categories."""
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.crud.news_categories import (
|
||||
create_category,
|
||||
delete_category,
|
||||
get_all_categories,
|
||||
get_category_by_id,
|
||||
update_category,
|
||||
)
|
||||
from app.database.models import User
|
||||
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
from ..schemas.news_categories import NewsCategoryCreate, NewsCategoryResponse, NewsCategoryUpdate
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/news/categories', tags=['Cabinet Admin News Categories'])
|
||||
|
||||
|
||||
@router.get('', response_model=list[NewsCategoryResponse])
|
||||
async def list_categories(
|
||||
admin: User = Depends(require_permission('news:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> list[NewsCategoryResponse]:
|
||||
"""Get all news categories."""
|
||||
categories = await get_all_categories(db)
|
||||
return [NewsCategoryResponse.model_validate(c) for c in categories]
|
||||
|
||||
|
||||
@router.post('', response_model=NewsCategoryResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_new_category(
|
||||
request: NewsCategoryCreate,
|
||||
admin: User = Depends(require_permission('news:create')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> NewsCategoryResponse:
|
||||
"""Create a new news category."""
|
||||
try:
|
||||
category = await create_category(db, name=request.name, color=request.color)
|
||||
except IntegrityError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail='Category already exists',
|
||||
)
|
||||
return NewsCategoryResponse.model_validate(category)
|
||||
|
||||
|
||||
@router.put('/{category_id}', response_model=NewsCategoryResponse)
|
||||
async def update_existing_category(
|
||||
category_id: int,
|
||||
request: NewsCategoryUpdate,
|
||||
admin: User = Depends(require_permission('news:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> NewsCategoryResponse:
|
||||
"""Update an existing news category."""
|
||||
category = await get_category_by_id(db, category_id)
|
||||
if not category:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Category not found',
|
||||
)
|
||||
try:
|
||||
category = await update_category(db, category, **request.model_dump(exclude_unset=True))
|
||||
except IntegrityError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail='Category name already exists',
|
||||
)
|
||||
return NewsCategoryResponse.model_validate(category)
|
||||
|
||||
|
||||
@router.delete('/{category_id}', status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def remove_category(
|
||||
category_id: int,
|
||||
admin: User = Depends(require_permission('news:delete')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> None:
|
||||
"""Delete a news category. Articles using it will have category_id set to NULL."""
|
||||
category = await get_category_by_id(db, category_id)
|
||||
if not category:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Category not found',
|
||||
)
|
||||
await delete_category(db, category)
|
||||
@@ -0,0 +1,157 @@
|
||||
"""Admin routes for managing news article media (images/videos)."""
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile, status
|
||||
from PIL import Image as PILImage
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.services.news_media_service import (
|
||||
SavedMedia,
|
||||
delete_media_file,
|
||||
detect_file_type,
|
||||
ensure_upload_dirs,
|
||||
save_image,
|
||||
save_video,
|
||||
)
|
||||
|
||||
from ..dependencies import require_permission
|
||||
from ..schemas.news_media import NewsMediaUploadResponse
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
_BYTES_PER_MB = 1024 * 1024
|
||||
|
||||
# Only allow UUID-hex filenames with expected extensions (path traversal defense-in-depth).
|
||||
# thumb_ prefix is NOT allowed — thumbnails are cleaned up automatically when the main file is deleted.
|
||||
_SAFE_FILENAME_RE = re.compile(r'^[0-9a-f]{32}\.(jpg|mp4|webm)$')
|
||||
|
||||
router = APIRouter(prefix='/admin/news/media', tags=['Cabinet Admin News Media'])
|
||||
|
||||
|
||||
_ALLOWED_SCHEMES = frozenset({'http', 'https'})
|
||||
|
||||
|
||||
def _build_media_url(request: Request, relative_path: str) -> str:
|
||||
"""Build a full URL for a media file, respecting reverse proxy headers."""
|
||||
proto = request.headers.get('X-Forwarded-Proto', request.url.scheme).split(',')[0].strip()
|
||||
if proto not in _ALLOWED_SCHEMES:
|
||||
proto = 'https'
|
||||
host = request.headers.get('X-Forwarded-Host', request.headers.get('Host', request.url.netloc))
|
||||
host = host.split(',')[0].strip()
|
||||
return f'{proto}://{host}/uploads/{relative_path}'
|
||||
|
||||
|
||||
def _build_response(request: Request, saved: SavedMedia) -> NewsMediaUploadResponse:
|
||||
"""Convert SavedMedia to API response with full URLs."""
|
||||
thumbnail_url = _build_media_url(request, saved.thumbnail_path) if saved.thumbnail_path else None
|
||||
|
||||
return NewsMediaUploadResponse(
|
||||
url=_build_media_url(request, saved.relative_path),
|
||||
thumbnail_url=thumbnail_url,
|
||||
media_type=saved.media_type,
|
||||
filename=saved.filename,
|
||||
size_bytes=saved.size_bytes,
|
||||
width=saved.width,
|
||||
height=saved.height,
|
||||
)
|
||||
|
||||
|
||||
@router.post('/upload', response_model=NewsMediaUploadResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def upload_media(
|
||||
request: Request,
|
||||
file: UploadFile = File(...),
|
||||
admin: User = Depends(require_permission('news:edit')),
|
||||
) -> NewsMediaUploadResponse:
|
||||
"""Upload an image or video for a news article."""
|
||||
# Read with a hard budget to prevent memory exhaustion from huge uploads.
|
||||
# Read slightly over the max allowed size so we can detect oversized files.
|
||||
absolute_max_bytes = settings.MEDIA_MAX_VIDEO_SIZE_MB * _BYTES_PER_MB + 1
|
||||
data = await file.read(absolute_max_bytes)
|
||||
await file.close()
|
||||
if not data:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Empty file',
|
||||
)
|
||||
|
||||
if len(data) >= absolute_max_bytes:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||||
detail=f'File too large. Absolute maximum: {settings.MEDIA_MAX_VIDEO_SIZE_MB} MB',
|
||||
)
|
||||
|
||||
# Detect type from magic bytes
|
||||
try:
|
||||
media_type, _ext = detect_file_type(data)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
|
||||
detail='Unsupported file type. Allowed: JPEG, PNG, WebP, MP4, WebM',
|
||||
) from None
|
||||
|
||||
# Enforce per-type size limits
|
||||
max_size_mb = settings.MEDIA_MAX_IMAGE_SIZE_MB if media_type == 'image' else settings.MEDIA_MAX_VIDEO_SIZE_MB
|
||||
if len(data) > max_size_mb * _BYTES_PER_MB:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||||
detail=f'File too large. Maximum size for {media_type}: {max_size_mb} MB',
|
||||
)
|
||||
|
||||
upload_path = settings.get_media_upload_path()
|
||||
await asyncio.to_thread(ensure_upload_dirs, upload_path)
|
||||
|
||||
try:
|
||||
if media_type == 'image':
|
||||
saved = await save_image(
|
||||
data,
|
||||
upload_path,
|
||||
max_dim=settings.MEDIA_IMAGE_MAX_DIMENSION,
|
||||
quality=settings.MEDIA_JPEG_QUALITY,
|
||||
)
|
||||
else:
|
||||
saved = await save_video(data, upload_path)
|
||||
except (ValueError, OSError, PILImage.DecompressionBombError) as exc:
|
||||
logger.warning('Failed to save uploaded media', media_type=media_type, error=str(exc))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail='Failed to process uploaded file',
|
||||
) from None
|
||||
|
||||
logger.info(
|
||||
'Media uploaded',
|
||||
filename=saved.filename,
|
||||
media_type=saved.media_type,
|
||||
size_bytes=saved.size_bytes,
|
||||
admin_id=admin.id,
|
||||
)
|
||||
|
||||
return _build_response(request, saved)
|
||||
|
||||
|
||||
@router.delete('/{filename}', status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_media(
|
||||
filename: str,
|
||||
admin: User = Depends(require_permission('news:delete')),
|
||||
) -> None:
|
||||
"""Delete a previously uploaded media file."""
|
||||
if not _SAFE_FILENAME_RE.match(filename):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Invalid filename',
|
||||
)
|
||||
|
||||
upload_path = settings.get_media_upload_path()
|
||||
|
||||
deleted = await asyncio.to_thread(delete_media_file, filename, upload_path)
|
||||
if not deleted:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='File not found',
|
||||
)
|
||||
|
||||
logger.info('Media deleted', filename=filename, admin_id=admin.id)
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Admin routes for managing news tags."""
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.crud.news_tags import (
|
||||
create_tag,
|
||||
delete_tag,
|
||||
get_all_tags,
|
||||
get_tag_by_id,
|
||||
update_tag,
|
||||
)
|
||||
from app.database.models import User
|
||||
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
from ..schemas.news_tags import NewsTagCreate, NewsTagResponse, NewsTagUpdate
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/news/tags', tags=['Cabinet Admin News Tags'])
|
||||
|
||||
|
||||
@router.get('', response_model=list[NewsTagResponse])
|
||||
async def list_tags(
|
||||
admin: User = Depends(require_permission('news:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> list[NewsTagResponse]:
|
||||
"""Get all news tags."""
|
||||
tags = await get_all_tags(db)
|
||||
return [NewsTagResponse.model_validate(t) for t in tags]
|
||||
|
||||
|
||||
@router.post('', response_model=NewsTagResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_new_tag(
|
||||
request: NewsTagCreate,
|
||||
admin: User = Depends(require_permission('news:create')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> NewsTagResponse:
|
||||
"""Create a new news tag."""
|
||||
try:
|
||||
tag = await create_tag(db, name=request.name, color=request.color)
|
||||
except IntegrityError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail='Tag already exists',
|
||||
)
|
||||
return NewsTagResponse.model_validate(tag)
|
||||
|
||||
|
||||
@router.put('/{tag_id}', response_model=NewsTagResponse)
|
||||
async def update_existing_tag(
|
||||
tag_id: int,
|
||||
request: NewsTagUpdate,
|
||||
admin: User = Depends(require_permission('news:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> NewsTagResponse:
|
||||
"""Update an existing news tag."""
|
||||
tag = await get_tag_by_id(db, tag_id)
|
||||
if not tag:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Tag not found',
|
||||
)
|
||||
try:
|
||||
tag = await update_tag(db, tag, **request.model_dump(exclude_unset=True))
|
||||
except IntegrityError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail='Tag name already exists',
|
||||
)
|
||||
return NewsTagResponse.model_validate(tag)
|
||||
|
||||
|
||||
@router.delete('/{tag_id}', status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def remove_tag(
|
||||
tag_id: int,
|
||||
admin: User = Depends(require_permission('news:delete')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> None:
|
||||
"""Delete a news tag. Articles using it will have tag_id set to NULL."""
|
||||
tag = await get_tag_by_id(db, tag_id)
|
||||
if not tag:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Tag not found',
|
||||
)
|
||||
await delete_tag(db, tag)
|
||||
@@ -85,6 +85,7 @@ async def update_partner_settings(
|
||||
admin: User = Depends(require_permission('partners:settings')),
|
||||
):
|
||||
"""Update partner system settings."""
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
# Update in-memory settings
|
||||
@@ -104,8 +105,8 @@ async def update_partner_settings(
|
||||
# Persist to .env file
|
||||
try:
|
||||
env_file = Path('.env')
|
||||
if env_file.exists():
|
||||
lines = env_file.read_text().splitlines()
|
||||
if await asyncio.to_thread(env_file.exists):
|
||||
lines = (await asyncio.to_thread(env_file.read_text)).splitlines()
|
||||
updates: dict[str, str] = {}
|
||||
|
||||
if request.withdrawal_enabled is not None:
|
||||
@@ -143,7 +144,7 @@ async def update_partner_settings(
|
||||
if key not in updated_keys:
|
||||
new_lines.append(f'{key}={value}')
|
||||
|
||||
env_file.write_text('\n'.join(new_lines) + '\n')
|
||||
await asyncio.to_thread(env_file.write_text, '\n'.join(new_lines) + '\n')
|
||||
logger.info('Updated partner settings in .env file', admin_id=admin.id)
|
||||
except Exception as e:
|
||||
logger.warning('Failed to update .env file', error=e)
|
||||
@@ -226,8 +227,7 @@ async def approve_application(
|
||||
|
||||
# Notify user about approval
|
||||
try:
|
||||
from aiogram import Bot
|
||||
|
||||
from app.bot_factory import create_bot
|
||||
from app.config import settings
|
||||
from app.services.notification_delivery_service import notification_delivery_service
|
||||
|
||||
@@ -239,7 +239,7 @@ async def approve_application(
|
||||
tg_message = (
|
||||
f'✅ Ваша заявка на партнёрство одобрена!\nКомиссия: {request.commission_percent}%{comment_text}'
|
||||
)
|
||||
bot = Bot(token=settings.BOT_TOKEN)
|
||||
bot = create_bot()
|
||||
try:
|
||||
await notification_delivery_service.notify_partner_approved(
|
||||
user=user,
|
||||
@@ -279,8 +279,7 @@ async def reject_application(
|
||||
|
||||
# Notify user about rejection
|
||||
try:
|
||||
from aiogram import Bot
|
||||
|
||||
from app.bot_factory import create_bot
|
||||
from app.config import settings
|
||||
from app.services.notification_delivery_service import notification_delivery_service
|
||||
|
||||
@@ -290,7 +289,7 @@ async def reject_application(
|
||||
if user:
|
||||
comment_text = f'\nПричина: {request.comment}' if request.comment else ''
|
||||
tg_message = f'❌ Ваша заявка на партнёрство отклонена.{comment_text}'
|
||||
bot = Bot(token=settings.BOT_TOKEN)
|
||||
bot = create_bot()
|
||||
try:
|
||||
await notification_delivery_service.notify_partner_rejected(
|
||||
user=user,
|
||||
|
||||
@@ -4,7 +4,7 @@ from datetime import datetime
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import User
|
||||
@@ -60,10 +60,23 @@ class PaymentMethodConfigResponse(BaseModel):
|
||||
class PaymentMethodConfigUpdateRequest(BaseModel):
|
||||
is_enabled: bool | None = None
|
||||
display_name: str | None = Field(default=None, description='Null to reset to default')
|
||||
sub_options: dict | None = None
|
||||
sub_options: dict[str, bool] | None = None
|
||||
min_amount_kopeks: int | None = Field(default=None, ge=0)
|
||||
max_amount_kopeks: int | None = Field(default=None, ge=0)
|
||||
user_type_filter: str | None = Field(default=None, pattern='^(all|telegram|email)$')
|
||||
|
||||
@field_validator('sub_options', mode='before')
|
||||
@classmethod
|
||||
def validate_sub_options(cls, v: dict[str, bool] | None) -> dict[str, bool] | None:
|
||||
if not v:
|
||||
return None
|
||||
if len(v) > 20:
|
||||
raise ValueError('sub_options cannot have more than 20 keys')
|
||||
for key in v:
|
||||
if not isinstance(key, str) or len(key) > 50:
|
||||
raise ValueError('sub_options keys must be strings of at most 50 characters')
|
||||
return v
|
||||
|
||||
first_topup_filter: str | None = Field(default=None, pattern='^(any|yes|no)$')
|
||||
promo_group_filter_mode: str | None = Field(default=None, pattern='^(all|selected)$')
|
||||
allowed_promo_group_ids: list[int] | None = None
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
"""Admin routes for payment verification in cabinet."""
|
||||
|
||||
import math
|
||||
from datetime import datetime
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import structlog
|
||||
from aiogram import Bot
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.bot_factory import create_bot
|
||||
from app.database.models import PaymentMethod, User
|
||||
from app.services.payment_search_service import (
|
||||
MAX_ALL_TIME_DAYS,
|
||||
PeriodPreset,
|
||||
SearchParams,
|
||||
StatusFilter,
|
||||
search_payments,
|
||||
search_payments_stats,
|
||||
)
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.services.payment_verification_service import (
|
||||
SUPPORTED_MANUAL_CHECK_METHODS,
|
||||
@@ -54,6 +59,7 @@ class PendingPaymentResponse(BaseModel):
|
||||
user_id: int | None = None
|
||||
user_telegram_id: int | None = None
|
||||
user_username: str | None = None
|
||||
user_email: str | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -87,6 +93,16 @@ class PaymentsStatsResponse(BaseModel):
|
||||
by_method: dict
|
||||
|
||||
|
||||
class SearchStatsResponse(BaseModel):
|
||||
"""Statistics for payment search results."""
|
||||
|
||||
total: int
|
||||
pending: int
|
||||
paid: int
|
||||
cancelled: int
|
||||
by_method: dict
|
||||
|
||||
|
||||
# ============ Helper functions ============
|
||||
|
||||
|
||||
@@ -210,7 +226,7 @@ def _is_checkable(record: PendingPayment) -> bool:
|
||||
if record.method == PaymentMethod.YOOKASSA:
|
||||
return status_str in {'pending', 'waiting_for_capture'}
|
||||
if record.method == PaymentMethod.CRYPTOBOT:
|
||||
return status_str in {'active'}
|
||||
return status_str == 'active'
|
||||
if record.method == PaymentMethod.CLOUDPAYMENTS:
|
||||
return status_str in {'pending', 'authorized'}
|
||||
if record.method == PaymentMethod.FREEKASSA:
|
||||
@@ -241,6 +257,8 @@ def _get_payment_url(record: PendingPayment) -> str | None:
|
||||
elif record.method == PaymentMethod.CLOUDPAYMENTS or record.method == PaymentMethod.FREEKASSA:
|
||||
payment_url = getattr(payment, 'payment_url', None) or payment_url
|
||||
|
||||
if payment_url and not payment_url.startswith(('https://', 'http://')):
|
||||
return None
|
||||
return payment_url
|
||||
|
||||
|
||||
@@ -265,6 +283,7 @@ def _record_to_response(record: PendingPayment) -> PendingPaymentResponse:
|
||||
user_id=record.user.id if record.user else None,
|
||||
user_telegram_id=record.user.telegram_id if record.user else None,
|
||||
user_username=record.user.username if record.user else None,
|
||||
user_email=record.user.email if record.user else None,
|
||||
)
|
||||
|
||||
|
||||
@@ -329,6 +348,140 @@ async def get_payments_stats(
|
||||
)
|
||||
|
||||
|
||||
@router.get('/search', response_model=PendingPaymentListResponse)
|
||||
async def search_payments_endpoint(
|
||||
search: str | None = Query(
|
||||
None, max_length=256, description='Search query (invoice, @username, telegram_id, email)'
|
||||
),
|
||||
status_filter: str = Query('all', description='Status filter: all, pending, paid, cancelled'),
|
||||
method_filter: str | None = Query(None, description='Filter by payment method'),
|
||||
period: str = Query('24h', description='Period preset: 24h, 7d, 30d, all'),
|
||||
date_from: datetime | None = Query(None, description='Custom range start (ISO 8601)'),
|
||||
date_to: datetime | None = Query(None, description='Custom range end (ISO 8601)'),
|
||||
page: int = Query(1, ge=1, description='Page number'),
|
||||
per_page: int = Query(20, ge=1, le=100, description='Items per page'),
|
||||
admin: User = Depends(require_permission('payments:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Search payments across all providers with filters."""
|
||||
try:
|
||||
parsed_status = StatusFilter(status_filter)
|
||||
except ValueError:
|
||||
parsed_status = StatusFilter.ALL
|
||||
|
||||
try:
|
||||
parsed_period = PeriodPreset(period)
|
||||
except ValueError:
|
||||
parsed_period = PeriodPreset.H24
|
||||
|
||||
parsed_method: PaymentMethod | None = None
|
||||
if method_filter:
|
||||
try:
|
||||
parsed_method = PaymentMethod(method_filter)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Ensure custom dates are timezone-aware
|
||||
if date_from is not None and date_from.tzinfo is None:
|
||||
date_from = date_from.replace(tzinfo=UTC)
|
||||
if date_to is not None and date_to.tzinfo is None:
|
||||
date_to = date_to.replace(tzinfo=UTC)
|
||||
|
||||
# Clamp custom dates to safety limit
|
||||
min_allowed = datetime.now(UTC) - timedelta(days=MAX_ALL_TIME_DAYS)
|
||||
if date_from is not None and date_from < min_allowed:
|
||||
date_from = min_allowed
|
||||
if date_from is not None and date_to is not None and date_from > date_to:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='date_from must be before date_to')
|
||||
|
||||
params = SearchParams(
|
||||
search=search.strip() if search else None,
|
||||
status_filter=parsed_status,
|
||||
method_filter=parsed_method,
|
||||
period=parsed_period,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
)
|
||||
|
||||
page_items, total = await search_payments(db, params)
|
||||
pages = math.ceil(total / per_page) if total > 0 else 1
|
||||
items = [_record_to_response(p) for p in page_items]
|
||||
|
||||
return PendingPaymentListResponse(
|
||||
items=items,
|
||||
total=total,
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
pages=pages,
|
||||
)
|
||||
|
||||
|
||||
@router.get('/search/stats', response_model=SearchStatsResponse)
|
||||
async def search_payments_stats_endpoint(
|
||||
search: str | None = Query(
|
||||
None, max_length=256, description='Search query (invoice, @username, telegram_id, email)'
|
||||
),
|
||||
status_filter: str = Query('all', description='Status filter: all, pending, paid, cancelled'),
|
||||
method_filter: str | None = Query(None, description='Filter by payment method'),
|
||||
period: str = Query('24h', description='Period preset: 24h, 7d, 30d, all'),
|
||||
date_from: datetime | None = Query(None, description='Custom range start (ISO 8601)'),
|
||||
date_to: datetime | None = Query(None, description='Custom range end (ISO 8601)'),
|
||||
admin: User = Depends(require_permission('payments:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get aggregated statistics for payment search results."""
|
||||
try:
|
||||
parsed_status = StatusFilter(status_filter)
|
||||
except ValueError:
|
||||
parsed_status = StatusFilter.ALL
|
||||
|
||||
try:
|
||||
parsed_period = PeriodPreset(period)
|
||||
except ValueError:
|
||||
parsed_period = PeriodPreset.H24
|
||||
|
||||
parsed_method: PaymentMethod | None = None
|
||||
if method_filter:
|
||||
try:
|
||||
parsed_method = PaymentMethod(method_filter)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Ensure custom dates are timezone-aware
|
||||
if date_from is not None and date_from.tzinfo is None:
|
||||
date_from = date_from.replace(tzinfo=UTC)
|
||||
if date_to is not None and date_to.tzinfo is None:
|
||||
date_to = date_to.replace(tzinfo=UTC)
|
||||
|
||||
# Clamp custom dates to safety limit
|
||||
min_allowed = datetime.now(UTC) - timedelta(days=MAX_ALL_TIME_DAYS)
|
||||
if date_from is not None and date_from < min_allowed:
|
||||
date_from = min_allowed
|
||||
if date_from is not None and date_to is not None and date_from > date_to:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='date_from must be before date_to')
|
||||
|
||||
params = SearchParams(
|
||||
search=search.strip() if search else None,
|
||||
status_filter=parsed_status,
|
||||
method_filter=parsed_method,
|
||||
period=parsed_period,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
)
|
||||
|
||||
stats = await search_payments_stats(db, params)
|
||||
|
||||
return SearchStatsResponse(
|
||||
total=stats.total,
|
||||
pending=stats.pending,
|
||||
paid=stats.paid,
|
||||
cancelled=stats.cancelled,
|
||||
by_method=stats.by_method or {},
|
||||
)
|
||||
|
||||
|
||||
@router.get('/{method}/{payment_id}', response_model=PendingPaymentResponse)
|
||||
async def get_pending_payment_details(
|
||||
method: str,
|
||||
@@ -342,7 +495,7 @@ async def get_pending_payment_details(
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Invalid payment method: {method}',
|
||||
detail='Invalid payment method',
|
||||
)
|
||||
|
||||
record = await get_payment_record(db, payment_method, payment_id)
|
||||
@@ -369,7 +522,7 @@ async def check_payment_status(
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Invalid payment method: {method}',
|
||||
detail='Invalid payment method',
|
||||
)
|
||||
|
||||
# Get current record
|
||||
@@ -394,7 +547,7 @@ async def check_payment_status(
|
||||
old_is_paid = record.is_paid
|
||||
|
||||
# Run manual check
|
||||
bot = Bot(token=settings.BOT_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
|
||||
bot = create_bot()
|
||||
try:
|
||||
payment_service = PaymentService(bot=bot)
|
||||
updated = await run_manual_check(db, payment_method, payment_id, payment_service)
|
||||
|
||||
@@ -5,13 +5,11 @@ from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from aiogram import Bot
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.bot_factory import create_bot
|
||||
from app.database.models import PinnedMessage, User
|
||||
from app.services.pinned_message_service import (
|
||||
broadcast_pinned_message,
|
||||
@@ -77,10 +75,7 @@ _cached_bot: Bot | None = None
|
||||
def _get_bot() -> Bot:
|
||||
global _cached_bot
|
||||
if _cached_bot is None:
|
||||
_cached_bot = Bot(
|
||||
token=settings.BOT_TOKEN,
|
||||
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
|
||||
)
|
||||
_cached_bot = create_bot()
|
||||
return _cached_bot
|
||||
|
||||
|
||||
|
||||
@@ -4,19 +4,17 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from typing import Any, ClassVar
|
||||
|
||||
import structlog
|
||||
from aiogram import Bot
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, validator
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.bot_factory import create_bot
|
||||
from app.database.crud.discount_offer import (
|
||||
count_discount_offers,
|
||||
list_discount_offers,
|
||||
@@ -130,6 +128,20 @@ class PromoOfferBroadcastRequest(BaseModel):
|
||||
message_text: str | None = Field(None, description='Custom message text (HTML)')
|
||||
button_text: str | None = Field(None, description='Button text')
|
||||
|
||||
_TARGET_ALIASES: ClassVar[dict[str, str]] = {
|
||||
'no_sub': 'no',
|
||||
'all_users': 'all',
|
||||
'active_subscribers': 'active',
|
||||
'trial_users': 'trial',
|
||||
}
|
||||
|
||||
@validator('target')
|
||||
def normalize_target(cls, value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
normalized = value.strip().lower()
|
||||
return cls._TARGET_ALIASES.get(normalized, normalized)
|
||||
|
||||
|
||||
class PromoOfferBroadcastResponse(BaseModel):
|
||||
created_offers: int
|
||||
@@ -369,10 +381,7 @@ async def list_offers(
|
||||
|
||||
def _get_bot() -> Bot:
|
||||
"""Create bot instance for sending notifications."""
|
||||
return Bot(
|
||||
token=settings.BOT_TOKEN,
|
||||
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
|
||||
)
|
||||
return create_bot()
|
||||
|
||||
|
||||
def _build_default_promo_message(
|
||||
|
||||
@@ -28,6 +28,7 @@ from app.database.crud.promocode import (
|
||||
get_promocodes_list,
|
||||
update_promocode,
|
||||
)
|
||||
from app.database.crud.tariff import get_tariff_by_id
|
||||
from app.database.models import PromoCode, PromoCodeType, PromoCodeUse, PromoGroup, User
|
||||
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
@@ -55,6 +56,8 @@ class PromoCodeResponse(BaseModel):
|
||||
valid_from: datetime
|
||||
valid_until: datetime | None = None
|
||||
promo_group_id: int | None = None
|
||||
tariff_id: int | None = None
|
||||
tariff_name: str | None = None
|
||||
created_by: int | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
@@ -93,6 +96,7 @@ class PromoCodeCreateRequest(BaseModel):
|
||||
is_active: bool = True
|
||||
first_purchase_only: bool = False
|
||||
promo_group_id: int | None = None
|
||||
tariff_id: int | None = None
|
||||
|
||||
|
||||
class PromoCodeUpdateRequest(BaseModel):
|
||||
@@ -106,6 +110,7 @@ class PromoCodeUpdateRequest(BaseModel):
|
||||
is_active: bool | None = None
|
||||
first_purchase_only: bool | None = None
|
||||
promo_group_id: int | None = None
|
||||
tariff_id: int | None = None
|
||||
|
||||
|
||||
# ============== PromoGroup Schemas ==============
|
||||
@@ -168,7 +173,12 @@ def _normalize_datetime(value: datetime | None) -> datetime | None:
|
||||
return value
|
||||
|
||||
|
||||
def _serialize_promocode(promocode: PromoCode) -> PromoCodeResponse:
|
||||
async def _serialize_promocode(db: AsyncSession, promocode: PromoCode) -> PromoCodeResponse:
|
||||
tariff_name = None
|
||||
if promocode.tariff_id:
|
||||
tariff = await get_tariff_by_id(db, promocode.tariff_id)
|
||||
tariff_name = tariff.name if tariff else None
|
||||
|
||||
promo_type = PromoCodeType(promocode.type)
|
||||
return PromoCodeResponse(
|
||||
id=promocode.id,
|
||||
@@ -186,6 +196,8 @@ def _serialize_promocode(promocode: PromoCode) -> PromoCodeResponse:
|
||||
valid_from=promocode.valid_from,
|
||||
valid_until=promocode.valid_until,
|
||||
promo_group_id=promocode.promo_group_id,
|
||||
tariff_id=promocode.tariff_id,
|
||||
tariff_name=tariff_name,
|
||||
created_by=promocode.created_by,
|
||||
created_at=promocode.created_at,
|
||||
updated_at=promocode.updated_at,
|
||||
@@ -315,8 +327,9 @@ async def list_promocodes(
|
||||
total = await get_promocodes_count(db, is_active=is_active) or 0
|
||||
promocodes = await get_promocodes_list(db, offset=offset, limit=limit, is_active=is_active)
|
||||
|
||||
serialized = [await _serialize_promocode(db, p) for p in promocodes]
|
||||
return PromoCodeListResponse(
|
||||
items=[_serialize_promocode(promocode) for promocode in promocodes],
|
||||
items=serialized,
|
||||
total=int(total),
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
@@ -335,7 +348,7 @@ async def get_promocode(
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Promo code not found')
|
||||
|
||||
stats = await get_promocode_statistics(db, promocode_id)
|
||||
base = _serialize_promocode(promocode)
|
||||
base = await _serialize_promocode(db, promocode)
|
||||
recent_uses = [_serialize_recent_use(use) for use in stats.get('recent_uses', [])]
|
||||
|
||||
return PromoCodeDetailResponse(
|
||||
@@ -388,11 +401,13 @@ async def create_promocode_endpoint(
|
||||
update_fields['first_purchase_only'] = payload.first_purchase_only
|
||||
if payload.promo_group_id is not None:
|
||||
update_fields['promo_group_id'] = payload.promo_group_id
|
||||
if payload.tariff_id is not None:
|
||||
update_fields['tariff_id'] = payload.tariff_id
|
||||
|
||||
if update_fields:
|
||||
promocode = await update_promocode(db, promocode, **update_fields)
|
||||
|
||||
return _serialize_promocode(promocode)
|
||||
return await _serialize_promocode(db, promocode)
|
||||
|
||||
|
||||
@router.patch('/{promocode_id}', response_model=PromoCodeResponse)
|
||||
@@ -446,11 +461,14 @@ async def update_promocode_endpoint(
|
||||
if payload.promo_group_id is not None:
|
||||
updates['promo_group_id'] = payload.promo_group_id
|
||||
|
||||
if payload.tariff_id is not None:
|
||||
updates['tariff_id'] = payload.tariff_id if payload.tariff_id != 0 else None
|
||||
|
||||
if not updates:
|
||||
return _serialize_promocode(promocode)
|
||||
return await _serialize_promocode(db, promocode)
|
||||
|
||||
promocode = await update_promocode(db, promocode, **updates)
|
||||
return _serialize_promocode(promocode)
|
||||
return await _serialize_promocode(db, promocode)
|
||||
|
||||
|
||||
@router.delete(
|
||||
@@ -489,44 +507,63 @@ async def admin_deactivate_discount_promocode(
|
||||
admin: User = Depends(require_permission('promocodes:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> DeactivateDiscountResponse:
|
||||
"""Admin: deactivate a user's active discount promo code."""
|
||||
"""Admin: deactivate a user's active discount (promo code or promo offer)."""
|
||||
from app.database.crud.user import get_user_by_id as get_user
|
||||
|
||||
target_user = await get_user(db, user_id)
|
||||
if not target_user:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, 'User not found')
|
||||
|
||||
from app.services.promocode_service import PromoCodeService
|
||||
current_discount = getattr(target_user, 'promo_offer_discount_percent', 0) or 0
|
||||
source = getattr(target_user, 'promo_offer_discount_source', None)
|
||||
|
||||
service = PromoCodeService()
|
||||
result = await service.deactivate_discount_promocode(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
admin_initiated=True,
|
||||
)
|
||||
if current_discount <= 0:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, 'User has no active discount')
|
||||
|
||||
if result['success']:
|
||||
return DeactivateDiscountResponse(
|
||||
success=True,
|
||||
message=f'Discount promo code deactivated for user {user_id}',
|
||||
deactivated_code=result.get('deactivated_code'),
|
||||
discount_percent=result.get('discount_percent', 0),
|
||||
# If source is a promo code, use the service to properly rollback usage
|
||||
if source and source.startswith('promocode:'):
|
||||
from app.services.promocode_service import PromoCodeService
|
||||
|
||||
service = PromoCodeService()
|
||||
result = await service.deactivate_discount_promocode(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
admin_initiated=True,
|
||||
)
|
||||
|
||||
error_messages = {
|
||||
'user_not_found': 'User not found',
|
||||
'no_active_discount_promocode': 'User has no active discount from a promo code',
|
||||
'discount_already_expired': 'Discount has already expired (cleaned up)',
|
||||
'server_error': 'Server error occurred',
|
||||
}
|
||||
if result['success']:
|
||||
return DeactivateDiscountResponse(
|
||||
success=True,
|
||||
message=f'Discount promo code deactivated for user {user_id}',
|
||||
deactivated_code=result.get('deactivated_code'),
|
||||
discount_percent=result.get('discount_percent', 0),
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
error_code = result.get('error', 'server_error')
|
||||
error_message = error_messages.get(error_code, 'Failed to deactivate promo code')
|
||||
error_messages = {
|
||||
'user_not_found': 'User not found',
|
||||
'no_active_discount_promocode': 'User has no active discount from a promo code',
|
||||
'discount_already_expired': 'Discount has already expired (cleaned up)',
|
||||
'server_error': 'Server error occurred',
|
||||
}
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=error_message,
|
||||
error_code = result.get('error', 'server_error')
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, error_messages.get(error_code, 'Failed to deactivate'))
|
||||
|
||||
# For non-promocode offers (admin offers, etc.) — just clear the fields
|
||||
old_percent = target_user.promo_offer_discount_percent
|
||||
target_user.promo_offer_discount_percent = 0
|
||||
target_user.promo_offer_discount_source = None
|
||||
target_user.promo_offer_discount_expires_at = None
|
||||
target_user.updated_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
|
||||
return DeactivateDiscountResponse(
|
||||
success=True,
|
||||
message=f'Promo offer deactivated for user {user_id}',
|
||||
deactivated_code=None,
|
||||
discount_percent=old_percent,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,7 @@ from typing import Any
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.crud.server_squad import (
|
||||
@@ -129,22 +130,22 @@ def _serialize_node(node_data: dict[str, Any]) -> NodeInfo:
|
||||
is_disabled=bool(node_data.get('is_disabled')),
|
||||
is_node_online=bool(node_data.get('is_node_online')),
|
||||
is_xray_running=bool(node_data.get('is_xray_running')),
|
||||
users_online=node_data.get('users_online'),
|
||||
users_online=node_data.get('users_online', 0),
|
||||
traffic_used_bytes=node_data.get('traffic_used_bytes'),
|
||||
traffic_limit_bytes=node_data.get('traffic_limit_bytes'),
|
||||
last_status_change=_parse_datetime(node_data.get('last_status_change')),
|
||||
last_status_message=node_data.get('last_status_message'),
|
||||
xray_uptime=node_data.get('xray_uptime'),
|
||||
xray_uptime=node_data.get('xray_uptime', 0) or 0,
|
||||
is_traffic_tracking_active=bool(node_data.get('is_traffic_tracking_active', False)),
|
||||
traffic_reset_day=node_data.get('traffic_reset_day'),
|
||||
notify_percent=node_data.get('notify_percent'),
|
||||
consumption_multiplier=float(node_data.get('consumption_multiplier', 1.0)),
|
||||
cpu_count=node_data.get('cpu_count'),
|
||||
cpu_model=node_data.get('cpu_model'),
|
||||
total_ram=node_data.get('total_ram'),
|
||||
created_at=_parse_datetime(node_data.get('created_at')),
|
||||
updated_at=_parse_datetime(node_data.get('updated_at')),
|
||||
provider_uuid=node_data.get('provider_uuid'),
|
||||
versions=node_data.get('versions'),
|
||||
system=node_data.get('system'),
|
||||
active_plugin_uuid=node_data.get('active_plugin_uuid'),
|
||||
)
|
||||
|
||||
|
||||
@@ -208,11 +209,9 @@ async def get_system_statistics(
|
||||
users_by_status=stats.get('users_by_status', {}),
|
||||
server_info=ServerInfo(
|
||||
cpu_cores=server_data.get('cpu_cores', 0),
|
||||
cpu_physical_cores=server_data.get('cpu_physical_cores', 0),
|
||||
memory_total=server_data.get('memory_total', 0),
|
||||
memory_used=server_data.get('memory_used', 0),
|
||||
memory_free=server_data.get('memory_free', 0),
|
||||
memory_available=server_data.get('memory_available', 0),
|
||||
uptime_seconds=server_data.get('uptime_seconds', 0),
|
||||
),
|
||||
bandwidth=Bandwidth(
|
||||
@@ -397,15 +396,21 @@ async def perform_node_action(
|
||||
)
|
||||
|
||||
|
||||
class RestartAllNodesPayload(BaseModel):
|
||||
force_restart: bool = False
|
||||
|
||||
|
||||
@router.post('/nodes/restart-all', response_model=NodeActionResponse)
|
||||
async def restart_all_nodes(
|
||||
payload: RestartAllNodesPayload | None = None,
|
||||
admin: User = Depends(require_permission('remnawave:manage')),
|
||||
) -> NodeActionResponse:
|
||||
"""Restart all nodes."""
|
||||
service = _get_service()
|
||||
_ensure_configured(service)
|
||||
|
||||
success = await service.restart_all_nodes()
|
||||
force = payload.force_restart if payload else False
|
||||
success = await service.restart_all_nodes(force_restart=force)
|
||||
|
||||
if success:
|
||||
logger.info('Admin restarted all nodes', telegram_id=admin.telegram_id)
|
||||
|
||||
@@ -4,12 +4,13 @@ from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
import sqlalchemy as sa
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.crud.rbac import AdminRoleCRUD, UserRoleCRUD
|
||||
from app.database.crud.rbac import SUPERADMIN_LEVEL, AdminRoleCRUD, UserRoleCRUD
|
||||
from app.database.models import User
|
||||
from app.services.permission_service import PERMISSION_REGISTRY, get_all_permissions
|
||||
|
||||
@@ -129,21 +130,26 @@ async def _role_to_response(db: AsyncSession, role) -> RoleResponse:
|
||||
|
||||
|
||||
async def _get_admin_level(db: AsyncSession, admin: User) -> int:
|
||||
"""Get the maximum role level of the current admin.
|
||||
"""Get the effective management level of the current admin.
|
||||
|
||||
Legacy config-based admins (ADMIN_IDS) get superadmin level (999+1=1000)
|
||||
so they can manage all roles including level 999.
|
||||
Superadmin-tier users (DB level 999 or legacy ADMIN_IDS) are promoted to
|
||||
level 1000 so they can manage peer Superadmins. Without this, the ``>=``
|
||||
hierarchy guard would block 999-vs-999 operations.
|
||||
"""
|
||||
from app.config import settings
|
||||
|
||||
_perms, _names, max_level = await UserRoleCRUD.get_user_permissions(db, admin.id)
|
||||
|
||||
# DB-assigned Superadmins can manage peers
|
||||
if max_level >= SUPERADMIN_LEVEL:
|
||||
max_level = SUPERADMIN_LEVEL + 1
|
||||
|
||||
# Legacy config-based admins always get the highest level
|
||||
if settings.is_admin(
|
||||
telegram_id=admin.telegram_id,
|
||||
email=admin.email if admin.email_verified else None,
|
||||
):
|
||||
max_level = max(max_level, 1000)
|
||||
max_level = max(max_level, SUPERADMIN_LEVEL + 1)
|
||||
|
||||
return max_level
|
||||
|
||||
@@ -177,6 +183,45 @@ async def get_permission_registry(
|
||||
]
|
||||
|
||||
|
||||
@router.get('/users', response_model=list[AdminWithRolesResponse])
|
||||
async def list_rbac_users(
|
||||
admin: User = Depends(require_permission('roles:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""List all users that have at least one active RBAC role."""
|
||||
from sqlalchemy import select as _sa_select
|
||||
from sqlalchemy.orm import selectinload as _sel
|
||||
|
||||
from app.database.models import UserRole as _UserRole
|
||||
|
||||
result = await db.execute(
|
||||
_sa_select(_UserRole)
|
||||
.options(_sel(_UserRole.user), _sel(_UserRole.role))
|
||||
.where(_UserRole.is_active.is_(True))
|
||||
.order_by(_UserRole.user_id)
|
||||
)
|
||||
assignments = result.scalars().all()
|
||||
|
||||
users_map: dict[int, AdminWithRolesResponse] = {}
|
||||
for a in assignments:
|
||||
if not a.user:
|
||||
continue
|
||||
if a.user_id not in users_map:
|
||||
users_map[a.user_id] = AdminWithRolesResponse(
|
||||
user_id=a.user_id,
|
||||
telegram_id=a.user.telegram_id,
|
||||
username=a.user.username,
|
||||
first_name=a.user.first_name,
|
||||
last_name=a.user.last_name,
|
||||
email=a.user.email,
|
||||
role_names=[],
|
||||
)
|
||||
if a.role:
|
||||
users_map[a.user_id].role_names.append(a.role.name)
|
||||
|
||||
return list(users_map.values())
|
||||
|
||||
|
||||
@router.get('/roles/{role_id}/users', response_model=list[UserRoleResponse])
|
||||
async def list_role_users(
|
||||
role_id: int,
|
||||
@@ -300,6 +345,15 @@ async def update_role(
|
||||
|
||||
update_data = payload.model_dump(exclude_unset=True)
|
||||
|
||||
# System roles: only permissions can be extended, block is_active/level changes
|
||||
if role.is_system:
|
||||
blocked = {'is_active', 'level'} & update_data.keys()
|
||||
if blocked:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f'Cannot change {", ".join(sorted(blocked))} on a system role',
|
||||
)
|
||||
|
||||
# Validate level change
|
||||
if 'level' in update_data and update_data['level'] >= admin_level:
|
||||
raise HTTPException(
|
||||
@@ -387,6 +441,14 @@ async def assign_role(
|
||||
detail='Role not found',
|
||||
)
|
||||
|
||||
# Superadmin role is managed exclusively via ADMIN_IDS/ADMIN_EMAILS env config
|
||||
if role.level >= SUPERADMIN_LEVEL:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Superadmin role is managed via ADMIN_IDS/ADMIN_EMAILS environment variables. '
|
||||
'Add the user there and restart the bot.',
|
||||
)
|
||||
|
||||
admin_level = await _get_admin_level(db, admin)
|
||||
|
||||
# Cannot assign a role with level >= own level
|
||||
@@ -444,13 +506,11 @@ async def revoke_role(
|
||||
admin: User = Depends(require_permission('roles:assign')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Revoke a role assignment. Cannot remove the last superadmin."""
|
||||
from sqlalchemy import select as sa_select
|
||||
|
||||
"""Revoke a role assignment. Superadmin roles are managed via env config."""
|
||||
from app.database.models import UserRole
|
||||
|
||||
# Load the assignment to check hierarchy
|
||||
result = await db.execute(sa_select(UserRole).where(UserRole.id == assignment_id))
|
||||
# Lock the assignment row (FOR UPDATE held until commit)
|
||||
result = await db.execute(sa.select(UserRole).where(UserRole.id == assignment_id).with_for_update())
|
||||
user_role = result.scalar_one_or_none()
|
||||
if not user_role:
|
||||
raise HTTPException(
|
||||
@@ -465,6 +525,14 @@ async def revoke_role(
|
||||
detail='Associated role not found',
|
||||
)
|
||||
|
||||
# Superadmin role is managed exclusively via env config
|
||||
if role.level >= SUPERADMIN_LEVEL:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Superadmin role is managed via ADMIN_IDS/ADMIN_EMAILS environment variables. '
|
||||
'Remove the user from env and restart the bot.',
|
||||
)
|
||||
|
||||
admin_level = await _get_admin_level(db, admin)
|
||||
|
||||
# Cannot revoke a role at or above own level
|
||||
@@ -474,23 +542,9 @@ async def revoke_role(
|
||||
detail='Cannot revoke a role at or above your own level',
|
||||
)
|
||||
|
||||
# Protect last superadmin (level 999)
|
||||
superadmin_level = 999
|
||||
if role.level == superadmin_level:
|
||||
superadmin_count = await UserRoleCRUD.get_superadmin_count(db)
|
||||
if superadmin_count <= 1:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Cannot remove the last superadmin',
|
||||
)
|
||||
|
||||
revoked = await UserRoleCRUD.revoke_role(db, assignment_id)
|
||||
if not revoked:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Failed to revoke role',
|
||||
)
|
||||
|
||||
# Revoke directly on the locked object (avoid CRUD re-fetch without FOR UPDATE)
|
||||
user_role.is_active = False
|
||||
await db.flush()
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
@@ -500,4 +554,5 @@ async def revoke_role(
|
||||
target_user_id=user_role.user_id,
|
||||
role_name=role.name,
|
||||
)
|
||||
|
||||
return {'message': 'Role revoked', 'assignment_id': assignment_id}
|
||||
|
||||
@@ -10,6 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.crud.transaction import REAL_PAYMENT_METHODS
|
||||
from app.database.models import (
|
||||
PaymentMethod,
|
||||
Subscription,
|
||||
SubscriptionConversion,
|
||||
SubscriptionStatus,
|
||||
@@ -87,6 +88,7 @@ class SalesSummary(BaseModel):
|
||||
"""Summary stats for the top cards."""
|
||||
|
||||
total_revenue_kopeks: int
|
||||
manual_topup_kopeks: int
|
||||
active_subscriptions: int
|
||||
active_trials: int
|
||||
new_trials: int
|
||||
@@ -110,11 +112,11 @@ async def get_sales_summary(
|
||||
try:
|
||||
period_start, period_end = _parse_period(days, start_date, end_date)
|
||||
|
||||
# Total revenue (deposits with real payment methods)
|
||||
# Total revenue (deposits + direct subscription payments with real payment methods)
|
||||
revenue_result = await db.execute(
|
||||
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
|
||||
select(func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0)).where(
|
||||
and_(
|
||||
Transaction.type == TransactionType.DEPOSIT.value,
|
||||
Transaction.type.in_([TransactionType.DEPOSIT.value, TransactionType.SUBSCRIPTION_PAYMENT.value]),
|
||||
Transaction.is_completed == True,
|
||||
Transaction.payment_method.in_(REAL_PAYMENT_METHODS),
|
||||
Transaction.created_at >= period_start,
|
||||
@@ -124,6 +126,20 @@ async def get_sales_summary(
|
||||
)
|
||||
total_revenue = revenue_result.scalar() or 0
|
||||
|
||||
# Manual top-ups by admins
|
||||
manual_topup_result = await db.execute(
|
||||
select(func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0)).where(
|
||||
and_(
|
||||
Transaction.type == TransactionType.DEPOSIT.value,
|
||||
Transaction.is_completed == True,
|
||||
Transaction.payment_method == PaymentMethod.MANUAL.value,
|
||||
Transaction.created_at >= period_start,
|
||||
Transaction.created_at <= period_end,
|
||||
)
|
||||
)
|
||||
)
|
||||
manual_topup = manual_topup_result.scalar() or 0
|
||||
|
||||
# Consolidated subscription counts: active paid, active trial, new trials in period
|
||||
sub_counts_result = await db.execute(
|
||||
select(
|
||||
@@ -170,6 +186,7 @@ async def get_sales_summary(
|
||||
new_trials = row.new_trials or 0
|
||||
|
||||
# Trial-to-paid conversion in period
|
||||
# Method 1: SubscriptionConversion records (only created by some purchase flows)
|
||||
conversions_result = await db.execute(
|
||||
select(func.count(SubscriptionConversion.id)).where(
|
||||
and_(
|
||||
@@ -178,9 +195,29 @@ async def get_sales_summary(
|
||||
)
|
||||
)
|
||||
)
|
||||
conversions = conversions_result.scalar() or 0
|
||||
# Cap at 100%: conversions from previous periods can exceed current new_trials
|
||||
conversion_rate = min(round((conversions / new_trials * 100), 1), 100.0) if new_trials > 0 else 0.0
|
||||
conversion_records = conversions_result.scalar() or 0
|
||||
|
||||
# Method 2: Users registered in period who have paid (catches all purchase flows)
|
||||
converted_users_result = await db.execute(
|
||||
select(func.count(User.id)).where(
|
||||
and_(
|
||||
User.created_at >= period_start,
|
||||
User.created_at <= period_end,
|
||||
User.has_had_paid_subscription.is_(True),
|
||||
)
|
||||
)
|
||||
)
|
||||
converted_users = converted_users_result.scalar() or 0
|
||||
|
||||
# Use the higher count to catch conversions from all purchase flows
|
||||
conversions = max(conversion_records, converted_users)
|
||||
|
||||
# new_trials only counts REMAINING trials (is_trial=True), but converted users
|
||||
# had is_trial flipped to False. Add conversions back to get total trial starters.
|
||||
total_trial_starters = new_trials + conversions
|
||||
conversion_rate = (
|
||||
min(round((conversions / total_trial_starters * 100), 1), 100.0) if total_trial_starters > 0 else 0.0
|
||||
)
|
||||
|
||||
# Renewals count
|
||||
renewals_subquery = (
|
||||
@@ -209,7 +246,7 @@ async def get_sales_summary(
|
||||
|
||||
# Add-on revenue
|
||||
addon_revenue_result = await db.execute(
|
||||
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
|
||||
select(func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0)).where(
|
||||
and_(
|
||||
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
|
||||
Transaction.is_completed == True,
|
||||
@@ -219,10 +256,11 @@ async def get_sales_summary(
|
||||
)
|
||||
)
|
||||
)
|
||||
addon_revenue = abs(addon_revenue_result.scalar() or 0)
|
||||
addon_revenue = addon_revenue_result.scalar() or 0
|
||||
|
||||
return SalesSummary(
|
||||
total_revenue_kopeks=total_revenue,
|
||||
total_revenue_kopeks=total_revenue + manual_topup,
|
||||
manual_topup_kopeks=manual_topup,
|
||||
active_subscriptions=active_subs,
|
||||
active_trials=active_trials,
|
||||
new_trials=new_trials,
|
||||
@@ -290,6 +328,7 @@ async def get_trials_stats(
|
||||
)
|
||||
total_trials = total_result.scalar() or 0
|
||||
|
||||
# Conversion: SubscriptionConversion records + fallback to has_had_paid_subscription
|
||||
conversions_result = await db.execute(
|
||||
select(func.count(SubscriptionConversion.id)).where(
|
||||
and_(
|
||||
@@ -298,9 +337,25 @@ async def get_trials_stats(
|
||||
)
|
||||
)
|
||||
)
|
||||
conversions = conversions_result.scalar() or 0
|
||||
# Cap at 100%: conversions from previous periods can exceed current period trials
|
||||
conversion_rate = min(round((conversions / total_trials * 100), 1), 100.0) if total_trials > 0 else 0.0
|
||||
conversion_records = conversions_result.scalar() or 0
|
||||
|
||||
converted_users_result = await db.execute(
|
||||
select(func.count(User.id)).where(
|
||||
and_(
|
||||
User.created_at >= period_start,
|
||||
User.created_at <= period_end,
|
||||
User.has_had_paid_subscription.is_(True),
|
||||
)
|
||||
)
|
||||
)
|
||||
converted_users = converted_users_result.scalar() or 0
|
||||
conversions = max(conversion_records, converted_users)
|
||||
|
||||
# total_trials only counts remaining is_trial=True; add conversions for total starters
|
||||
total_trial_starters = total_trials + conversions
|
||||
conversion_rate = (
|
||||
min(round((conversions / total_trial_starters * 100), 1), 100.0) if total_trial_starters > 0 else 0.0
|
||||
)
|
||||
|
||||
avg_duration_result = await db.execute(
|
||||
select(func.avg(SubscriptionConversion.trial_duration_days)).where(
|
||||
@@ -1022,10 +1077,11 @@ async def get_deposits_stats(
|
||||
try:
|
||||
period_start, period_end = _parse_period(days, start_date, end_date)
|
||||
|
||||
methods_with_manual = [*REAL_PAYMENT_METHODS, PaymentMethod.MANUAL.value]
|
||||
base_filter = and_(
|
||||
Transaction.type == TransactionType.DEPOSIT.value,
|
||||
Transaction.type.in_([TransactionType.DEPOSIT.value, TransactionType.SUBSCRIPTION_PAYMENT.value]),
|
||||
Transaction.is_completed == True,
|
||||
Transaction.payment_method.in_(REAL_PAYMENT_METHODS),
|
||||
Transaction.payment_method.in_(methods_with_manual),
|
||||
Transaction.created_at >= period_start,
|
||||
Transaction.created_at <= period_end,
|
||||
)
|
||||
@@ -1033,7 +1089,7 @@ async def get_deposits_stats(
|
||||
totals_result = await db.execute(
|
||||
select(
|
||||
func.count(Transaction.id).label('count'),
|
||||
func.coalesce(func.sum(Transaction.amount_kopeks), 0).label('amount'),
|
||||
func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0).label('amount'),
|
||||
).where(base_filter)
|
||||
)
|
||||
totals = totals_result.one()
|
||||
@@ -1045,11 +1101,11 @@ async def get_deposits_stats(
|
||||
select(
|
||||
Transaction.payment_method.label('method'),
|
||||
func.count(Transaction.id).label('count'),
|
||||
func.coalesce(func.sum(Transaction.amount_kopeks), 0).label('amount'),
|
||||
func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0).label('amount'),
|
||||
)
|
||||
.where(base_filter)
|
||||
.group_by(Transaction.payment_method)
|
||||
.order_by(func.sum(Transaction.amount_kopeks).desc())
|
||||
.order_by(func.sum(func.abs(Transaction.amount_kopeks)).desc())
|
||||
)
|
||||
by_method = [
|
||||
DepositByMethodItem(method=row.method or 'unknown', count=row.count, amount_kopeks=row.amount)
|
||||
@@ -1060,7 +1116,7 @@ async def get_deposits_stats(
|
||||
select(
|
||||
func.date(Transaction.created_at).label('date'),
|
||||
func.count(Transaction.id).label('count'),
|
||||
func.coalesce(func.sum(Transaction.amount_kopeks), 0).label('amount'),
|
||||
func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0).label('amount'),
|
||||
)
|
||||
.where(base_filter)
|
||||
.group_by(func.date(Transaction.created_at))
|
||||
@@ -1076,12 +1132,12 @@ async def get_deposits_stats(
|
||||
]
|
||||
|
||||
# Daily deposits grouped by payment method
|
||||
# base_filter already excludes NULLs via .in_(REAL_PAYMENT_METHODS), no coalesce needed
|
||||
# base_filter already excludes NULLs via .in_(methods_with_manual), no coalesce needed
|
||||
daily_by_method_query = await db.execute(
|
||||
select(
|
||||
func.date(Transaction.created_at).label('date'),
|
||||
Transaction.payment_method.label('method'),
|
||||
func.coalesce(func.sum(Transaction.amount_kopeks), 0).label('amount'),
|
||||
func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0).label('amount'),
|
||||
)
|
||||
.where(base_filter)
|
||||
.group_by(func.date(Transaction.created_at), Transaction.payment_method)
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import sys
|
||||
import time
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
@@ -13,7 +14,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.database.crud.campaign import get_campaign_statistics, get_campaigns_count, get_campaigns_list
|
||||
from app.database.crud.server_squad import get_server_statistics
|
||||
from app.database.crud.subscription import get_subscriptions_statistics
|
||||
from app.database.crud.transaction import get_revenue_by_period, get_transactions_statistics
|
||||
from app.database.crud.transaction import REAL_PAYMENT_METHODS, get_revenue_by_period, get_transactions_statistics
|
||||
from app.database.models import (
|
||||
ReferralEarning,
|
||||
Subscription,
|
||||
@@ -49,15 +50,11 @@ class NodeStatus(BaseModel):
|
||||
is_disabled: bool
|
||||
users_online: int
|
||||
traffic_used_bytes: int | None = None
|
||||
uptime: str | None = None
|
||||
xray_version: str | None = None
|
||||
node_version: str | None = None
|
||||
last_status_message: str | None = None
|
||||
xray_uptime: str | None = None
|
||||
xray_uptime: int = 0
|
||||
is_xray_running: bool | None = None
|
||||
cpu_count: int | None = None
|
||||
cpu_model: str | None = None
|
||||
total_ram: str | None = None
|
||||
versions: dict[str, str] | None = None
|
||||
system: dict[str, Any] | None = None
|
||||
country_code: str | None = None
|
||||
|
||||
|
||||
@@ -275,6 +272,14 @@ async def get_dashboard_stats(
|
||||
# Get tariff statistics
|
||||
tariff_stats = await _get_tariff_stats(db)
|
||||
|
||||
# Derive income_today from revenue_chart to ensure consistency with chart
|
||||
today_str = now.date().isoformat()
|
||||
income_today_from_chart = sum(
|
||||
item.get('amount_kopeks', 0) for item in revenue_data if str(item.get('date', '')) == today_str
|
||||
)
|
||||
# Use chart-derived value if available, otherwise fall back to trans_stats
|
||||
income_today_kopeks = income_today_from_chart or trans_stats.get('today', {}).get('income_kopeks', 0)
|
||||
|
||||
# Build response
|
||||
return DashboardStats(
|
||||
nodes=nodes_data,
|
||||
@@ -290,8 +295,8 @@ async def get_dashboard_stats(
|
||||
trial_to_paid_conversion=sub_stats.get('trial_to_paid_conversion', 0.0),
|
||||
),
|
||||
financial=FinancialStats(
|
||||
income_today_kopeks=trans_stats.get('today', {}).get('income_kopeks', 0),
|
||||
income_today_rubles=trans_stats.get('today', {}).get('income_kopeks', 0) / 100,
|
||||
income_today_kopeks=income_today_kopeks,
|
||||
income_today_rubles=income_today_kopeks / 100,
|
||||
income_month_kopeks=trans_stats.get('totals', {}).get('income_kopeks', 0),
|
||||
income_month_rubles=trans_stats.get('totals', {}).get('income_kopeks', 0) / 100,
|
||||
income_total_kopeks=all_time_stats.get('totals', {}).get('income_kopeks', 0),
|
||||
@@ -461,15 +466,11 @@ async def _get_nodes_overview() -> NodesOverview:
|
||||
is_disabled=n.get('is_disabled', False),
|
||||
users_online=n.get('users_online', 0) or 0,
|
||||
traffic_used_bytes=n.get('traffic_used_bytes'),
|
||||
uptime=n.get('uptime'),
|
||||
xray_version=n.get('xray_version'),
|
||||
node_version=n.get('node_version'),
|
||||
last_status_message=n.get('last_status_message'),
|
||||
xray_uptime=n.get('xray_uptime'),
|
||||
xray_uptime=n.get('xray_uptime', 0) or 0,
|
||||
is_xray_running=n.get('is_xray_running'),
|
||||
cpu_count=n.get('cpu_count'),
|
||||
cpu_model=n.get('cpu_model'),
|
||||
total_ram=n.get('total_ram'),
|
||||
versions=n.get('versions'),
|
||||
system=n.get('system'),
|
||||
country_code=n.get('country_code'),
|
||||
)
|
||||
for n in nodes
|
||||
@@ -926,22 +927,24 @@ async def get_recent_payments(
|
||||
total_count = total_count_result.scalar() or 0
|
||||
|
||||
today_total_result = await db.execute(
|
||||
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
|
||||
select(func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0)).where(
|
||||
and_(
|
||||
Transaction.type == TransactionType.DEPOSIT.value,
|
||||
Transaction.type.in_([TransactionType.DEPOSIT.value, TransactionType.SUBSCRIPTION_PAYMENT.value]),
|
||||
Transaction.is_completed == True,
|
||||
Transaction.created_at >= today_start,
|
||||
Transaction.payment_method.in_(REAL_PAYMENT_METHODS),
|
||||
)
|
||||
)
|
||||
)
|
||||
total_today = today_total_result.scalar() or 0
|
||||
|
||||
week_total_result = await db.execute(
|
||||
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
|
||||
select(func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0)).where(
|
||||
and_(
|
||||
Transaction.type == TransactionType.DEPOSIT.value,
|
||||
Transaction.type.in_([TransactionType.DEPOSIT.value, TransactionType.SUBSCRIPTION_PAYMENT.value]),
|
||||
Transaction.is_completed == True,
|
||||
Transaction.created_at >= week_ago,
|
||||
Transaction.payment_method.in_(REAL_PAYMENT_METHODS),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
"""Admin routes for managing tariffs in cabinet."""
|
||||
|
||||
import asyncio
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy import and_, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.server_squad import get_all_server_squads
|
||||
from app.database.crud.tariff import (
|
||||
create_tariff,
|
||||
@@ -17,14 +21,16 @@ from app.database.crud.tariff import (
|
||||
set_tariff_promo_groups,
|
||||
update_tariff,
|
||||
)
|
||||
from app.database.models import PromoGroup, Subscription, Tariff, Transaction, TransactionType, User
|
||||
from app.database.models import PromoGroup, Subscription, SubscriptionStatus, Tariff, Transaction, TransactionType, User
|
||||
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
from ..schemas.tariffs import (
|
||||
ExternalSquadInfoResponse,
|
||||
PeriodPrice,
|
||||
PromoGroupInfo,
|
||||
ServerInfo,
|
||||
ServerTrafficLimit,
|
||||
SyncSquadsResponse,
|
||||
TariffCreateRequest,
|
||||
TariffDetailResponse,
|
||||
TariffListItem,
|
||||
@@ -126,6 +132,7 @@ async def list_tariffs(
|
||||
is_daily=tariff.is_daily,
|
||||
daily_price_kopeks=tariff.daily_price_kopeks,
|
||||
allow_traffic_topup=tariff.allow_traffic_topup,
|
||||
show_in_gift=tariff.show_in_gift,
|
||||
traffic_limit_gb=tariff.traffic_limit_gb,
|
||||
device_limit=tariff.device_limit,
|
||||
tier_level=tariff.tier_level,
|
||||
@@ -158,6 +165,30 @@ async def get_available_servers(
|
||||
]
|
||||
|
||||
|
||||
@router.get('/available-external-squads', response_model=list[ExternalSquadInfoResponse])
|
||||
async def get_available_external_squads(
|
||||
admin: User = Depends(require_permission('tariffs:read')),
|
||||
):
|
||||
"""Fetch external squads from RemnaWave panel."""
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
|
||||
try:
|
||||
service = RemnaWaveService()
|
||||
async with service.get_api_client() as api:
|
||||
squads = await api.get_external_squads()
|
||||
return [
|
||||
{
|
||||
'uuid': s.uuid,
|
||||
'name': s.name,
|
||||
'members_count': s.members_count,
|
||||
}
|
||||
for s in squads
|
||||
]
|
||||
except Exception:
|
||||
logger.warning('Failed to fetch external squads from RemnaWave', exc_info=True)
|
||||
return []
|
||||
|
||||
|
||||
@router.put('/order')
|
||||
async def update_tariff_order(
|
||||
request: TariffSortOrderRequest,
|
||||
@@ -238,6 +269,12 @@ async def get_tariff(
|
||||
daily_price_kopeks=tariff.daily_price_kopeks,
|
||||
# Режим сброса трафика
|
||||
traffic_reset_mode=tariff.traffic_reset_mode,
|
||||
# Внешний сквад
|
||||
external_squad_uuid=tariff.external_squad_uuid,
|
||||
# Показывать в подарках
|
||||
show_in_gift=tariff.show_in_gift,
|
||||
# Бонусные дни Tasks
|
||||
bonus_days_per_purchase=getattr(tariff, 'bonus_days_per_purchase', 0) or 0,
|
||||
created_at=tariff.created_at,
|
||||
updated_at=tariff.updated_at,
|
||||
)
|
||||
@@ -276,7 +313,7 @@ async def create_new_tariff(
|
||||
period_prices=period_prices_dict,
|
||||
allowed_squads=request.allowed_squads,
|
||||
server_traffic_limits=server_limits_dict,
|
||||
promo_group_ids=request.promo_group_ids if request.promo_group_ids else None,
|
||||
promo_group_ids=request.promo_group_ids or None,
|
||||
# Произвольное количество дней
|
||||
custom_days_enabled=request.custom_days_enabled,
|
||||
price_per_day_kopeks=request.price_per_day_kopeks,
|
||||
@@ -292,6 +329,12 @@ async def create_new_tariff(
|
||||
daily_price_kopeks=request.daily_price_kopeks,
|
||||
# Режим сброса трафика
|
||||
traffic_reset_mode=request.traffic_reset_mode,
|
||||
# Внешний сквад
|
||||
external_squad_uuid=request.external_squad_uuid,
|
||||
# Показывать в подарках
|
||||
show_in_gift=request.show_in_gift,
|
||||
# Бонусные дни Tasks
|
||||
bonus_days_per_purchase=request.bonus_days_per_purchase,
|
||||
)
|
||||
|
||||
logger.info('Admin created tariff', admin_id=admin.id, tariff_id=tariff.id, tariff_name=tariff.name)
|
||||
@@ -318,6 +361,10 @@ async def update_existing_tariff(
|
||||
detail='Tariff not found',
|
||||
)
|
||||
|
||||
# Capture old values for change detection
|
||||
old_squads = list(tariff.allowed_squads) if tariff.allowed_squads else []
|
||||
old_external_squad = tariff.external_squad_uuid
|
||||
|
||||
# Build updates dict
|
||||
updates = {}
|
||||
if request.name is not None:
|
||||
@@ -381,6 +428,15 @@ async def update_existing_tariff(
|
||||
# Режим сброса трафика (None допускается как значение для сброса к глобальной настройке)
|
||||
if 'traffic_reset_mode' in request.model_fields_set:
|
||||
updates['traffic_reset_mode'] = request.traffic_reset_mode
|
||||
# Внешний сквад (None допускается для сброса)
|
||||
if 'external_squad_uuid' in request.model_fields_set:
|
||||
updates['external_squad_uuid'] = request.external_squad_uuid
|
||||
# Показывать в подарках
|
||||
if request.show_in_gift is not None:
|
||||
updates['show_in_gift'] = request.show_in_gift
|
||||
# Бонусные дни Tasks
|
||||
if request.bonus_days_per_purchase is not None:
|
||||
updates['bonus_days_per_purchase'] = request.bonus_days_per_purchase
|
||||
|
||||
if updates:
|
||||
await update_tariff(db, tariff, **updates)
|
||||
@@ -394,6 +450,18 @@ async def update_existing_tariff(
|
||||
# Перезагружаем периоды из БД для синхронизации с ботом
|
||||
await load_period_prices_from_db(db)
|
||||
|
||||
# Auto-sync squads to active subscriptions in Remnawave when squads changed
|
||||
new_squads = tariff.allowed_squads or []
|
||||
squads_changed = request.allowed_squads is not None and sorted(old_squads) != sorted(new_squads)
|
||||
ext_squad_changed = (
|
||||
'external_squad_uuid' in request.model_fields_set and tariff.external_squad_uuid != old_external_squad
|
||||
)
|
||||
if squads_changed or ext_squad_changed:
|
||||
asyncio.create_task(
|
||||
_background_sync_squads(tariff_id, admin.id),
|
||||
name=f'sync-squads-tariff-{tariff_id}',
|
||||
)
|
||||
|
||||
return await get_tariff(tariff_id, admin, db)
|
||||
|
||||
|
||||
@@ -554,3 +622,225 @@ async def get_tariff_stats(
|
||||
revenue_kopeks=revenue_kopeks,
|
||||
revenue_rubles=revenue_kopeks / 100,
|
||||
)
|
||||
|
||||
|
||||
async def _background_sync_squads(tariff_id: int, admin_id: int) -> None:
|
||||
"""Run squad sync in background with its own DB session (fire-and-forget)."""
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
|
||||
try:
|
||||
async with AsyncSessionLocal() as db:
|
||||
tariff = await get_tariff_by_id(db, tariff_id)
|
||||
if not tariff:
|
||||
return
|
||||
|
||||
result = await db.execute(
|
||||
select(Subscription)
|
||||
.join(User, Subscription.user_id == User.id)
|
||||
.options(joinedload(Subscription.user))
|
||||
.where(
|
||||
and_(
|
||||
Subscription.tariff_id == tariff_id,
|
||||
Subscription.status.in_([SubscriptionStatus.ACTIVE.value, SubscriptionStatus.TRIAL.value]),
|
||||
User.remnawave_uuid.isnot(None),
|
||||
)
|
||||
)
|
||||
)
|
||||
subscriptions = list(result.unique().scalars().all())
|
||||
|
||||
if not subscriptions:
|
||||
return
|
||||
|
||||
new_squads = tariff.allowed_squads or []
|
||||
ext_squad_uuid = tariff.external_squad_uuid
|
||||
|
||||
service = RemnaWaveService()
|
||||
updated = 0
|
||||
failed = 0
|
||||
|
||||
async with service.get_api_client() as api:
|
||||
semaphore = asyncio.Semaphore(5)
|
||||
|
||||
async def _sync_one(sub: Subscription) -> None:
|
||||
nonlocal updated, failed
|
||||
remnawave_uuid = (
|
||||
getattr(sub, 'remnawave_uuid', None)
|
||||
if settings.is_multi_tariff_enabled()
|
||||
else (sub.user.remnawave_uuid if sub.user else None)
|
||||
)
|
||||
if not remnawave_uuid:
|
||||
return
|
||||
async with semaphore:
|
||||
try:
|
||||
await api.update_user(
|
||||
uuid=remnawave_uuid,
|
||||
active_internal_squads=new_squads,
|
||||
external_squad_uuid=ext_squad_uuid,
|
||||
)
|
||||
sub.connected_squads = new_squads
|
||||
updated += 1
|
||||
except Exception as e:
|
||||
failed += 1
|
||||
logger.warning(
|
||||
'Background sync: failed to sync squads for user',
|
||||
user_id=sub.user_id,
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
await asyncio.gather(*[_sync_one(sub) for sub in subscriptions])
|
||||
|
||||
await db.commit()
|
||||
logger.info(
|
||||
'Background squad sync completed after tariff update',
|
||||
admin_id=admin_id,
|
||||
tariff_id=tariff_id,
|
||||
tariff_name=tariff.name,
|
||||
total=len(subscriptions),
|
||||
updated=updated,
|
||||
failed=failed,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception('Background squad sync failed', tariff_id=tariff_id)
|
||||
|
||||
|
||||
_SYNC_SQUADS_CONCURRENCY = 5
|
||||
_SYNC_SQUADS_MAX_CONSECUTIVE_FAILURES = 10
|
||||
|
||||
|
||||
@router.post('/{tariff_id}/sync-squads', response_model=SyncSquadsResponse)
|
||||
async def sync_tariff_squads(
|
||||
tariff_id: int,
|
||||
admin: User = Depends(require_permission('tariffs:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Sync squads from tariff to all active/trial subscriptions in Remnawave panel.
|
||||
|
||||
Updates connected_squads and external_squad_uuid for every active or trial
|
||||
subscription linked to this tariff. Only users that have a remnawave_uuid
|
||||
(i.e. already exist in the panel) are touched.
|
||||
"""
|
||||
tariff = await get_tariff_by_id(db, tariff_id)
|
||||
if not tariff:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Tariff not found',
|
||||
)
|
||||
|
||||
# Fetch active + trial subscriptions for this tariff whose users exist in Remnawave
|
||||
result = await db.execute(
|
||||
select(Subscription)
|
||||
.join(User, Subscription.user_id == User.id)
|
||||
.options(joinedload(Subscription.user))
|
||||
.where(
|
||||
and_(
|
||||
Subscription.tariff_id == tariff_id,
|
||||
Subscription.status.in_([SubscriptionStatus.ACTIVE.value, SubscriptionStatus.TRIAL.value]),
|
||||
User.remnawave_uuid.isnot(None),
|
||||
)
|
||||
)
|
||||
)
|
||||
subscriptions = list(result.unique().scalars().all())
|
||||
|
||||
if not subscriptions:
|
||||
return SyncSquadsResponse(
|
||||
tariff_id=tariff_id,
|
||||
tariff_name=tariff.name,
|
||||
total_subscriptions=0,
|
||||
updated_count=0,
|
||||
failed_count=0,
|
||||
skipped_count=0,
|
||||
)
|
||||
|
||||
new_squads = tariff.allowed_squads or []
|
||||
# None means "clear external squad" — intentional when tariff has none
|
||||
ext_squad_uuid = tariff.external_squad_uuid
|
||||
|
||||
# Sync to Remnawave panel with concurrency limit and circuit breaker
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
|
||||
service = RemnaWaveService()
|
||||
updated_count = 0
|
||||
failed_count = 0
|
||||
skipped_count = 0
|
||||
consecutive_failures = 0
|
||||
errors: list[str] = []
|
||||
aborted = False
|
||||
|
||||
async with service.get_api_client() as api:
|
||||
semaphore = asyncio.Semaphore(_SYNC_SQUADS_CONCURRENCY)
|
||||
|
||||
async def _sync_one(sub: Subscription) -> str:
|
||||
# Counter mutations are safe: no `await` between read-modify-write
|
||||
# and the check within each branch (single-threaded asyncio event loop).
|
||||
nonlocal updated_count, failed_count, skipped_count, consecutive_failures, aborted
|
||||
|
||||
if aborted:
|
||||
skipped_count += 1
|
||||
return 'skipped'
|
||||
|
||||
remnawave_uuid = (
|
||||
getattr(sub, 'remnawave_uuid', None)
|
||||
if settings.is_multi_tariff_enabled()
|
||||
else (sub.user.remnawave_uuid if sub.user else None)
|
||||
)
|
||||
if not remnawave_uuid:
|
||||
skipped_count += 1
|
||||
return 'skipped'
|
||||
|
||||
async with semaphore:
|
||||
if aborted:
|
||||
skipped_count += 1
|
||||
return 'skipped'
|
||||
|
||||
try:
|
||||
await api.update_user(
|
||||
uuid=remnawave_uuid,
|
||||
active_internal_squads=new_squads,
|
||||
external_squad_uuid=ext_squad_uuid,
|
||||
)
|
||||
# Update local DB only on successful API call
|
||||
sub.connected_squads = new_squads
|
||||
updated_count += 1
|
||||
consecutive_failures = 0
|
||||
return 'ok'
|
||||
except Exception as e:
|
||||
failed_count += 1
|
||||
consecutive_failures += 1
|
||||
errors.append(f'user_id={sub.user_id}: sync failed')
|
||||
logger.warning(
|
||||
'Failed to sync squads for user in Remnawave',
|
||||
user_id=sub.user_id,
|
||||
remnawave_uuid=remnawave_uuid,
|
||||
error=str(e),
|
||||
)
|
||||
if consecutive_failures >= _SYNC_SQUADS_MAX_CONSECUTIVE_FAILURES:
|
||||
aborted = True
|
||||
errors.append(f'Aborted after {_SYNC_SQUADS_MAX_CONSECUTIVE_FAILURES} consecutive failures')
|
||||
return 'error'
|
||||
|
||||
await asyncio.gather(*[_sync_one(sub) for sub in subscriptions])
|
||||
|
||||
# Commit local DB changes only for successfully synced subscriptions
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
'Admin synced squads for tariff',
|
||||
admin_id=admin.id,
|
||||
tariff_id=tariff_id,
|
||||
tariff_name=tariff.name,
|
||||
total=len(subscriptions),
|
||||
updated=updated_count,
|
||||
failed=failed_count,
|
||||
skipped=skipped_count,
|
||||
)
|
||||
|
||||
return SyncSquadsResponse(
|
||||
tariff_id=tariff_id,
|
||||
tariff_name=tariff.name,
|
||||
total_subscriptions=len(subscriptions),
|
||||
updated_count=updated_count,
|
||||
failed_count=failed_count,
|
||||
skipped_count=skipped_count,
|
||||
errors=errors[:20],
|
||||
)
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
"""Admin endpoints для системы заданий с наградами и партнёрских каналов."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.cabinet.dependencies import get_cabinet_db, require_permission
|
||||
from app.cabinet.schemas.tasks import (
|
||||
TaskCreateRequest,
|
||||
TaskListItem,
|
||||
TaskPartnerChannelCreateRequest,
|
||||
TaskPartnerChannelResponse,
|
||||
TaskPartnerChannelUpdateRequest,
|
||||
TaskResponse,
|
||||
TaskUpdateRequest,
|
||||
)
|
||||
from app.database.crud import tasks as tasks_crud
|
||||
from app.database.models import User
|
||||
|
||||
|
||||
router = APIRouter(prefix='/admin', tags=['Cabinet Admin Tasks'])
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Tasks
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
@router.get('/tasks', response_model=list[TaskListItem])
|
||||
async def admin_list_tasks(
|
||||
include_inactive: bool = True,
|
||||
parent_task_id: int | None = None,
|
||||
admin: User = Depends(require_permission('tasks:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
tasks = await tasks_crud.list_tasks(db, include_inactive=include_inactive, parent_task_id=parent_task_id)
|
||||
return [TaskListItem.model_validate(t) for t in tasks]
|
||||
|
||||
|
||||
@router.get('/tasks/{task_id}', response_model=TaskResponse)
|
||||
async def admin_get_task(
|
||||
task_id: int,
|
||||
admin: User = Depends(require_permission('tasks:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
task = await tasks_crud.get_task_by_id(db, task_id)
|
||||
if task is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='task_not_found')
|
||||
return TaskResponse.model_validate(task)
|
||||
|
||||
|
||||
@router.post('/tasks', response_model=TaskResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def admin_create_task(
|
||||
request: TaskCreateRequest,
|
||||
admin: User = Depends(require_permission('tasks:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
if request.parent_task_id is not None:
|
||||
parent = await tasks_crud.get_task_by_id(db, request.parent_task_id)
|
||||
if parent is None:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='parent_task_not_found')
|
||||
|
||||
task = await tasks_crud.create_task(
|
||||
db,
|
||||
title=request.title,
|
||||
description=request.description,
|
||||
task_type=request.task_type,
|
||||
reward_type=request.reward_type,
|
||||
target_value=request.target_value,
|
||||
reward_value=request.reward_value,
|
||||
target_meta=request.target_meta,
|
||||
reward_meta=request.reward_meta,
|
||||
icon=request.icon,
|
||||
is_active=request.is_active,
|
||||
sort_order=request.sort_order,
|
||||
allow_user_choice=request.allow_user_choice,
|
||||
user_audience=request.user_audience,
|
||||
promo_group_id=request.promo_group_id,
|
||||
parent_task_id=request.parent_task_id,
|
||||
level=request.level,
|
||||
starts_at=request.starts_at,
|
||||
ends_at=request.ends_at,
|
||||
)
|
||||
return TaskResponse.model_validate(task)
|
||||
|
||||
|
||||
async def _parent_chain_has_cycle(db: AsyncSession, *, task_id: int, parent_id: int, max_depth: int = 10) -> bool:
|
||||
"""Идёт вверх по цепочке parent — проверяет, не возвращается ли в task_id."""
|
||||
current = parent_id
|
||||
visited: set[int] = set()
|
||||
for _ in range(max_depth):
|
||||
if current == task_id:
|
||||
return True
|
||||
if current in visited:
|
||||
return False
|
||||
visited.add(current)
|
||||
parent = await tasks_crud.get_task_by_id(db, current)
|
||||
if parent is None or parent.parent_task_id is None:
|
||||
return False
|
||||
current = parent.parent_task_id
|
||||
return False # max_depth достигнут — дальше не считаем циклом
|
||||
|
||||
|
||||
@router.put('/tasks/{task_id}', response_model=TaskResponse)
|
||||
async def admin_update_task(
|
||||
task_id: int,
|
||||
request: TaskUpdateRequest,
|
||||
admin: User = Depends(require_permission('tasks:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
task = await tasks_crud.get_task_by_id(db, task_id)
|
||||
if task is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='task_not_found')
|
||||
|
||||
if request.parent_task_id is not None and request.parent_task_id == task_id:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='parent_task_cannot_be_self')
|
||||
|
||||
if request.parent_task_id is not None:
|
||||
if await _parent_chain_has_cycle(db, task_id=task_id, parent_id=request.parent_task_id):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='parent_chain_cycle_detected')
|
||||
|
||||
fields = request.model_dump(exclude_unset=True)
|
||||
if not fields:
|
||||
return TaskResponse.model_validate(task)
|
||||
|
||||
updated = await tasks_crud.update_task(db, task, **fields)
|
||||
return TaskResponse.model_validate(updated)
|
||||
|
||||
|
||||
@router.delete('/tasks/{task_id}', status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def admin_delete_task(
|
||||
task_id: int,
|
||||
admin: User = Depends(require_permission('tasks:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
task = await tasks_crud.get_task_by_id(db, task_id)
|
||||
if task is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='task_not_found')
|
||||
await tasks_crud.delete_task(db, task)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Partner channels
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
@router.get('/task-partner-channels', response_model=list[TaskPartnerChannelResponse])
|
||||
async def admin_list_partner_channels(
|
||||
include_inactive: bool = True,
|
||||
admin: User = Depends(require_permission('tasks:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
channels = await tasks_crud.list_partner_channels(db, include_inactive=include_inactive)
|
||||
return [TaskPartnerChannelResponse.model_validate(c) for c in channels]
|
||||
|
||||
|
||||
@router.post(
|
||||
'/task-partner-channels',
|
||||
response_model=TaskPartnerChannelResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def admin_create_partner_channel(
|
||||
request: TaskPartnerChannelCreateRequest,
|
||||
admin: User = Depends(require_permission('tasks:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
existing = await tasks_crud.get_partner_channel_by_channel_id(db, request.channel_id)
|
||||
if existing is not None:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='channel_id_already_exists')
|
||||
channel = await tasks_crud.create_partner_channel(
|
||||
db,
|
||||
channel_id=request.channel_id,
|
||||
title=request.title,
|
||||
channel_link=request.channel_link,
|
||||
description=request.description,
|
||||
is_active=request.is_active,
|
||||
sort_order=request.sort_order,
|
||||
)
|
||||
return TaskPartnerChannelResponse.model_validate(channel)
|
||||
|
||||
|
||||
@router.put('/task-partner-channels/{channel_pk}', response_model=TaskPartnerChannelResponse)
|
||||
async def admin_update_partner_channel(
|
||||
channel_pk: int,
|
||||
request: TaskPartnerChannelUpdateRequest,
|
||||
admin: User = Depends(require_permission('tasks:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
channel = await tasks_crud.get_partner_channel_by_id(db, channel_pk)
|
||||
if channel is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='channel_not_found')
|
||||
fields = request.model_dump(exclude_unset=True)
|
||||
updated = await tasks_crud.update_partner_channel(db, channel, **fields)
|
||||
return TaskPartnerChannelResponse.model_validate(updated)
|
||||
|
||||
|
||||
@router.delete('/task-partner-channels/{channel_pk}', status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def admin_delete_partner_channel(
|
||||
channel_pk: int,
|
||||
admin: User = Depends(require_permission('tasks:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
channel = await tasks_crud.get_partner_channel_by_id(db, channel_pk)
|
||||
if channel is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='channel_not_found')
|
||||
await tasks_crud.delete_partner_channel(db, channel)
|
||||
@@ -5,7 +5,7 @@ from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
from sqlalchemy import desc, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
@@ -17,7 +17,7 @@ from app.database.crud.ticket_notification import TicketNotificationCRUD
|
||||
from app.database.models import Ticket, TicketMessage, User
|
||||
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
from ..schemas.tickets import TicketMessageResponse
|
||||
from ..schemas.tickets import TicketMediaItem, TicketMessageResponse, _validate_media_bundle
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
@@ -89,7 +89,20 @@ class AdminTicketListResponse(BaseModel):
|
||||
class AdminReplyRequest(BaseModel):
|
||||
"""Admin reply to ticket."""
|
||||
|
||||
message: str = Field(..., min_length=1, max_length=4000, description='Reply message')
|
||||
message: str = Field(default='', max_length=4000, description='Reply message')
|
||||
media_type: str | None = Field(None, description='Media type: photo, video, or document')
|
||||
media_file_id: str | None = Field(None, max_length=255, description='Telegram file_id from media upload')
|
||||
media_caption: str | None = Field(None, max_length=1000, description='Caption for media')
|
||||
media_items: list[TicketMediaItem] | None = Field(None, description='Multi-media gallery attachments')
|
||||
|
||||
@model_validator(mode='after')
|
||||
def validate_media_fields(self) -> 'AdminReplyRequest':
|
||||
_validate_media_bundle(self.media_type, self.media_file_id, self.media_items)
|
||||
has_text = bool(self.message.strip())
|
||||
has_media = bool(self.media_file_id) or bool(self.media_items)
|
||||
if not has_text and not has_media:
|
||||
raise ValueError('message or media is required')
|
||||
return self
|
||||
|
||||
|
||||
class AdminStatusUpdateRequest(BaseModel):
|
||||
@@ -144,14 +157,23 @@ class TicketSettingsUpdateRequest(BaseModel):
|
||||
|
||||
def _message_to_response(message: TicketMessage) -> TicketMessageResponse:
|
||||
"""Convert TicketMessage to response."""
|
||||
raw_items = getattr(message, 'media_items', None) or None
|
||||
items = None
|
||||
if raw_items:
|
||||
try:
|
||||
items = [TicketMediaItem(**it) for it in raw_items]
|
||||
except (TypeError, KeyError, ValueError) as exc:
|
||||
logger.warning('Failed to parse media_items', message_id=message.id, error=str(exc))
|
||||
items = None
|
||||
return TicketMessageResponse(
|
||||
id=message.id,
|
||||
message_text=message.message_text or '',
|
||||
is_from_admin=message.is_from_admin,
|
||||
has_media=bool(message.media_file_id),
|
||||
has_media=bool(message.media_file_id) or bool(items),
|
||||
media_type=message.media_type,
|
||||
media_file_id=message.media_file_id,
|
||||
media_caption=message.media_caption,
|
||||
media_items=items,
|
||||
created_at=message.created_at,
|
||||
)
|
||||
|
||||
@@ -246,6 +268,7 @@ async def update_ticket_settings(
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update ticket system settings."""
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
from app.services.support_settings_service import SupportSettingsService
|
||||
@@ -280,8 +303,8 @@ async def update_ticket_settings(
|
||||
# Try to persist to .env file
|
||||
try:
|
||||
env_file = Path('.env')
|
||||
if env_file.exists():
|
||||
lines = env_file.read_text().splitlines()
|
||||
if await asyncio.to_thread(env_file.exists):
|
||||
lines = (await asyncio.to_thread(env_file.read_text)).splitlines()
|
||||
updates = {}
|
||||
|
||||
if request.sla_enabled is not None:
|
||||
@@ -314,7 +337,7 @@ async def update_ticket_settings(
|
||||
if key not in updated_keys:
|
||||
new_lines.append(f'{key}={value}')
|
||||
|
||||
env_file.write_text('\n'.join(new_lines) + '\n')
|
||||
await asyncio.to_thread(env_file.write_text, '\n'.join(new_lines) + '\n')
|
||||
logger.info('Updated ticket settings in .env file')
|
||||
except Exception as e:
|
||||
logger.warning('Failed to update .env file', error=e)
|
||||
@@ -441,12 +464,29 @@ async def reply_to_ticket(
|
||||
detail='Ticket not found',
|
||||
)
|
||||
|
||||
# Create admin message
|
||||
# Resolve media payload: prefer media_items, fall back to legacy single-media fields
|
||||
items_payload = None
|
||||
primary_type = request.media_type
|
||||
primary_file_id = request.media_file_id
|
||||
primary_caption = request.media_caption
|
||||
if request.media_items:
|
||||
items_payload = [it.model_dump() for it in request.media_items]
|
||||
first = request.media_items[0]
|
||||
primary_type = first.type
|
||||
primary_file_id = first.file_id
|
||||
primary_caption = primary_caption or first.caption
|
||||
has_media = bool(primary_file_id)
|
||||
|
||||
message = TicketMessage(
|
||||
ticket_id=ticket.id,
|
||||
user_id=ticket.user_id,
|
||||
message_text=request.message,
|
||||
is_from_admin=True,
|
||||
has_media=has_media,
|
||||
media_type=primary_type if has_media else None,
|
||||
media_file_id=primary_file_id if has_media else None,
|
||||
media_caption=primary_caption if has_media else None,
|
||||
media_items=items_payload,
|
||||
created_at=datetime.now(UTC),
|
||||
)
|
||||
db.add(message)
|
||||
@@ -460,14 +500,9 @@ async def reply_to_ticket(
|
||||
|
||||
# Try to notify user via Telegram
|
||||
try:
|
||||
from aiogram import Bot
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
from app.bot_factory import create_bot
|
||||
|
||||
bot = Bot(
|
||||
token=settings.BOT_TOKEN,
|
||||
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
|
||||
)
|
||||
bot = create_bot()
|
||||
try:
|
||||
from app.handlers.admin.tickets import notify_user_about_ticket_reply
|
||||
|
||||
|
||||
@@ -7,16 +7,13 @@ import time
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import structlog
|
||||
from aiogram import Bot
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
from aiogram.types import BufferedInputFile
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import and_, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.config import settings
|
||||
from app.bot_factory import create_bot
|
||||
from app.database.models import Subscription, Transaction, TransactionType, User
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
|
||||
@@ -24,6 +21,8 @@ from ..dependencies import get_cabinet_db, require_permission
|
||||
from ..schemas.traffic import (
|
||||
ExportCsvRequest,
|
||||
ExportCsvResponse,
|
||||
SubscriptionEnrichmentInfo,
|
||||
SubscriptionTrafficInfo,
|
||||
TrafficEnrichmentResponse,
|
||||
TrafficNodeInfo,
|
||||
TrafficUsageResponse,
|
||||
@@ -159,15 +158,42 @@ def _compute_date_range(period_days: int) -> tuple[str, str]:
|
||||
|
||||
|
||||
async def _load_user_map(db: AsyncSession) -> dict[str, User]:
|
||||
"""Load all users with remnawave_uuid, eagerly loading subscription + tariff."""
|
||||
stmt = (
|
||||
"""Load all users with remnawave_uuid, eagerly loading subscription + tariff.
|
||||
|
||||
In multi-tariff mode UUIDs live on Subscription rows, not on User.
|
||||
Both sources are merged so the caller gets a complete uuid → User map.
|
||||
"""
|
||||
from app.config import settings
|
||||
|
||||
# Build user map from both user-level and subscription-level UUIDs
|
||||
user_map: dict[str, User] = {}
|
||||
|
||||
# Legacy: user-level UUIDs
|
||||
stmt_users = (
|
||||
select(User)
|
||||
.where(User.remnawave_uuid.isnot(None))
|
||||
.options(selectinload(User.subscription).selectinload(Subscription.tariff))
|
||||
.options(selectinload(User.subscriptions).selectinload(Subscription.tariff))
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
users = result.scalars().all()
|
||||
return {u.remnawave_uuid: u for u in users if u.remnawave_uuid}
|
||||
result_users = await db.execute(stmt_users)
|
||||
users = result_users.scalars().all()
|
||||
for u in users:
|
||||
if u.remnawave_uuid:
|
||||
user_map[u.remnawave_uuid] = u
|
||||
|
||||
# Multi-tariff: subscription-level UUIDs
|
||||
if settings.is_multi_tariff_enabled():
|
||||
stmt_subs = (
|
||||
select(Subscription)
|
||||
.where(Subscription.remnawave_uuid.isnot(None))
|
||||
.options(selectinload(Subscription.user).selectinload(User.subscriptions).selectinload(Subscription.tariff))
|
||||
)
|
||||
result_subs = await db.execute(stmt_subs)
|
||||
subs = result_subs.scalars().all()
|
||||
for sub in subs:
|
||||
if sub.remnawave_uuid and sub.user and sub.remnawave_uuid not in user_map:
|
||||
user_map[sub.remnawave_uuid] = sub.user
|
||||
|
||||
return user_map
|
||||
|
||||
|
||||
def _build_traffic_items(
|
||||
@@ -205,19 +231,23 @@ def _build_traffic_items(
|
||||
):
|
||||
continue
|
||||
|
||||
sub = user.subscription
|
||||
subs = getattr(user, 'subscriptions', None) or []
|
||||
|
||||
# Primary subscription for backward-compat top-level fields
|
||||
primary_sub = next((s for s in subs if s.is_active), subs[0] if subs else None)
|
||||
tariff_name = None
|
||||
subscription_status = None
|
||||
traffic_limit_gb = 0.0
|
||||
device_limit = 1
|
||||
|
||||
if sub:
|
||||
subscription_status = _get_status(sub)
|
||||
traffic_limit_gb = float(sub.traffic_limit_gb or 0)
|
||||
device_limit = sub.device_limit or 1
|
||||
if sub.tariff:
|
||||
tariff_name = sub.tariff.name
|
||||
if primary_sub:
|
||||
subscription_status = _get_status(primary_sub)
|
||||
traffic_limit_gb = float(primary_sub.traffic_limit_gb or 0)
|
||||
device_limit = primary_sub.device_limit or 1
|
||||
if primary_sub.tariff:
|
||||
tariff_name = primary_sub.tariff.name
|
||||
|
||||
# Filtering uses primary sub values (keeps existing filter semantics)
|
||||
if tariff_filter is not None:
|
||||
if (tariff_name or '') not in tariff_filter:
|
||||
continue
|
||||
@@ -232,6 +262,18 @@ def _build_traffic_items(
|
||||
|
||||
total_bytes = sum(traffic.values())
|
||||
|
||||
# Build per-subscription detail list for multi-subscription display
|
||||
subscriptions_traffic = [
|
||||
SubscriptionTrafficInfo(
|
||||
subscription_id=sub.id,
|
||||
tariff_name=sub.tariff.name if sub.tariff else None,
|
||||
status=_get_status(sub),
|
||||
traffic_limit_gb=float(sub.traffic_limit_gb or 0),
|
||||
device_limit=sub.device_limit or 1,
|
||||
)
|
||||
for sub in subs
|
||||
]
|
||||
|
||||
items.append(
|
||||
UserTrafficItem(
|
||||
user_id=user.id,
|
||||
@@ -245,6 +287,7 @@ def _build_traffic_items(
|
||||
device_limit=device_limit,
|
||||
node_traffic=traffic,
|
||||
total_bytes=total_bytes,
|
||||
subscriptions=subscriptions_traffic,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -308,15 +351,21 @@ async def get_traffic_usage(
|
||||
# Collect all available tariff names (before filtering)
|
||||
available_tariffs = sorted(
|
||||
{
|
||||
u.subscription.tariff.name
|
||||
sub.tariff.name
|
||||
for u in user_map.values()
|
||||
if u.subscription and u.subscription.tariff and u.subscription.tariff.name
|
||||
for sub in (getattr(u, 'subscriptions', None) or [])
|
||||
if sub.tariff and sub.tariff.name
|
||||
}
|
||||
)
|
||||
|
||||
# Collect all available statuses (before filtering)
|
||||
available_statuses = sorted(
|
||||
{_get_status(sub) for u in user_map.values() if (sub := u.subscription) and _get_status(sub)}
|
||||
{
|
||||
_get_status(sub)
|
||||
for u in user_map.values()
|
||||
for sub in (getattr(u, 'subscriptions', None) or [])
|
||||
if _get_status(sub)
|
||||
}
|
||||
)
|
||||
|
||||
# Parse tariff filter
|
||||
@@ -469,27 +518,42 @@ async def _build_enrichment(db: AsyncSession, user_map: dict[str, User]) -> dict
|
||||
enrichment: dict[int, UserTrafficEnrichment] = {}
|
||||
for uuid, user in user_map.items():
|
||||
uid = user.id
|
||||
sub = user.subscription
|
||||
subs_list = getattr(user, 'subscriptions', None) or []
|
||||
|
||||
# Primary subscription for backward-compat top-level date fields
|
||||
primary_sub = next((s for s in subs_list if s.is_active), subs_list[0] if subs_list else None)
|
||||
|
||||
start_date = None
|
||||
end_date = None
|
||||
if sub:
|
||||
if sub.start_date:
|
||||
start_date = sub.start_date.isoformat()
|
||||
if sub.end_date:
|
||||
end_date = sub.end_date.isoformat()
|
||||
if primary_sub:
|
||||
if primary_sub.start_date:
|
||||
start_date = primary_sub.start_date.isoformat()
|
||||
if primary_sub.end_date:
|
||||
end_date = primary_sub.end_date.isoformat()
|
||||
|
||||
last_node_name = None
|
||||
last_uuid = last_node_uuid_by_user.get(uid)
|
||||
if last_uuid:
|
||||
last_node_name = node_uuid_to_name.get(last_uuid)
|
||||
|
||||
# Build per-subscription enrichment list for multi-subscription display
|
||||
subscriptions_enrichment = [
|
||||
SubscriptionEnrichmentInfo(
|
||||
subscription_id=sub.id,
|
||||
tariff_name=sub.tariff.name if sub.tariff else None,
|
||||
start_date=sub.start_date.isoformat() if sub.start_date else None,
|
||||
end_date=sub.end_date.isoformat() if sub.end_date else None,
|
||||
)
|
||||
for sub in subs_list
|
||||
]
|
||||
|
||||
enrichment[uid] = UserTrafficEnrichment(
|
||||
devices_connected=devices_by_user.get(uid, 0),
|
||||
total_spent_kopeks=spending_map.get(uid, 0),
|
||||
subscription_start_date=start_date,
|
||||
subscription_end_date=end_date,
|
||||
last_node_name=last_node_name,
|
||||
subscriptions=subscriptions_enrichment,
|
||||
)
|
||||
|
||||
return enrichment
|
||||
@@ -680,10 +744,7 @@ async def export_traffic_csv(
|
||||
filename = f'traffic_usage_{period_label}_{timestamp}.csv'
|
||||
|
||||
try:
|
||||
bot = Bot(
|
||||
token=settings.BOT_TOKEN,
|
||||
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
|
||||
)
|
||||
bot = create_bot()
|
||||
async with bot:
|
||||
await bot.send_document(
|
||||
chat_id=admin.telegram_id,
|
||||
|
||||
+1094
-190
File diff suppressed because it is too large
Load Diff
@@ -199,8 +199,7 @@ async def approve_withdrawal(
|
||||
|
||||
# Notify user about approval
|
||||
try:
|
||||
from aiogram import Bot
|
||||
|
||||
from app.bot_factory import create_bot
|
||||
from app.config import settings
|
||||
from app.services.notification_delivery_service import notification_delivery_service
|
||||
|
||||
@@ -211,7 +210,7 @@ async def approve_withdrawal(
|
||||
formatted_amount = settings.format_price(withdrawal.amount_kopeks)
|
||||
comment_text = f'\n{request.comment}' if request.comment else ''
|
||||
tg_message = f'✅ Ваш запрос на вывод {formatted_amount} одобрен.{comment_text}'
|
||||
bot = Bot(token=settings.BOT_TOKEN)
|
||||
bot = create_bot()
|
||||
try:
|
||||
await notification_delivery_service.notify_withdrawal_approved(
|
||||
user=user,
|
||||
@@ -251,8 +250,7 @@ async def reject_withdrawal(
|
||||
|
||||
# Notify user about rejection
|
||||
try:
|
||||
from aiogram import Bot
|
||||
|
||||
from app.bot_factory import create_bot
|
||||
from app.config import settings
|
||||
from app.services.notification_delivery_service import notification_delivery_service
|
||||
|
||||
@@ -263,7 +261,7 @@ async def reject_withdrawal(
|
||||
formatted_amount = settings.format_price(withdrawal.amount_kopeks)
|
||||
comment_text = f'\nПричина: {request.comment}' if request.comment else ''
|
||||
tg_message = f'❌ Ваш запрос на вывод {formatted_amount} отклонён.{comment_text}'
|
||||
bot = Bot(token=settings.BOT_TOKEN)
|
||||
bot = create_bot()
|
||||
try:
|
||||
await notification_delivery_service.notify_withdrawal_rejected(
|
||||
user=user,
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
"""Apple In-App Purchase cabinet route."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.apple_iap import (
|
||||
create_apple_transaction,
|
||||
)
|
||||
from app.database.crud.transaction import create_transaction as create_trans
|
||||
from app.database.crud.user import lock_user_for_update
|
||||
from app.database.models import PaymentMethod, TransactionType, User
|
||||
from app.external.apple_iap import AppleIAPService
|
||||
from app.utils.user_utils import format_referrer_info
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
from ..schemas.apple_iap import ApplePurchaseRequest, ApplePurchaseResponse
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(tags=['Cabinet Apple IAP'])
|
||||
|
||||
|
||||
def get_apple_iap_service() -> AppleIAPService:
|
||||
return AppleIAPService()
|
||||
|
||||
|
||||
@router.post('/apple-purchase', response_model=ApplePurchaseResponse)
|
||||
async def apple_purchase(
|
||||
request: ApplePurchaseRequest,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
apple_iap_service: AppleIAPService = Depends(get_apple_iap_service),
|
||||
):
|
||||
"""Verify an Apple In-App Purchase and credit the user's balance.
|
||||
|
||||
The iOS app calls this endpoint after a successful StoreKit transaction.
|
||||
If the backend returns success=false, the iOS app will NOT finish the
|
||||
transaction and will retry on next launch.
|
||||
"""
|
||||
if not settings.is_apple_iap_enabled():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Apple In-App Purchase is not enabled',
|
||||
)
|
||||
|
||||
# Validate product ID
|
||||
products = settings.get_apple_iap_products()
|
||||
if request.product_id not in products:
|
||||
logger.warning(
|
||||
'Unknown Apple product ID',
|
||||
product_id=request.product_id,
|
||||
user_id=user.id,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Unknown product ID',
|
||||
)
|
||||
|
||||
amount_kopeks = products[request.product_id]
|
||||
|
||||
# Verify transaction with Apple Server API (no DB lock needed).
|
||||
# verify_transaction automatically falls back Sandbox<->Production.
|
||||
txn_info = await apple_iap_service.verify_transaction(request.transaction_id, settings.APPLE_IAP_ENVIRONMENT)
|
||||
if not txn_info:
|
||||
logger.warning(
|
||||
'Apple transaction verification failed',
|
||||
transaction_id=request.transaction_id,
|
||||
user_id=user.id,
|
||||
)
|
||||
return ApplePurchaseResponse(success=False)
|
||||
|
||||
# Validate transaction fields
|
||||
validation_error = apple_iap_service.validate_transaction_info(txn_info, request.product_id)
|
||||
if validation_error:
|
||||
logger.warning(
|
||||
'Apple transaction validation failed',
|
||||
error=validation_error,
|
||||
transaction_id=request.transaction_id,
|
||||
user_id=user.id,
|
||||
)
|
||||
return ApplePurchaseResponse(success=False)
|
||||
|
||||
# FIX 4: appAccountToken is mandatory -- reject if missing
|
||||
app_account_token = txn_info.get('appAccountToken')
|
||||
if not app_account_token:
|
||||
logger.warning(
|
||||
'Apple appAccountToken missing -- rejecting transaction',
|
||||
transaction_id=request.transaction_id,
|
||||
user_id=user.id,
|
||||
)
|
||||
return ApplePurchaseResponse(success=False)
|
||||
|
||||
if app_account_token != str(user.id):
|
||||
logger.warning(
|
||||
'Apple appAccountToken mismatch -- possible replay',
|
||||
expected=str(user.id),
|
||||
received=app_account_token,
|
||||
transaction_id=request.transaction_id,
|
||||
user_id=user.id,
|
||||
)
|
||||
return ApplePurchaseResponse(success=False)
|
||||
|
||||
# Detect sandbox transactions -- store actual environment from Apple's response
|
||||
actual_environment = txn_info.get('environment', settings.APPLE_IAP_ENVIRONMENT)
|
||||
is_sandbox = actual_environment == 'Sandbox'
|
||||
|
||||
if is_sandbox and settings.APPLE_IAP_ENVIRONMENT == 'Production':
|
||||
# Sandbox transaction on a production server (e.g. App Review).
|
||||
# Record it for audit but do NOT credit real balance.
|
||||
logger.info(
|
||||
'Apple sandbox transaction on production -- storing without balance credit',
|
||||
transaction_id=request.transaction_id,
|
||||
product_id=request.product_id,
|
||||
user_id=user.id,
|
||||
)
|
||||
try:
|
||||
async with db.begin_nested():
|
||||
await create_apple_transaction(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
transaction_id=request.transaction_id,
|
||||
original_transaction_id=txn_info.get('originalTransactionId'),
|
||||
product_id=request.product_id,
|
||||
bundle_id=txn_info.get('bundleId', settings.APPLE_IAP_BUNDLE_ID),
|
||||
amount_kopeks=amount_kopeks,
|
||||
environment='Sandbox',
|
||||
)
|
||||
except IntegrityError:
|
||||
pass # already stored
|
||||
await db.commit()
|
||||
return ApplePurchaseResponse(success=True)
|
||||
|
||||
# Atomically insert transaction record -- unique constraint on transaction_id
|
||||
# prevents double-spend even under concurrent requests.
|
||||
apple_txn = None
|
||||
try:
|
||||
async with db.begin_nested():
|
||||
apple_txn = await create_apple_transaction(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
transaction_id=request.transaction_id,
|
||||
original_transaction_id=txn_info.get('originalTransactionId'),
|
||||
product_id=request.product_id,
|
||||
bundle_id=txn_info.get('bundleId', settings.APPLE_IAP_BUNDLE_ID),
|
||||
amount_kopeks=amount_kopeks,
|
||||
environment=actual_environment,
|
||||
)
|
||||
except IntegrityError:
|
||||
logger.info(
|
||||
'Apple transaction already processed (idempotent)',
|
||||
transaction_id=request.transaction_id,
|
||||
user_id=user.id,
|
||||
)
|
||||
return ApplePurchaseResponse(success=True)
|
||||
|
||||
# Create financial transaction record
|
||||
transaction = await create_trans(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
type=TransactionType.DEPOSIT,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=f'Пополнение через Apple IAP: {request.product_id}',
|
||||
payment_method=PaymentMethod.APPLE_IAP,
|
||||
external_id=request.transaction_id,
|
||||
is_completed=True,
|
||||
commit=False,
|
||||
)
|
||||
|
||||
# FIX 9: Link AppleTransaction to financial Transaction via FK
|
||||
if apple_txn and transaction:
|
||||
apple_txn.transaction_id_fk = transaction.id
|
||||
apple_txn.updated_at = datetime.now(UTC)
|
||||
|
||||
# Lock user row and credit balance
|
||||
user = await lock_user_for_update(db, user)
|
||||
old_balance = user.balance_kopeks
|
||||
was_first_topup = not user.has_made_first_topup
|
||||
|
||||
user.balance_kopeks += amount_kopeks
|
||||
# FIX 10: Update user.updated_at when modifying balance
|
||||
user.updated_at = datetime.now(UTC)
|
||||
|
||||
promo_group = user.get_primary_promo_group()
|
||||
subscription = getattr(user, 'subscription', None)
|
||||
referrer_info = format_referrer_info(user)
|
||||
topup_status = 'Первое пополнение' if was_first_topup else 'Пополнение'
|
||||
|
||||
await db.commit()
|
||||
|
||||
# --- Post-payment side-effects (after atomic commit) ---
|
||||
|
||||
from app.database.crud.transaction import emit_transaction_side_effects
|
||||
|
||||
try:
|
||||
await emit_transaction_side_effects(
|
||||
db,
|
||||
transaction,
|
||||
amount_kopeks=amount_kopeks,
|
||||
user_id=user.id,
|
||||
type=TransactionType.DEPOSIT,
|
||||
payment_method=PaymentMethod.APPLE_IAP,
|
||||
external_id=request.transaction_id,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error('Ошибка emit_transaction_side_effects Apple IAP', error=error)
|
||||
|
||||
try:
|
||||
from app.services.referral_service import process_referral_topup
|
||||
|
||||
await process_referral_topup(db, user.id, amount_kopeks, bot=None)
|
||||
except Exception as error:
|
||||
logger.error('Ошибка обработки реферального пополнения Apple IAP', error=error)
|
||||
|
||||
if was_first_topup and not user.has_made_first_topup and not user.referred_by_id:
|
||||
user.has_made_first_topup = True
|
||||
await db.commit()
|
||||
|
||||
await db.refresh(user)
|
||||
|
||||
# Admin notification + cart auto-purchase
|
||||
try:
|
||||
from app.bot_factory import create_bot
|
||||
|
||||
bot = create_bot()
|
||||
try:
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
notification_service = AdminNotificationService(bot)
|
||||
await notification_service.send_balance_topup_notification(
|
||||
user,
|
||||
transaction,
|
||||
old_balance,
|
||||
topup_status=topup_status,
|
||||
referrer_info=referrer_info,
|
||||
subscription=subscription,
|
||||
promo_group=promo_group,
|
||||
db=db,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error('Ошибка отправки админ уведомления Apple IAP', error=error)
|
||||
|
||||
try:
|
||||
from app.services.payment.common import send_cart_notification_after_topup
|
||||
|
||||
await send_cart_notification_after_topup(user, amount_kopeks, db, bot)
|
||||
except Exception as error:
|
||||
logger.error('Ошибка при работе с сохраненной корзиной Apple IAP', user_id=user.id, error=error)
|
||||
finally:
|
||||
await bot.session.close()
|
||||
except Exception as error:
|
||||
logger.error('Ошибка создания бота для уведомлений Apple IAP', error=error)
|
||||
|
||||
logger.info(
|
||||
'Apple IAP purchase credited',
|
||||
transaction_id=request.transaction_id,
|
||||
product_id=request.product_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
user_id=user.id,
|
||||
)
|
||||
|
||||
return ApplePurchaseResponse(success=True)
|
||||
+822
-189
File diff suppressed because it is too large
Load Diff
+564
-62
@@ -4,16 +4,17 @@ import math
|
||||
import time
|
||||
from decimal import ROUND_HALF_UP, Decimal, InvalidOperation
|
||||
|
||||
import httpx
|
||||
import structlog
|
||||
from aiogram import Bot
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import desc, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.bot_factory import create_bot
|
||||
from app.config import settings
|
||||
from app.database.crud.saved_payment_method import (
|
||||
deactivate_payment_method,
|
||||
get_active_payment_methods_by_user,
|
||||
)
|
||||
from app.database.crud.user import get_user_by_id
|
||||
from app.database.models import PaymentMethod, Transaction, User
|
||||
from app.services.payment_method_config_service import get_enabled_methods_for_user
|
||||
@@ -35,6 +36,8 @@ from ..schemas.balance import (
|
||||
PaymentMethodResponse,
|
||||
PendingPaymentListResponse,
|
||||
PendingPaymentResponse,
|
||||
SavedCardResponse,
|
||||
SavedCardsListResponse,
|
||||
StarsInvoiceRequest,
|
||||
StarsInvoiceResponse,
|
||||
TopUpRequest,
|
||||
@@ -102,8 +105,8 @@ async def get_transactions(
|
||||
for t in transactions:
|
||||
# Determine sign based on transaction type
|
||||
# Credits (positive): DEPOSIT, REFERRAL_REWARD, REFUND, POLL_REWARD
|
||||
# Debits (negative): SUBSCRIPTION_PAYMENT, WITHDRAWAL
|
||||
is_debit = t.type in ['subscription_payment', 'withdrawal']
|
||||
# Debits (negative): SUBSCRIPTION_PAYMENT, WITHDRAWAL, GIFT_PAYMENT
|
||||
is_debit = t.type in ['subscription_payment', 'withdrawal', 'gift_payment']
|
||||
amount_kopeks = -abs(t.amount_kopeks) if is_debit else abs(t.amount_kopeks)
|
||||
|
||||
items.append(
|
||||
@@ -198,7 +201,7 @@ async def get_payment_methods(
|
||||
'description': description,
|
||||
}
|
||||
)
|
||||
options = formatted_options if formatted_options else None
|
||||
options = formatted_options or None
|
||||
|
||||
methods.append(
|
||||
PaymentMethodResponse(
|
||||
@@ -244,13 +247,16 @@ async def create_stars_invoice(
|
||||
detail='Maximum amount is 10,000.00 RUB',
|
||||
)
|
||||
|
||||
# Calculate Stars amount
|
||||
# Calculate Stars amount and normalize kopeks to match exact star value
|
||||
try:
|
||||
amount_rubles = request.amount_kopeks / 100
|
||||
stars_amount = settings.rubles_to_stars(amount_rubles)
|
||||
|
||||
if stars_amount <= 0:
|
||||
stars_amount = 1
|
||||
|
||||
# Normalize kopeks so credited amount = stars * rate (no rounding mismatch)
|
||||
normalized_kopeks = round(stars_amount * settings.get_stars_rate() * 100)
|
||||
except Exception as e:
|
||||
logger.error('Error calculating Stars amount', error=e)
|
||||
raise HTTPException(
|
||||
@@ -259,54 +265,41 @@ async def create_stars_invoice(
|
||||
)
|
||||
|
||||
# Create payload for tracking payment
|
||||
payload = f'balance_topup_{user.id}_{request.amount_kopeks}_{int(time.time())}'
|
||||
payload = f'balance_topup_{user.id}_{normalized_kopeks}_{int(time.time())}'
|
||||
|
||||
# Create invoice through Telegram Bot API
|
||||
try:
|
||||
bot_token = settings.BOT_TOKEN
|
||||
api_url = f'https://api.telegram.org/bot{bot_token}/createInvoiceLink'
|
||||
from aiogram.exceptions import TelegramAPIError
|
||||
from aiogram.types import LabeledPrice
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
api_url,
|
||||
json={
|
||||
'title': 'Пополнение баланса VPN',
|
||||
'description': f'Пополнение баланса на {amount_rubles:.2f} ₽ ({stars_amount} ⭐)',
|
||||
'payload': payload,
|
||||
'provider_token': '', # Empty for Stars
|
||||
'currency': 'XTR',
|
||||
'prices': [{'label': 'Пополнение баланса', 'amount': stars_amount}],
|
||||
},
|
||||
async with create_bot() as bot:
|
||||
invoice_url = await bot.create_invoice_link(
|
||||
title='Пополнение баланса VPN',
|
||||
description=f'Пополнение баланса на {normalized_kopeks / 100:.2f} ₽ ({stars_amount} ⭐)',
|
||||
payload=payload,
|
||||
provider_token='',
|
||||
currency='XTR',
|
||||
prices=[LabeledPrice(label='Пополнение баланса', amount=stars_amount)],
|
||||
)
|
||||
|
||||
result = response.json()
|
||||
logger.info(
|
||||
'Created Stars invoice for balance top-up: user=, amount= kopeks, stars',
|
||||
user_id=user.id,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
stars_amount=stars_amount,
|
||||
)
|
||||
|
||||
if not result.get('ok'):
|
||||
logger.error('Telegram API error', result=result)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to create Stars invoice',
|
||||
)
|
||||
return StarsInvoiceResponse(
|
||||
invoice_url=invoice_url,
|
||||
stars_amount=stars_amount,
|
||||
amount_kopeks=normalized_kopeks,
|
||||
)
|
||||
|
||||
invoice_url = result['result']
|
||||
logger.info(
|
||||
'Created Stars invoice for balance top-up: user=, amount= kopeks, stars',
|
||||
user_id=user.id,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
stars_amount=stars_amount,
|
||||
)
|
||||
|
||||
return StarsInvoiceResponse(
|
||||
invoice_url=invoice_url,
|
||||
stars_amount=stars_amount,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
)
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
logger.error('HTTP error creating Stars invoice', error=e)
|
||||
except TelegramAPIError as e:
|
||||
logger.error('Error creating Stars invoice', error=e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to connect to Telegram API',
|
||||
detail='Failed to create Stars invoice',
|
||||
)
|
||||
|
||||
|
||||
@@ -349,6 +342,9 @@ async def create_topup(
|
||||
amount_rubles = request.amount_kopeks / 100
|
||||
payment_url = None
|
||||
payment_id = None
|
||||
cabinet_return_url = f'{settings.CABINET_URL.rstrip("/")}/balance/top-up/result?method={request.payment_method}'
|
||||
cabinet_success_url = f'{cabinet_return_url}&status=success'
|
||||
cabinet_failed_url = f'{cabinet_return_url}&status=failed'
|
||||
|
||||
try:
|
||||
if request.payment_method == 'yookassa':
|
||||
@@ -364,7 +360,7 @@ async def create_topup(
|
||||
option = (request.payment_option or '').strip().lower()
|
||||
# Use description with telegram_id for tax receipts
|
||||
description = settings.get_balance_payment_description(
|
||||
request.amount_kopeks, telegram_user_id=user.telegram_id
|
||||
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
|
||||
)
|
||||
if option == 'sbp':
|
||||
result = await payment_service.create_yookassa_sbp_payment(
|
||||
@@ -373,6 +369,7 @@ async def create_topup(
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
description=description,
|
||||
metadata=yookassa_metadata,
|
||||
return_url=cabinet_return_url,
|
||||
)
|
||||
else:
|
||||
result = await payment_service.create_yookassa_payment(
|
||||
@@ -381,11 +378,12 @@ async def create_topup(
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
description=description,
|
||||
metadata=yookassa_metadata,
|
||||
return_url=cabinet_return_url,
|
||||
)
|
||||
|
||||
if result:
|
||||
payment_url = result.get('confirmation_url')
|
||||
payment_id = result.get('yookassa_payment_id')
|
||||
payment_id = str(result.get('local_payment_id') or result.get('yookassa_payment_id') or 'pending')
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
@@ -425,7 +423,7 @@ async def create_topup(
|
||||
amount_usd=amount_usd,
|
||||
asset=settings.CRYPTOBOT_DEFAULT_ASSET,
|
||||
description=settings.get_balance_payment_description(
|
||||
request.amount_kopeks, telegram_user_id=user.telegram_id
|
||||
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
|
||||
),
|
||||
payload=f'cabinet_topup_{user.id}_{request.amount_kopeks}',
|
||||
)
|
||||
@@ -486,10 +484,12 @@ async def create_topup(
|
||||
user_id=user.id,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
description=settings.get_balance_payment_description(
|
||||
request.amount_kopeks, telegram_user_id=user.telegram_id
|
||||
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
|
||||
),
|
||||
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
|
||||
payment_method_code=method_code,
|
||||
return_url=cabinet_success_url,
|
||||
failed_url=cabinet_failed_url,
|
||||
)
|
||||
|
||||
if result and result.get('redirect_url'):
|
||||
@@ -513,8 +513,12 @@ async def create_topup(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
description=settings.get_balance_payment_description(request.amount_kopeks),
|
||||
description=settings.get_balance_payment_description(
|
||||
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
|
||||
),
|
||||
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
|
||||
return_url=cabinet_return_url,
|
||||
success_url=cabinet_success_url,
|
||||
)
|
||||
|
||||
if result and result.get('payment_url'):
|
||||
@@ -538,7 +542,9 @@ async def create_topup(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
description=settings.get_balance_payment_description(request.amount_kopeks),
|
||||
description=settings.get_balance_payment_description(
|
||||
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
|
||||
),
|
||||
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
|
||||
)
|
||||
|
||||
@@ -562,16 +568,17 @@ async def create_topup(
|
||||
option = (request.payment_option or '').strip().lower()
|
||||
if option not in {'card', 'sbp'}:
|
||||
option = 'sbp'
|
||||
provider_method = 'card' if option == 'card' else 'sbp'
|
||||
|
||||
payment_service = PaymentService()
|
||||
result = await payment_service.create_pal24_payment(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
description=settings.get_balance_payment_description(request.amount_kopeks),
|
||||
description=settings.get_balance_payment_description(
|
||||
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
|
||||
),
|
||||
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
|
||||
payment_method=provider_method,
|
||||
payment_method=option,
|
||||
)
|
||||
|
||||
if result:
|
||||
@@ -610,8 +617,12 @@ async def create_topup(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
description=settings.get_balance_payment_description(request.amount_kopeks),
|
||||
description=settings.get_balance_payment_description(
|
||||
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
|
||||
),
|
||||
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
|
||||
return_url=cabinet_success_url,
|
||||
failed_url=cabinet_failed_url,
|
||||
)
|
||||
|
||||
if result and result.get('payment_url'):
|
||||
@@ -635,9 +646,13 @@ async def create_topup(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
description=settings.get_balance_payment_description(request.amount_kopeks),
|
||||
description=settings.get_balance_payment_description(
|
||||
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
|
||||
),
|
||||
telegram_id=user.telegram_id,
|
||||
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
|
||||
return_url=cabinet_success_url,
|
||||
failed_url=cabinet_failed_url,
|
||||
)
|
||||
|
||||
if result and result.get('payment_url'):
|
||||
@@ -661,7 +676,9 @@ async def create_topup(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
description=settings.get_balance_payment_description(request.amount_kopeks),
|
||||
description=settings.get_balance_payment_description(
|
||||
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
|
||||
),
|
||||
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
|
||||
)
|
||||
|
||||
@@ -681,14 +698,22 @@ async def create_topup(
|
||||
detail='KassaAI payment method is unavailable',
|
||||
)
|
||||
|
||||
# Use payment_option to select sbp or card
|
||||
KASSA_AI_OPTION_MAP = {'sbp': 44, 'card': 36, 'sberpay': 43}
|
||||
option = (request.payment_option or '').strip().lower()
|
||||
ps_id = KASSA_AI_OPTION_MAP.get(option) # None = use env default
|
||||
|
||||
payment_service = PaymentService()
|
||||
result = await payment_service.create_kassa_ai_payment(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
description=settings.get_balance_payment_description(request.amount_kopeks),
|
||||
description=settings.get_balance_payment_description(
|
||||
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
|
||||
),
|
||||
email=getattr(user, 'email', None),
|
||||
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
|
||||
payment_system_id=ps_id,
|
||||
)
|
||||
|
||||
if result and result.get('payment_url'):
|
||||
@@ -700,6 +725,35 @@ async def create_topup(
|
||||
detail='Failed to create KassaAI payment',
|
||||
)
|
||||
|
||||
elif request.payment_method == 'riopay':
|
||||
if not settings.is_riopay_enabled():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='RioPay payment method is unavailable',
|
||||
)
|
||||
|
||||
payment_service = PaymentService()
|
||||
result = await payment_service.create_riopay_payment(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
description=settings.get_balance_payment_description(
|
||||
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
|
||||
),
|
||||
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
|
||||
success_url=cabinet_success_url,
|
||||
fail_url=cabinet_failed_url,
|
||||
)
|
||||
|
||||
if result and result.get('payment_url'):
|
||||
payment_url = result.get('payment_url')
|
||||
payment_id = str(result.get('local_payment_id') or result.get('riopay_order_id') or 'pending')
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to create RioPay payment',
|
||||
)
|
||||
|
||||
elif request.payment_method == 'tribute':
|
||||
if not settings.TRIBUTE_ENABLED or not settings.TRIBUTE_DONATE_LINK:
|
||||
raise HTTPException(
|
||||
@@ -711,6 +765,248 @@ async def create_topup(
|
||||
payment_url = f'{settings.TRIBUTE_DONATE_LINK}&user_id={user_identifier}'
|
||||
payment_id = f'tribute_{user_identifier}_{request.amount_kopeks}'
|
||||
|
||||
elif request.payment_method == 'severpay':
|
||||
if not settings.is_severpay_enabled():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='SeverPay payment method is unavailable',
|
||||
)
|
||||
|
||||
payment_service = PaymentService()
|
||||
result = await payment_service.create_severpay_payment(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
description=settings.get_balance_payment_description(
|
||||
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
|
||||
),
|
||||
email=getattr(user, 'email', None),
|
||||
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
|
||||
return_url=cabinet_success_url,
|
||||
)
|
||||
|
||||
if result and result.get('payment_url'):
|
||||
payment_url = result.get('payment_url')
|
||||
payment_id = str(result.get('local_payment_id') or result.get('order_id') or 'pending')
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to create SeverPay payment',
|
||||
)
|
||||
|
||||
elif request.payment_method == 'paypear':
|
||||
if not settings.is_paypear_enabled():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='PayPear payment method is unavailable',
|
||||
)
|
||||
|
||||
payment_service = PaymentService()
|
||||
result = await payment_service.create_paypear_payment(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
description=settings.get_balance_payment_description(
|
||||
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
|
||||
),
|
||||
email=getattr(user, 'email', None),
|
||||
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
|
||||
return_url=cabinet_success_url,
|
||||
)
|
||||
|
||||
if result and result.get('payment_url'):
|
||||
payment_url = result.get('payment_url')
|
||||
payment_id = str(result.get('local_payment_id') or result.get('order_id') or 'pending')
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to create PayPear payment',
|
||||
)
|
||||
|
||||
elif request.payment_method == 'rollypay':
|
||||
if not settings.is_rollypay_enabled():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='RollyPay payment method is unavailable',
|
||||
)
|
||||
|
||||
payment_service = PaymentService()
|
||||
payment_method_type = request.payment_option or None
|
||||
result = await payment_service.create_rollypay_payment(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
description=settings.get_balance_payment_description(
|
||||
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
|
||||
),
|
||||
email=getattr(user, 'email', None),
|
||||
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
|
||||
payment_method_type=payment_method_type,
|
||||
return_url=cabinet_success_url,
|
||||
)
|
||||
|
||||
if result and result.get('payment_url'):
|
||||
payment_url = result.get('payment_url')
|
||||
payment_id = str(result.get('local_payment_id') or result.get('order_id') or 'pending')
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to create RollyPay payment',
|
||||
)
|
||||
|
||||
elif request.payment_method == 'overpay':
|
||||
if not settings.is_overpay_enabled():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Overpay payment method is unavailable',
|
||||
)
|
||||
|
||||
payment_service = PaymentService()
|
||||
result = await payment_service.create_overpay_payment(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
description=settings.get_balance_payment_description(
|
||||
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
|
||||
),
|
||||
email=getattr(user, 'email', None),
|
||||
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
|
||||
return_url=cabinet_success_url,
|
||||
)
|
||||
|
||||
if result and result.get('payment_url'):
|
||||
payment_url = result.get('payment_url')
|
||||
payment_id = str(result.get('local_payment_id') or result.get('order_id') or 'pending')
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to create Overpay payment',
|
||||
)
|
||||
|
||||
elif request.payment_method == 'aurapay':
|
||||
if not settings.is_aurapay_enabled():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='AuraPay payment method is unavailable',
|
||||
)
|
||||
|
||||
payment_service = PaymentService()
|
||||
payment_method_type = request.payment_option or None
|
||||
result = await payment_service.create_aurapay_payment(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
description=settings.get_balance_payment_description(
|
||||
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
|
||||
),
|
||||
email=getattr(user, 'email', None),
|
||||
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
|
||||
payment_method_type=payment_method_type,
|
||||
return_url=cabinet_success_url,
|
||||
)
|
||||
|
||||
if result and result.get('payment_url'):
|
||||
payment_url = result.get('payment_url')
|
||||
payment_id = str(result.get('local_payment_id') or result.get('order_id') or 'pending')
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to create AuraPay payment',
|
||||
)
|
||||
|
||||
elif request.payment_method == 'jupiter':
|
||||
if not settings.is_jupiter_enabled():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Jupiter payment method is unavailable',
|
||||
)
|
||||
|
||||
payment_service = PaymentService()
|
||||
payment_method_type = request.payment_option or None
|
||||
result = await payment_service.create_jupiter_payment(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
description=settings.get_balance_payment_description(
|
||||
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
|
||||
),
|
||||
email=getattr(user, 'email', None),
|
||||
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
|
||||
payment_method_type=payment_method_type,
|
||||
return_url=cabinet_success_url,
|
||||
)
|
||||
|
||||
if result and result.get('payment_url'):
|
||||
payment_url = result.get('payment_url')
|
||||
payment_id = str(result.get('local_payment_id') or result.get('order_id') or 'pending')
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to create Jupiter payment',
|
||||
)
|
||||
|
||||
elif request.payment_method == 'donut':
|
||||
if not settings.is_donut_enabled():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Donut payment method is unavailable',
|
||||
)
|
||||
|
||||
payment_service = PaymentService()
|
||||
payment_method_type = request.payment_option or None
|
||||
result = await payment_service.create_donut_payment(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
description=settings.get_balance_payment_description(
|
||||
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
|
||||
),
|
||||
email=getattr(user, 'email', None),
|
||||
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
|
||||
payment_method_type=payment_method_type,
|
||||
return_url=cabinet_success_url,
|
||||
)
|
||||
|
||||
if result and result.get('payment_url'):
|
||||
payment_url = result.get('payment_url')
|
||||
payment_id = str(result.get('local_payment_id') or result.get('order_id') or 'pending')
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to create Donut payment',
|
||||
)
|
||||
|
||||
elif request.payment_method == 'lava':
|
||||
if not settings.is_lava_enabled():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Lava payment method is unavailable',
|
||||
)
|
||||
|
||||
payment_service = PaymentService()
|
||||
payment_method_type = request.payment_option or None
|
||||
result = await payment_service.create_lava_payment(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
description=settings.get_balance_payment_description(
|
||||
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
|
||||
),
|
||||
email=getattr(user, 'email', None),
|
||||
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
|
||||
payment_method_type=payment_method_type,
|
||||
return_url=cabinet_success_url,
|
||||
)
|
||||
|
||||
if result and result.get('payment_url'):
|
||||
payment_url = result.get('payment_url')
|
||||
payment_id = str(result.get('local_payment_id') or result.get('order_id') or 'pending')
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to create Lava payment',
|
||||
)
|
||||
|
||||
else:
|
||||
# For other payment methods, redirect to bot
|
||||
raise HTTPException(
|
||||
@@ -851,6 +1147,56 @@ def _get_status_info(record: PendingPayment) -> tuple[str, str]:
|
||||
}
|
||||
return mapping.get(status, ('❓', 'Неизвестно'))
|
||||
|
||||
if record.method == PaymentMethod.RIOPAY:
|
||||
mapping = {
|
||||
'pending': ('⏳', 'Ожидает оплаты'),
|
||||
'success': ('✅', 'Оплачено'),
|
||||
'failed': ('❌', 'Ошибка'),
|
||||
'canceled': ('❌', 'Отменено'),
|
||||
'expired': ('⌛', 'Истёк'),
|
||||
'amount_mismatch': ('⚠️', 'Несовпадение суммы'),
|
||||
}
|
||||
return mapping.get(status, ('❓', 'Неизвестно'))
|
||||
|
||||
if record.method == PaymentMethod.JUPITER:
|
||||
mapping = {
|
||||
'pending': ('⏳', 'Ожидает оплаты'),
|
||||
'processing': ('⌛', 'Обрабатывается'),
|
||||
'success': ('✅', 'Оплачено'),
|
||||
'cancelled': ('❌', 'Отменено'),
|
||||
'declined': ('❌', 'Отклонено'),
|
||||
'error': ('❌', 'Ошибка'),
|
||||
'amount_mismatch': ('⚠️', 'Несовпадение суммы'),
|
||||
}
|
||||
return mapping.get(status, ('❓', 'Неизвестно'))
|
||||
|
||||
if record.method == PaymentMethod.DONUT:
|
||||
mapping = {
|
||||
'pending': ('⏳', 'Ожидает оплаты'),
|
||||
'created': ('⏳', 'Создано'),
|
||||
'processing': ('⌛', 'Обрабатывается'),
|
||||
'success': ('✅', 'Оплачено'),
|
||||
'cancelled': ('❌', 'Отменено'),
|
||||
'error': ('❌', 'Ошибка'),
|
||||
'amount_mismatch': ('⚠️', 'Несовпадение суммы'),
|
||||
}
|
||||
return mapping.get(status, ('❓', 'Неизвестно'))
|
||||
|
||||
if record.method == PaymentMethod.LAVA:
|
||||
mapping = {
|
||||
'pending': ('⏳', 'Ожидает оплаты'),
|
||||
'created': ('⏳', 'Создано'),
|
||||
'processing': ('⌛', 'Обрабатывается'),
|
||||
'success': ('✅', 'Оплачено'),
|
||||
'cancel': ('❌', 'Отменено'),
|
||||
'cancelled': ('❌', 'Отменено'),
|
||||
'expired': ('⌛', 'Истёк'),
|
||||
'failed': ('❌', 'Ошибка'),
|
||||
'error': ('❌', 'Ошибка'),
|
||||
'amount_mismatch': ('⚠️', 'Несовпадение суммы'),
|
||||
}
|
||||
return mapping.get(status, ('❓', 'Неизвестно'))
|
||||
|
||||
return '❓', 'Неизвестно'
|
||||
|
||||
|
||||
@@ -874,13 +1220,15 @@ def _is_checkable(record: PendingPayment) -> bool:
|
||||
if record.method == PaymentMethod.YOOKASSA:
|
||||
return status in {'pending', 'waiting_for_capture'}
|
||||
if record.method == PaymentMethod.CRYPTOBOT:
|
||||
return status in {'active'}
|
||||
return status == 'active'
|
||||
if record.method == PaymentMethod.CLOUDPAYMENTS:
|
||||
return status in {'pending', 'authorized'}
|
||||
if record.method == PaymentMethod.FREEKASSA:
|
||||
return status in {'pending', 'created', 'processing'}
|
||||
if record.method == PaymentMethod.KASSA_AI:
|
||||
return status in {'pending', 'created', 'processing'}
|
||||
if record.method == PaymentMethod.RIOPAY:
|
||||
return status in {'pending'}
|
||||
return False
|
||||
|
||||
|
||||
@@ -904,7 +1252,12 @@ def _get_payment_url(record: PendingPayment) -> str | None:
|
||||
)
|
||||
elif record.method == PaymentMethod.PLATEGA:
|
||||
payment_url = getattr(payment, 'redirect_url', None) or payment_url
|
||||
elif record.method in (PaymentMethod.CLOUDPAYMENTS, PaymentMethod.FREEKASSA, PaymentMethod.KASSA_AI):
|
||||
elif record.method in (
|
||||
PaymentMethod.CLOUDPAYMENTS,
|
||||
PaymentMethod.FREEKASSA,
|
||||
PaymentMethod.KASSA_AI,
|
||||
PaymentMethod.RIOPAY,
|
||||
):
|
||||
payment_url = getattr(payment, 'payment_url', None) or payment_url
|
||||
|
||||
return payment_url
|
||||
@@ -965,6 +1318,103 @@ async def get_pending_payments(
|
||||
)
|
||||
|
||||
|
||||
@router.get('/pending-payments/{method}/latest', response_model=PendingPaymentResponse)
|
||||
async def get_latest_payment_by_method(
|
||||
method: str,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get user's most recent payment for a given method (any status, not just pending)."""
|
||||
try:
|
||||
payment_method = PaymentMethod(method)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Invalid payment method: {method}',
|
||||
)
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import (
|
||||
AuraPayPayment,
|
||||
CloudPaymentsPayment,
|
||||
CryptoBotPayment,
|
||||
FreekassaPayment,
|
||||
HeleketPayment,
|
||||
KassaAiPayment,
|
||||
MulenPayPayment,
|
||||
OverpayPayment,
|
||||
Pal24Payment,
|
||||
PayPearPayment,
|
||||
PlategaPayment,
|
||||
RioPayPayment,
|
||||
RollyPayPayment,
|
||||
SeverPayPayment,
|
||||
WataPayment,
|
||||
YooKassaPayment,
|
||||
)
|
||||
|
||||
model_map: dict[PaymentMethod, type] = {
|
||||
PaymentMethod.YOOKASSA: YooKassaPayment,
|
||||
PaymentMethod.CRYPTOBOT: CryptoBotPayment,
|
||||
PaymentMethod.HELEKET: HeleketPayment,
|
||||
PaymentMethod.MULENPAY: MulenPayPayment,
|
||||
PaymentMethod.PAL24: Pal24Payment,
|
||||
PaymentMethod.WATA: WataPayment,
|
||||
PaymentMethod.PLATEGA: PlategaPayment,
|
||||
PaymentMethod.CLOUDPAYMENTS: CloudPaymentsPayment,
|
||||
PaymentMethod.FREEKASSA: FreekassaPayment,
|
||||
PaymentMethod.KASSA_AI: KassaAiPayment,
|
||||
PaymentMethod.RIOPAY: RioPayPayment,
|
||||
PaymentMethod.SEVERPAY: SeverPayPayment,
|
||||
PaymentMethod.ROLLYPAY: RollyPayPayment,
|
||||
PaymentMethod.PAYPEAR: PayPearPayment,
|
||||
PaymentMethod.OVERPAY: OverpayPayment,
|
||||
PaymentMethod.AURAPAY: AuraPayPayment,
|
||||
}
|
||||
|
||||
model = model_map.get(payment_method)
|
||||
if not model:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Unsupported payment method: {method}',
|
||||
)
|
||||
|
||||
cutoff = datetime.now(UTC) - timedelta(hours=1)
|
||||
stmt = (
|
||||
select(model)
|
||||
.options(selectinload(model.user))
|
||||
.where(model.user_id == user.id, model.created_at >= cutoff)
|
||||
.order_by(desc(model.created_at))
|
||||
.limit(1)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
payment = result.scalars().first()
|
||||
|
||||
if not payment:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='No recent payments found',
|
||||
)
|
||||
|
||||
record = PendingPayment(
|
||||
local_id=payment.id,
|
||||
method=payment_method,
|
||||
identifier=str(getattr(payment, 'correlation_id', None) or payment.id),
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or '',
|
||||
is_paid=bool(payment.is_paid),
|
||||
created_at=payment.created_at,
|
||||
expires_at=getattr(payment, 'expires_at', None),
|
||||
user=payment.user,
|
||||
payment=payment,
|
||||
)
|
||||
|
||||
return _record_to_response(record)
|
||||
|
||||
|
||||
@router.get('/pending-payments/{method}/{payment_id}', response_model=PendingPaymentResponse)
|
||||
async def get_pending_payment_details(
|
||||
method: str,
|
||||
@@ -1044,7 +1494,7 @@ async def check_payment_status(
|
||||
old_is_paid = record.is_paid
|
||||
|
||||
# Run manual check
|
||||
bot = Bot(token=settings.BOT_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
|
||||
bot = create_bot()
|
||||
try:
|
||||
payment_service = PaymentService(bot=bot)
|
||||
updated = await run_manual_check(db, payment_method, payment_id, payment_service)
|
||||
@@ -1075,3 +1525,55 @@ async def check_payment_status(
|
||||
old_status=old_status,
|
||||
new_status=updated.status,
|
||||
)
|
||||
|
||||
|
||||
@router.get('/saved-cards', response_model=SavedCardsListResponse)
|
||||
async def get_saved_cards(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get user's saved payment methods (cards) for recurrent payments."""
|
||||
recurrent_enabled = settings.YOOKASSA_RECURRENT_ENABLED
|
||||
|
||||
if not recurrent_enabled:
|
||||
return SavedCardsListResponse(cards=[], recurrent_enabled=False)
|
||||
|
||||
methods = await get_active_payment_methods_by_user(db, user.id)
|
||||
|
||||
cards = [
|
||||
SavedCardResponse(
|
||||
id=m.id,
|
||||
method_type=m.method_type,
|
||||
card_last4=m.card_last4,
|
||||
card_type=m.card_type,
|
||||
title=m.title,
|
||||
created_at=m.created_at,
|
||||
)
|
||||
for m in methods
|
||||
]
|
||||
|
||||
return SavedCardsListResponse(cards=cards, recurrent_enabled=True)
|
||||
|
||||
|
||||
@router.delete('/saved-cards/{card_id}', status_code=status.HTTP_200_OK)
|
||||
async def delete_saved_card(
|
||||
card_id: int,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Unlink (deactivate) a saved payment method."""
|
||||
if not settings.YOOKASSA_RECURRENT_ENABLED:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Recurrent payments are not enabled',
|
||||
)
|
||||
|
||||
success = await deactivate_payment_method(db, card_id, user.id)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Saved card not found',
|
||||
)
|
||||
|
||||
return {'success': True, 'message': 'Card unlinked successfully'}
|
||||
|
||||
+196
-18
@@ -1,5 +1,6 @@
|
||||
"""Branding routes for cabinet - logo, project name, and theme colors management."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
@@ -13,9 +14,10 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.system_setting import get_setting_value
|
||||
from app.database.models import SystemSetting, User
|
||||
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
from ..dependencies import get_cabinet_db, get_current_cabinet_user, require_permission
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
@@ -38,7 +40,14 @@ YANDEX_METRIKA_ID_KEY = 'CABINET_YANDEX_METRIKA_ID' # Stores counter ID (numeri
|
||||
GOOGLE_ADS_ID_KEY = 'CABINET_GOOGLE_ADS_ID' # Stores conversion ID (e.g. "AW-123456789")
|
||||
GOOGLE_ADS_LABEL_KEY = 'CABINET_GOOGLE_ADS_LABEL' # Stores conversion label (alphanumeric)
|
||||
LITE_MODE_ENABLED_KEY = 'CABINET_LITE_MODE_ENABLED' # Stores "true" or "false"
|
||||
GIFT_ENABLED_KEY = 'CABINET_GIFT_ENABLED' # Stores "true" or "false"
|
||||
ANIMATION_CONFIG_KEY = 'CABINET_ANIMATION_CONFIG' # Stores JSON with animation config
|
||||
TELEGRAM_WIDGET_SIZE_KEY = 'TELEGRAM_WIDGET_SIZE'
|
||||
TELEGRAM_WIDGET_RADIUS_KEY = 'TELEGRAM_WIDGET_RADIUS'
|
||||
TELEGRAM_WIDGET_USERPIC_KEY = 'TELEGRAM_WIDGET_USERPIC'
|
||||
TELEGRAM_WIDGET_REQUEST_ACCESS_KEY = 'TELEGRAM_WIDGET_REQUEST_ACCESS'
|
||||
TELEGRAM_OIDC_ENABLED_KEY = 'TELEGRAM_OIDC_ENABLED'
|
||||
TELEGRAM_OIDC_CLIENT_ID_KEY = 'TELEGRAM_OIDC_CLIENT_ID'
|
||||
|
||||
# Default animation config
|
||||
DEFAULT_ANIMATION_CONFIG = {
|
||||
@@ -235,6 +244,7 @@ class EmailAuthEnabledResponse(BaseModel):
|
||||
"""Email auth enabled setting."""
|
||||
|
||||
enabled: bool = True
|
||||
verification_enabled: bool = True
|
||||
|
||||
|
||||
class EmailAuthEnabledUpdate(BaseModel):
|
||||
@@ -243,6 +253,20 @@ class EmailAuthEnabledUpdate(BaseModel):
|
||||
enabled: bool
|
||||
|
||||
|
||||
class TelegramWidgetConfigResponse(BaseModel):
|
||||
"""Public Telegram Login Widget configuration."""
|
||||
|
||||
bot_username: str
|
||||
size: Literal['large', 'medium', 'small'] = 'large'
|
||||
radius: int = Field(default=8, ge=0, le=20)
|
||||
userpic: bool = True
|
||||
request_access: bool = True
|
||||
|
||||
# OIDC fields (frontend decides which flow to use)
|
||||
oidc_enabled: bool = False
|
||||
oidc_client_id: str = ''
|
||||
|
||||
|
||||
class LiteModeEnabledResponse(BaseModel):
|
||||
"""Lite mode enabled setting."""
|
||||
|
||||
@@ -255,12 +279,36 @@ class LiteModeEnabledUpdate(BaseModel):
|
||||
enabled: bool
|
||||
|
||||
|
||||
class GiftEnabledResponse(BaseModel):
|
||||
"""Gift feature enabled setting."""
|
||||
|
||||
enabled: bool = False
|
||||
|
||||
|
||||
class GiftEnabledUpdate(BaseModel):
|
||||
"""Request to update gift feature setting."""
|
||||
|
||||
enabled: bool
|
||||
|
||||
|
||||
class OfflineConvGoal(BaseModel):
|
||||
"""Yandex Metrika offline conversion goal descriptor."""
|
||||
|
||||
name: str
|
||||
event_id: str
|
||||
dedup: str
|
||||
|
||||
|
||||
class AnalyticsCountersResponse(BaseModel):
|
||||
"""Analytics counter settings."""
|
||||
|
||||
yandex_metrika_id: str = ''
|
||||
google_ads_id: str = ''
|
||||
google_ads_label: str = ''
|
||||
offline_conv_enabled: bool = False
|
||||
offline_conv_counter_id: str = ''
|
||||
offline_conv_measurement_secret_masked: str = ''
|
||||
offline_conv_goals: list[OfflineConvGoal] = []
|
||||
|
||||
|
||||
class AnalyticsCountersUpdate(BaseModel):
|
||||
@@ -296,13 +344,6 @@ def ensure_branding_dir():
|
||||
BRANDING_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
async def get_setting_value(db: AsyncSession, key: str) -> str | None:
|
||||
"""Get a setting value from database."""
|
||||
result = await db.execute(select(SystemSetting).where(SystemSetting.key == key))
|
||||
setting = result.scalar_one_or_none()
|
||||
return setting.value if setting else None
|
||||
|
||||
|
||||
async def set_setting_value(db: AsyncSession, key: str, value: str):
|
||||
"""Set a setting value in database."""
|
||||
result = await db.execute(select(SystemSetting).where(SystemSetting.key == key))
|
||||
@@ -374,7 +415,7 @@ async def get_logo():
|
||||
"""
|
||||
logo_path = get_logo_path()
|
||||
|
||||
if logo_path is None or not logo_path.exists():
|
||||
if logo_path is None or not await asyncio.to_thread(logo_path.exists):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='No custom logo set')
|
||||
|
||||
# Determine media type from file extension
|
||||
@@ -443,7 +484,7 @@ async def upload_logo(
|
||||
)
|
||||
|
||||
# Ensure directory exists
|
||||
ensure_branding_dir()
|
||||
await asyncio.to_thread(ensure_branding_dir)
|
||||
|
||||
# Determine file extension from content type
|
||||
ext_map = {
|
||||
@@ -456,12 +497,12 @@ async def upload_logo(
|
||||
extension = ext_map.get(file.content_type, '.png')
|
||||
|
||||
# Remove old logo files with any extension
|
||||
for old_file in BRANDING_DIR.glob('logo.*'):
|
||||
old_file.unlink()
|
||||
for old_file in await asyncio.to_thread(lambda: list(BRANDING_DIR.glob('logo.*'))):
|
||||
await asyncio.to_thread(old_file.unlink)
|
||||
|
||||
# Save new logo
|
||||
logo_path = BRANDING_DIR / f'logo{extension}'
|
||||
logo_path.write_bytes(content)
|
||||
await asyncio.to_thread(logo_path.write_bytes, content)
|
||||
|
||||
# Mark that we have a custom logo
|
||||
await set_setting_value(db, BRANDING_LOGO_KEY, 'custom')
|
||||
@@ -490,8 +531,8 @@ async def delete_logo(
|
||||
):
|
||||
"""Delete custom logo and revert to letter. Admin only."""
|
||||
# Remove logo files
|
||||
for old_file in BRANDING_DIR.glob('logo.*'):
|
||||
old_file.unlink()
|
||||
for old_file in await asyncio.to_thread(lambda: list(BRANDING_DIR.glob('logo.*'))):
|
||||
await asyncio.to_thread(old_file.unlink)
|
||||
|
||||
# Update setting
|
||||
await set_setting_value(db, BRANDING_LOGO_KEY, 'default')
|
||||
@@ -810,10 +851,16 @@ async def get_email_auth_enabled(
|
||||
|
||||
if email_auth_value is not None:
|
||||
enabled = email_auth_value.lower() == 'true'
|
||||
return EmailAuthEnabledResponse(enabled=enabled)
|
||||
return EmailAuthEnabledResponse(
|
||||
enabled=enabled,
|
||||
verification_enabled=settings.is_cabinet_email_verification_enabled(),
|
||||
)
|
||||
|
||||
# Default: check config setting
|
||||
return EmailAuthEnabledResponse(enabled=settings.is_cabinet_email_auth_enabled())
|
||||
return EmailAuthEnabledResponse(
|
||||
enabled=settings.is_cabinet_email_auth_enabled(),
|
||||
verification_enabled=settings.is_cabinet_email_verification_enabled(),
|
||||
)
|
||||
|
||||
|
||||
@router.patch('/email-auth', response_model=EmailAuthEnabledResponse)
|
||||
@@ -827,7 +874,51 @@ async def update_email_auth_enabled(
|
||||
|
||||
logger.info('Admin set email auth enabled', telegram_id=admin.telegram_id, enabled=payload.enabled)
|
||||
|
||||
return EmailAuthEnabledResponse(enabled=payload.enabled)
|
||||
return EmailAuthEnabledResponse(
|
||||
enabled=payload.enabled,
|
||||
verification_enabled=settings.is_cabinet_email_verification_enabled(),
|
||||
)
|
||||
|
||||
|
||||
# ============ Telegram Widget Config Routes ============
|
||||
|
||||
|
||||
@router.get('/telegram-widget', response_model=TelegramWidgetConfigResponse)
|
||||
async def get_telegram_widget_config(
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""
|
||||
Get Telegram Login Widget configuration.
|
||||
This is a public endpoint - no authentication required.
|
||||
Returns widget display settings and bot username for the login page.
|
||||
"""
|
||||
bot_username = settings.BOT_USERNAME or ''
|
||||
|
||||
size_val = await get_setting_value(db, TELEGRAM_WIDGET_SIZE_KEY)
|
||||
radius_val = await get_setting_value(db, TELEGRAM_WIDGET_RADIUS_KEY)
|
||||
userpic_val = await get_setting_value(db, TELEGRAM_WIDGET_USERPIC_KEY)
|
||||
request_access_val = await get_setting_value(db, TELEGRAM_WIDGET_REQUEST_ACCESS_KEY)
|
||||
|
||||
oidc_enabled_val = await get_setting_value(db, TELEGRAM_OIDC_ENABLED_KEY)
|
||||
oidc_client_id_val = await get_setting_value(db, TELEGRAM_OIDC_CLIENT_ID_KEY)
|
||||
oidc_client_id = oidc_client_id_val or settings.TELEGRAM_OIDC_CLIENT_ID
|
||||
oidc_enabled = (
|
||||
oidc_enabled_val.lower() == 'true' if oidc_enabled_val is not None else settings.TELEGRAM_OIDC_ENABLED
|
||||
) and bool(oidc_client_id)
|
||||
|
||||
return TelegramWidgetConfigResponse(
|
||||
bot_username=bot_username,
|
||||
size=size_val if size_val in ('large', 'medium', 'small') else settings.TELEGRAM_WIDGET_SIZE,
|
||||
radius=max(0, min(int(radius_val), 20))
|
||||
if radius_val and radius_val.isdigit()
|
||||
else settings.TELEGRAM_WIDGET_RADIUS,
|
||||
userpic=userpic_val.lower() == 'true' if userpic_val is not None else settings.TELEGRAM_WIDGET_USERPIC,
|
||||
request_access=request_access_val.lower() == 'true'
|
||||
if request_access_val is not None
|
||||
else settings.TELEGRAM_WIDGET_REQUEST_ACCESS,
|
||||
oidc_enabled=oidc_enabled,
|
||||
oidc_client_id=oidc_client_id if oidc_enabled else '',
|
||||
)
|
||||
|
||||
|
||||
# ============ Analytics Counters Routes ============
|
||||
@@ -845,10 +936,27 @@ async def get_analytics_counters(
|
||||
google_id = await get_setting_value(db, GOOGLE_ADS_ID_KEY) or ''
|
||||
google_label = await get_setting_value(db, GOOGLE_ADS_LABEL_KEY) or ''
|
||||
|
||||
# Yandex Metrika offline conversions snapshot from Settings
|
||||
oc_enabled = bool(getattr(settings, 'YANDEX_OFFLINE_CONV_ENABLED', False))
|
||||
oc_counter = str(getattr(settings, 'YANDEX_OFFLINE_CONV_COUNTER_ID', '') or '')
|
||||
oc_secret = str(getattr(settings, 'YANDEX_OFFLINE_CONV_MEASUREMENT_SECRET', '') or '')
|
||||
oc_secret_masked = ('*' * 8 + oc_secret[-4:]) if len(oc_secret) > 4 else ('***' if oc_secret else '')
|
||||
oc_goals: list[OfflineConvGoal] = []
|
||||
if oc_enabled:
|
||||
oc_goals = [
|
||||
OfflineConvGoal(name='Registration', event_id='registration', dedup='user_id'),
|
||||
OfflineConvGoal(name='Trial', event_id='trial-add', dedup='user_id'),
|
||||
OfflineConvGoal(name='Purchase', event_id='purchase', dedup='order_id'),
|
||||
]
|
||||
|
||||
return AnalyticsCountersResponse(
|
||||
yandex_metrika_id=yandex_id,
|
||||
google_ads_id=google_id,
|
||||
google_ads_label=google_label,
|
||||
offline_conv_enabled=oc_enabled,
|
||||
offline_conv_counter_id=oc_counter,
|
||||
offline_conv_measurement_secret_masked=oc_secret_masked,
|
||||
offline_conv_goals=oc_goals,
|
||||
)
|
||||
|
||||
|
||||
@@ -887,13 +995,56 @@ async def update_analytics_counters(
|
||||
google_id = await get_setting_value(db, GOOGLE_ADS_ID_KEY) or ''
|
||||
google_label = await get_setting_value(db, GOOGLE_ADS_LABEL_KEY) or ''
|
||||
|
||||
oc_enabled = bool(getattr(settings, 'YANDEX_OFFLINE_CONV_ENABLED', False))
|
||||
oc_counter = str(getattr(settings, 'YANDEX_OFFLINE_CONV_COUNTER_ID', '') or '')
|
||||
oc_secret = str(getattr(settings, 'YANDEX_OFFLINE_CONV_MEASUREMENT_SECRET', '') or '')
|
||||
oc_secret_masked = ('*' * 8 + oc_secret[-4:]) if len(oc_secret) > 4 else ('***' if oc_secret else '')
|
||||
oc_goals: list[OfflineConvGoal] = []
|
||||
if oc_enabled:
|
||||
oc_goals = [
|
||||
OfflineConvGoal(name='Registration', event_id='registration', dedup='user_id'),
|
||||
OfflineConvGoal(name='Trial', event_id='trial-add', dedup='user_id'),
|
||||
OfflineConvGoal(name='Purchase', event_id='purchase', dedup='order_id'),
|
||||
]
|
||||
|
||||
return AnalyticsCountersResponse(
|
||||
yandex_metrika_id=yandex_id,
|
||||
google_ads_id=google_id,
|
||||
google_ads_label=google_label,
|
||||
offline_conv_enabled=oc_enabled,
|
||||
offline_conv_counter_id=oc_counter,
|
||||
offline_conv_measurement_secret_masked=oc_secret_masked,
|
||||
offline_conv_goals=oc_goals,
|
||||
)
|
||||
|
||||
|
||||
# ============ Yandex CID Sync ============
|
||||
|
||||
|
||||
class YandexCidRequest(BaseModel):
|
||||
cid: str = Field(max_length=128, pattern=r'^[A-Za-z0-9._:-]{4,128}$')
|
||||
|
||||
|
||||
@router.post('/analytics/yandex-cid', status_code=204)
|
||||
async def store_yandex_cid(
|
||||
body: YandexCidRequest,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Store Yandex Metrika ClientID for the authenticated cabinet user."""
|
||||
try:
|
||||
from app.services import yandex_offline_conv_service as yandex_conv
|
||||
|
||||
await yandex_conv.store_cid(db, user.id, body.cid, source='cabinet')
|
||||
await db.commit()
|
||||
except Exception as exc:
|
||||
logger.warning('Failed to store yandex_cid', user_id=user.id, exc=str(exc))
|
||||
try:
|
||||
await db.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ============ Lite Mode Routes ============
|
||||
|
||||
|
||||
@@ -928,3 +1079,30 @@ async def update_lite_mode_enabled(
|
||||
logger.info('Admin set lite mode enabled', telegram_id=admin.telegram_id, enabled=payload.enabled)
|
||||
|
||||
return LiteModeEnabledResponse(enabled=payload.enabled)
|
||||
|
||||
|
||||
# ============ Gift Feature Routes ============
|
||||
|
||||
|
||||
@router.get('/gift-enabled', response_model=GiftEnabledResponse)
|
||||
async def get_gift_enabled(
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get gift feature enabled setting. Public endpoint."""
|
||||
value = await get_setting_value(db, GIFT_ENABLED_KEY)
|
||||
if value is not None:
|
||||
enabled = value.lower() == 'true'
|
||||
return GiftEnabledResponse(enabled=enabled)
|
||||
return GiftEnabledResponse(enabled=False)
|
||||
|
||||
|
||||
@router.patch('/gift-enabled', response_model=GiftEnabledResponse)
|
||||
async def update_gift_enabled(
|
||||
payload: GiftEnabledUpdate,
|
||||
admin: User = Depends(require_permission('settings:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update gift feature enabled setting. Admin only."""
|
||||
await set_setting_value(db, GIFT_ENABLED_KEY, str(payload.enabled).lower())
|
||||
logger.info('Admin set gift enabled', telegram_id=admin.telegram_id, enabled=payload.enabled)
|
||||
return GiftEnabledResponse(enabled=payload.enabled)
|
||||
|
||||
@@ -9,14 +9,28 @@ from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.contest import (
|
||||
create_attempt,
|
||||
get_active_rounds,
|
||||
get_attempt,
|
||||
increment_winner_count,
|
||||
)
|
||||
from app.database.crud.subscription import get_subscription_by_user_id
|
||||
from app.database.crud.subscription import get_active_subscriptions_by_user_id, get_subscription_by_user_id
|
||||
from app.database.models import SubscriptionStatus, User
|
||||
|
||||
|
||||
async def _resolve_subscription_for_prize(db, user_id: int):
|
||||
"""Resolve best subscription for applying contest prize (days/traffic)."""
|
||||
if settings.is_multi_tariff_enabled():
|
||||
active_subs = await get_active_subscriptions_by_user_id(db, user_id)
|
||||
# Prefer non-daily with most days left
|
||||
non_daily = [s for s in active_subs if not (s.tariff and getattr(s.tariff, 'is_daily', False))]
|
||||
eligible = non_daily or active_subs
|
||||
return max(eligible, key=lambda s: s.days_left) if eligible else None
|
||||
return await get_subscription_by_user_id(db, user_id)
|
||||
|
||||
|
||||
from app.services.contest_rotation_service import (
|
||||
GAME_ANAGRAM,
|
||||
GAME_BLITZ,
|
||||
@@ -86,6 +100,7 @@ def _user_allowed(subscription) -> bool:
|
||||
return subscription.status in {
|
||||
SubscriptionStatus.ACTIVE.value,
|
||||
SubscriptionStatus.TRIAL.value,
|
||||
SubscriptionStatus.LIMITED.value,
|
||||
}
|
||||
|
||||
|
||||
@@ -97,7 +112,7 @@ async def _award_prize(db: AsyncSession, user_id: int, prize_type: str, prize_va
|
||||
except ValueError:
|
||||
return 'Error: invalid prize value'
|
||||
|
||||
subscription = await get_subscription_by_user_id(db, user_id)
|
||||
subscription = await _resolve_subscription_for_prize(db, user_id)
|
||||
if not subscription:
|
||||
return 'Error: subscription not found'
|
||||
|
||||
@@ -121,6 +136,9 @@ async def _award_prize(db: AsyncSession, user_id: int, prize_type: str, prize_va
|
||||
if not user:
|
||||
return 'Error: user not found'
|
||||
|
||||
from app.database.crud.user import lock_user_for_update
|
||||
|
||||
user = await lock_user_for_update(db, user)
|
||||
user.balance_kopeks += int(round(amount * 100))
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
@@ -147,7 +165,7 @@ async def get_contests_count(
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get count of contests available for the user."""
|
||||
subscription = await get_subscription_by_user_id(db, user.id)
|
||||
subscription = await _resolve_subscription_for_prize(db, user.id)
|
||||
|
||||
if not _user_allowed(subscription):
|
||||
return ContestsCountResponse(count=0)
|
||||
@@ -179,7 +197,7 @@ async def get_contests(
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get list of available contests/games."""
|
||||
subscription = await get_subscription_by_user_id(db, user.id)
|
||||
subscription = await _resolve_subscription_for_prize(db, user.id)
|
||||
|
||||
if not _user_allowed(subscription):
|
||||
raise HTTPException(
|
||||
@@ -226,7 +244,7 @@ async def get_contest_game(
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get game data for a specific contest round."""
|
||||
subscription = await get_subscription_by_user_id(db, user.id)
|
||||
subscription = await _resolve_subscription_for_prize(db, user.id)
|
||||
|
||||
if not _user_allowed(subscription):
|
||||
raise HTTPException(
|
||||
@@ -346,7 +364,7 @@ async def submit_contest_answer(
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Submit answer for a contest round."""
|
||||
subscription = await get_subscription_by_user_id(db, user.id)
|
||||
subscription = await _resolve_subscription_for_prize(db, user.id)
|
||||
|
||||
if not _user_allowed(subscription):
|
||||
raise HTTPException(
|
||||
|
||||
@@ -0,0 +1,836 @@
|
||||
"""Gift subscription routes for cabinet."""
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.system_setting import get_setting_value
|
||||
from app.database.crud.tariff import get_tariff_by_id
|
||||
from app.database.crud.transaction import create_transaction, emit_transaction_side_effects
|
||||
from app.database.crud.user import subtract_user_balance
|
||||
from app.database.models import (
|
||||
GuestPurchase,
|
||||
GuestPurchaseStatus,
|
||||
PaymentMethod,
|
||||
Tariff,
|
||||
TransactionType,
|
||||
User,
|
||||
)
|
||||
from app.services.guest_purchase_service import (
|
||||
GuestPurchaseError,
|
||||
create_purchase,
|
||||
fulfill_purchase,
|
||||
)
|
||||
from app.services.payment_method_config_service import get_enabled_methods_for_user
|
||||
from app.utils.cache import RateLimitCache
|
||||
from app.utils.promo_offer import get_user_active_promo_discount_percent
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
from ..schemas.gift import (
|
||||
ActivateGiftRequest,
|
||||
ActivateGiftResponse,
|
||||
GiftConfigPaymentMethod,
|
||||
GiftConfigResponse,
|
||||
GiftConfigSubOption,
|
||||
GiftConfigTariff,
|
||||
GiftConfigTariffPeriod,
|
||||
GiftPurchaseRequest,
|
||||
GiftPurchaseResponse,
|
||||
GiftPurchaseStatusResponse,
|
||||
PendingGiftResponse,
|
||||
ReceivedGiftResponse,
|
||||
SentGiftResponse,
|
||||
)
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/gift', tags=['Cabinet Gift'])
|
||||
|
||||
GIFT_ENABLED_KEY = 'CABINET_GIFT_ENABLED'
|
||||
|
||||
_EMAIL_RE = re.compile(r'^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$')
|
||||
_TELEGRAM_RE = re.compile(r'^@?[a-zA-Z][a-zA-Z0-9_]{4,31}$')
|
||||
|
||||
|
||||
async def _is_gift_enabled(db: AsyncSession) -> bool:
|
||||
"""Check if the gift feature is enabled via system settings."""
|
||||
value = await get_setting_value(db, GIFT_ENABLED_KEY)
|
||||
if value is not None:
|
||||
return value.lower() == 'true'
|
||||
return False
|
||||
|
||||
|
||||
@router.get('/config', response_model=GiftConfigResponse)
|
||||
async def get_gift_config(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get gift subscription configuration: tariffs, payment methods, balance."""
|
||||
enabled = await _is_gift_enabled(db)
|
||||
if not enabled:
|
||||
return GiftConfigResponse(
|
||||
is_enabled=False,
|
||||
balance_kopeks=user.balance_kopeks,
|
||||
)
|
||||
|
||||
# Load active tariffs visible in gift section
|
||||
result = await db.execute(
|
||||
select(Tariff)
|
||||
.where(Tariff.is_active.is_(True), Tariff.show_in_gift.is_(True))
|
||||
.order_by(Tariff.display_order, Tariff.id)
|
||||
)
|
||||
tariffs_db = result.scalars().all()
|
||||
|
||||
# Get user's promo group for discount calculation
|
||||
promo_group = user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else None
|
||||
if promo_group is None:
|
||||
promo_group = getattr(user, 'promo_group', None)
|
||||
promo_group_name = promo_group.name if promo_group else None
|
||||
|
||||
# Get active promo offer discount
|
||||
promo_offer_discount_percent = get_user_active_promo_discount_percent(user)
|
||||
|
||||
tariffs: list[GiftConfigTariff] = []
|
||||
for tariff in tariffs_db:
|
||||
period_days_list = tariff.get_available_periods()
|
||||
periods: list[GiftConfigTariffPeriod] = []
|
||||
for days in period_days_list:
|
||||
base_price = tariff.get_price_for_period(days)
|
||||
if base_price is None:
|
||||
continue
|
||||
|
||||
original_price = base_price
|
||||
price = base_price
|
||||
|
||||
# Apply promo group discount
|
||||
from app.services.pricing_engine import PricingEngine
|
||||
|
||||
promo_group_discount = 0
|
||||
if promo_group:
|
||||
promo_group_discount = promo_group.get_discount_percent('period', days)
|
||||
if promo_group_discount > 0:
|
||||
price = PricingEngine.apply_discount(price, promo_group_discount)
|
||||
|
||||
# Apply active promo offer discount (stacks on top)
|
||||
if promo_offer_discount_percent > 0:
|
||||
price = PricingEngine.apply_discount(price, promo_offer_discount_percent)
|
||||
|
||||
# Ensure minimum price of 1 kopek after all discounts
|
||||
price = max(1, price)
|
||||
|
||||
# Calculate combined discount percent
|
||||
combined_discount = 0
|
||||
if original_price > 0 and original_price != price:
|
||||
combined_discount = int((original_price - price) * 100 / original_price)
|
||||
|
||||
periods.append(
|
||||
GiftConfigTariffPeriod(
|
||||
days=days,
|
||||
price_kopeks=price,
|
||||
price_label=settings.format_price(price),
|
||||
original_price_kopeks=original_price if combined_discount > 0 else None,
|
||||
discount_percent=combined_discount if combined_discount > 0 else None,
|
||||
)
|
||||
)
|
||||
if not periods:
|
||||
continue
|
||||
tariffs.append(
|
||||
GiftConfigTariff(
|
||||
id=tariff.id,
|
||||
name=tariff.name,
|
||||
description=tariff.description,
|
||||
traffic_limit_gb=tariff.traffic_limit_gb,
|
||||
device_limit=tariff.device_limit,
|
||||
periods=periods,
|
||||
)
|
||||
)
|
||||
|
||||
# Load payment methods available for this user
|
||||
enabled_methods = await get_enabled_methods_for_user(db, user=user)
|
||||
payment_methods: list[GiftConfigPaymentMethod] = []
|
||||
for method_data in enabled_methods:
|
||||
sub_options = None
|
||||
raw_options = method_data.get('options')
|
||||
if raw_options:
|
||||
sub_options = [GiftConfigSubOption(id=opt['id'], name=opt.get('name', opt['id'])) for opt in raw_options]
|
||||
payment_methods.append(
|
||||
GiftConfigPaymentMethod(
|
||||
method_id=method_data['id'],
|
||||
display_name=method_data['name'],
|
||||
min_amount_kopeks=method_data.get('min_amount_kopeks'),
|
||||
max_amount_kopeks=method_data.get('max_amount_kopeks'),
|
||||
sub_options=sub_options,
|
||||
)
|
||||
)
|
||||
|
||||
return GiftConfigResponse(
|
||||
is_enabled=True,
|
||||
tariffs=tariffs,
|
||||
payment_methods=payment_methods,
|
||||
balance_kopeks=user.balance_kopeks,
|
||||
currency_symbol=getattr(settings, 'CURRENCY_SYMBOL', '\u20bd'),
|
||||
promo_group_name=promo_group_name,
|
||||
active_discount_percent=promo_offer_discount_percent if promo_offer_discount_percent > 0 else None,
|
||||
active_discount_expires_at=(
|
||||
getattr(user, 'promo_offer_discount_expires_at', None) if promo_offer_discount_percent > 0 else None
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.post('/purchase', response_model=GiftPurchaseResponse)
|
||||
async def create_gift_purchase(
|
||||
body: GiftPurchaseRequest,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Create a gift subscription purchase from the cabinet."""
|
||||
enabled = await _is_gift_enabled(db)
|
||||
if not enabled:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Gift feature is not enabled',
|
||||
)
|
||||
|
||||
# Rate limit: 5 gift purchases per 60 seconds per user
|
||||
is_limited = await RateLimitCache.is_rate_limited(user.id, 'gift_purchase', limit=5, window=60)
|
||||
if is_limited:
|
||||
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail='Too many requests')
|
||||
|
||||
# Check if user has purchase restrictions
|
||||
if getattr(user, 'restriction_subscription', False):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Purchases are restricted for this account',
|
||||
)
|
||||
|
||||
# Recipient is optional — when omitted, buyer gets a code to share manually
|
||||
has_recipient = bool(body.recipient_type and body.recipient_value)
|
||||
|
||||
if has_recipient:
|
||||
# Validate recipient format
|
||||
if body.recipient_type == 'email' and not _EMAIL_RE.match(body.recipient_value):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Invalid email format',
|
||||
)
|
||||
if body.recipient_type == 'telegram' and not _TELEGRAM_RE.match(body.recipient_value):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Invalid Telegram username format',
|
||||
)
|
||||
|
||||
# Prevent self-gift
|
||||
if body.recipient_type == 'telegram':
|
||||
normalized_recipient = body.recipient_value.lstrip('@').lower()
|
||||
if user.username and user.username.lower() == normalized_recipient:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Cannot gift to yourself',
|
||||
)
|
||||
elif body.recipient_type == 'email':
|
||||
if user.email and user.email.lower() == body.recipient_value.lower():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Cannot gift to yourself',
|
||||
)
|
||||
|
||||
# Find tariff and validate period
|
||||
tariff = await get_tariff_by_id(db, body.tariff_id)
|
||||
if tariff is None or not tariff.is_active or not tariff.show_in_gift:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Tariff not found or inactive',
|
||||
)
|
||||
|
||||
# Validate that period has a configured price before locking
|
||||
if tariff.get_price_for_period(body.period_days) is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Price is not configured for this period',
|
||||
)
|
||||
|
||||
# Lock user BEFORE price computation to prevent TOCTOU on promo offer
|
||||
from app.database.crud.user import lock_user_for_pricing
|
||||
|
||||
user = await lock_user_for_pricing(db, user.id)
|
||||
|
||||
from app.services.pricing_engine import pricing_engine
|
||||
|
||||
pricing_result = await pricing_engine.calculate_tariff_purchase_price(
|
||||
tariff,
|
||||
body.period_days,
|
||||
device_limit=tariff.device_limit,
|
||||
user=user,
|
||||
)
|
||||
price_kopeks = max(1, pricing_result.final_total)
|
||||
consume_promo = pricing_result.promo_offer_discount > 0
|
||||
|
||||
# Determine buyer contact info
|
||||
if user.email:
|
||||
buyer_contact_type = 'email'
|
||||
buyer_contact_value = user.email
|
||||
elif user.username:
|
||||
buyer_contact_type = 'telegram'
|
||||
buyer_contact_value = f'@{user.username}'
|
||||
else:
|
||||
buyer_contact_type = 'telegram'
|
||||
buyer_contact_value = f'id:{user.telegram_id or user.id}'
|
||||
|
||||
# Pre-check: try to resolve Telegram username — DB first, then Bot API.
|
||||
# Only relevant when a recipient is explicitly specified.
|
||||
recipient_warning: str | None = None
|
||||
pre_resolved_telegram_id: int | None = None
|
||||
if has_recipient and body.recipient_type == 'telegram':
|
||||
tg_username = body.recipient_value.lstrip('@')
|
||||
normalized_username = tg_username.lower()
|
||||
|
||||
# 1) Check local DB — user may already be registered in the bot
|
||||
db_result = await db.execute(
|
||||
select(User.telegram_id).where(
|
||||
func.lower(User.username) == normalized_username,
|
||||
User.telegram_id.isnot(None),
|
||||
)
|
||||
)
|
||||
db_telegram_id = db_result.scalar_one_or_none()
|
||||
|
||||
if db_telegram_id is not None:
|
||||
pre_resolved_telegram_id = db_telegram_id
|
||||
else:
|
||||
# 2) Fall back to Bot API (works for public usernames the bot has seen)
|
||||
try:
|
||||
from app.bot_factory import create_bot
|
||||
|
||||
async with create_bot() as bot:
|
||||
chat = await asyncio.wait_for(bot.get_chat(chat_id=f'@{tg_username}'), timeout=5.0)
|
||||
pre_resolved_telegram_id = chat.id
|
||||
except Exception:
|
||||
recipient_warning = 'telegram_unresolvable'
|
||||
logger.warning(
|
||||
'Telegram username not resolvable for gift',
|
||||
username=tg_username,
|
||||
buyer_id=user.id,
|
||||
)
|
||||
|
||||
# Gateway mode: create payment via external provider
|
||||
if body.payment_mode == 'gateway':
|
||||
if not body.payment_method:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='payment_method is required for gateway mode',
|
||||
)
|
||||
|
||||
purchase_kwargs: dict = (
|
||||
{
|
||||
'gift_recipient_type': body.recipient_type,
|
||||
'gift_recipient_value': body.recipient_value,
|
||||
'gift_message': body.gift_message,
|
||||
}
|
||||
if has_recipient
|
||||
else {
|
||||
'gift_message': body.gift_message,
|
||||
}
|
||||
)
|
||||
|
||||
try:
|
||||
purchase = await create_purchase(
|
||||
db,
|
||||
landing=None,
|
||||
tariff=tariff,
|
||||
period_days=body.period_days,
|
||||
amount_kopeks=price_kopeks,
|
||||
contact_type=buyer_contact_type,
|
||||
contact_value=buyer_contact_value,
|
||||
payment_method=body.payment_method,
|
||||
is_gift=True,
|
||||
source='cabinet',
|
||||
buyer_user_id=user.id,
|
||||
commit=False,
|
||||
**purchase_kwargs,
|
||||
)
|
||||
except GuestPurchaseError as exc:
|
||||
raise HTTPException(status_code=exc.status_code, detail=exc.message) from exc
|
||||
|
||||
# Persist warning so it survives the gateway redirect
|
||||
if recipient_warning:
|
||||
purchase.recipient_warning = recipient_warning
|
||||
|
||||
# Build return URL for after payment
|
||||
cabinet_base = (settings.CABINET_URL or '').rstrip('/')
|
||||
return_url = f'{cabinet_base}/gift/result?token={purchase.token[:12]}'
|
||||
|
||||
from app.services.payment_service import PaymentService
|
||||
|
||||
# Stars payments need a Bot instance to create invoice links
|
||||
bot = None
|
||||
if body.payment_method == 'telegram_stars':
|
||||
from app.bot_factory import create_bot
|
||||
|
||||
bot = create_bot()
|
||||
|
||||
try:
|
||||
payment_service = PaymentService(bot=bot)
|
||||
payment_result = await payment_service.create_guest_payment(
|
||||
db=db,
|
||||
amount_kopeks=price_kopeks,
|
||||
payment_method=body.payment_method,
|
||||
description=f'Gift: {tariff.name} ({body.period_days}d)',
|
||||
purchase_token=purchase.token,
|
||||
return_url=return_url,
|
||||
)
|
||||
finally:
|
||||
if bot:
|
||||
await bot.session.close()
|
||||
|
||||
if payment_result is None:
|
||||
await db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail='Payment provider is unavailable, please try again later',
|
||||
)
|
||||
|
||||
payment_url = payment_result.get('payment_url')
|
||||
if not payment_url:
|
||||
await db.rollback()
|
||||
logger.error(
|
||||
'Gift payment created but no payment_url returned',
|
||||
purchase_token=purchase.token[:5],
|
||||
provider=payment_result.get('provider'),
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail='Payment provider returned an invalid response',
|
||||
)
|
||||
|
||||
# Consume promo offer discount before committing gateway purchase
|
||||
if consume_promo 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
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(purchase)
|
||||
|
||||
return GiftPurchaseResponse(
|
||||
status='created',
|
||||
purchase_token=purchase.token[:12],
|
||||
payment_url=payment_url,
|
||||
warning=recipient_warning,
|
||||
)
|
||||
|
||||
# Balance mode (skip for 100% discount)
|
||||
if price_kopeks > 0 and user.balance_kopeks < price_kopeks:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Insufficient balance',
|
||||
)
|
||||
|
||||
# Create purchase record
|
||||
balance_purchase_kwargs: dict = (
|
||||
{
|
||||
'gift_recipient_type': body.recipient_type,
|
||||
'gift_recipient_value': body.recipient_value,
|
||||
'gift_message': body.gift_message,
|
||||
}
|
||||
if has_recipient
|
||||
else {
|
||||
'gift_message': body.gift_message,
|
||||
}
|
||||
)
|
||||
|
||||
try:
|
||||
purchase = await create_purchase(
|
||||
db,
|
||||
landing=None,
|
||||
tariff=tariff,
|
||||
period_days=body.period_days,
|
||||
amount_kopeks=price_kopeks,
|
||||
contact_type=buyer_contact_type,
|
||||
contact_value=buyer_contact_value,
|
||||
payment_method='balance',
|
||||
is_gift=True,
|
||||
source='cabinet',
|
||||
buyer_user_id=user.id,
|
||||
commit=False,
|
||||
**balance_purchase_kwargs,
|
||||
)
|
||||
except GuestPurchaseError as exc:
|
||||
raise HTTPException(status_code=exc.status_code, detail=exc.message) from exc
|
||||
|
||||
# Persist warning on purchase record
|
||||
if recipient_warning:
|
||||
purchase.recipient_warning = recipient_warning
|
||||
|
||||
# Subtract balance (consume promo offer if one was applied)
|
||||
balance_ok = await subtract_user_balance(
|
||||
db,
|
||||
user,
|
||||
price_kopeks,
|
||||
description=f'Gift: {tariff.name} ({body.period_days}d)',
|
||||
create_transaction=False,
|
||||
consume_promo_offer=consume_promo,
|
||||
)
|
||||
if not balance_ok:
|
||||
await db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Insufficient balance',
|
||||
)
|
||||
|
||||
# Transaction description: include recipient when specified
|
||||
tx_description = f'Gift: {tariff.name} ({body.period_days}d)'
|
||||
if has_recipient:
|
||||
tx_description += f' -> {body.recipient_value}'
|
||||
|
||||
# Create transaction record
|
||||
transaction = await create_transaction(
|
||||
db,
|
||||
user_id=user.id,
|
||||
type=TransactionType.GIFT_PAYMENT,
|
||||
amount_kopeks=price_kopeks,
|
||||
description=tx_description,
|
||||
payment_method=PaymentMethod.BALANCE,
|
||||
commit=False,
|
||||
)
|
||||
|
||||
# Mark purchase as paid
|
||||
purchase.status = GuestPurchaseStatus.PAID.value
|
||||
purchase.paid_at = datetime.now(UTC)
|
||||
|
||||
await db.commit()
|
||||
|
||||
# Emit deferred side-effects after atomic commit
|
||||
await emit_transaction_side_effects(
|
||||
db,
|
||||
transaction,
|
||||
amount_kopeks=price_kopeks,
|
||||
user_id=user.id,
|
||||
type=TransactionType.GIFT_PAYMENT,
|
||||
payment_method=PaymentMethod.BALANCE,
|
||||
description=tx_description,
|
||||
)
|
||||
|
||||
# Tasks: триггерим прогресс по подаркам
|
||||
try:
|
||||
from app.database.models import TaskType as _TaskType
|
||||
from app.services.tasks_service import record_event as _record_event
|
||||
|
||||
await _record_event(
|
||||
db,
|
||||
user_id=user.id,
|
||||
event_type=_TaskType.GIFT_PURCHASED,
|
||||
payload={'purchase_id': purchase.id},
|
||||
)
|
||||
await _record_event(
|
||||
db,
|
||||
user_id=user.id,
|
||||
event_type=_TaskType.GIFTS_COUNT,
|
||||
payload={'purchase_id': purchase.id},
|
||||
)
|
||||
# record_event делает только flush(); коммитим явно. Для has_recipient=True далее
|
||||
# fulfill_purchase сделает свой commit, для has_recipient=False — это единственный
|
||||
# шанс закоммитить task-прогресс перед return.
|
||||
await db.commit()
|
||||
except Exception as task_err:
|
||||
# Сессия может быть в poisoned state — откатываем, чтобы fulfill_purchase ниже
|
||||
# мог продолжить работу с сессией.
|
||||
try:
|
||||
await db.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
logger.warning('Tasks: ошибка GIFT триггеров', user_id=user.id, error=task_err)
|
||||
|
||||
# Capture token before fulfill_purchase — session state may change after rollback inside fulfill
|
||||
purchase_token = purchase.token
|
||||
|
||||
# Only fulfill immediately when a specific recipient was provided.
|
||||
# Code-only gifts (no recipient) stay in PAID status until someone activates via code.
|
||||
if has_recipient:
|
||||
try:
|
||||
await fulfill_purchase(db, purchase_token, pre_resolved_telegram_id=pre_resolved_telegram_id)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
'Gift purchase fulfillment failed (purchase is paid, will retry)',
|
||||
purchase_id=purchase.id,
|
||||
)
|
||||
|
||||
return GiftPurchaseResponse(
|
||||
status='ok',
|
||||
purchase_token=purchase_token[:12],
|
||||
warning=recipient_warning,
|
||||
)
|
||||
|
||||
|
||||
@router.get('/pending', response_model=list[PendingGiftResponse])
|
||||
async def get_pending_gifts(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get pending gift purchases that the current user can activate."""
|
||||
result = await db.execute(
|
||||
select(GuestPurchase)
|
||||
.options(selectinload(GuestPurchase.tariff))
|
||||
.where(
|
||||
GuestPurchase.user_id == user.id,
|
||||
GuestPurchase.is_gift.is_(True),
|
||||
GuestPurchase.status == GuestPurchaseStatus.PENDING_ACTIVATION.value,
|
||||
)
|
||||
.order_by(GuestPurchase.created_at.desc())
|
||||
.limit(100)
|
||||
)
|
||||
purchases = result.scalars().all()
|
||||
|
||||
pending: list[PendingGiftResponse] = []
|
||||
for p in purchases:
|
||||
# Determine sender display name
|
||||
sender_display = None
|
||||
if p.contact_value:
|
||||
sender_display = p.contact_value
|
||||
|
||||
pending.append(
|
||||
PendingGiftResponse(
|
||||
token=p.token[:12],
|
||||
tariff_name=p.tariff.name if p.tariff else None,
|
||||
period_days=p.period_days,
|
||||
gift_message=p.gift_message,
|
||||
sender_display=sender_display,
|
||||
created_at=p.created_at,
|
||||
)
|
||||
)
|
||||
|
||||
return pending
|
||||
|
||||
|
||||
@router.get('/purchase/{token}', response_model=GiftPurchaseStatusResponse)
|
||||
async def get_gift_purchase_status(
|
||||
token: str,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get the status of a cabinet gift purchase."""
|
||||
if len(token) >= 64:
|
||||
token_filter = GuestPurchase.token == token
|
||||
else:
|
||||
token_filter = GuestPurchase.token.startswith(token)
|
||||
|
||||
result = await db.execute(select(GuestPurchase).options(selectinload(GuestPurchase.tariff)).where(token_filter))
|
||||
purchase = result.scalars().first()
|
||||
if purchase is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Purchase not found',
|
||||
)
|
||||
|
||||
# Uniform 404 prevents token existence oracle
|
||||
if purchase.buyer_user_id != user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Purchase not found',
|
||||
)
|
||||
|
||||
tariff_name = purchase.tariff.name if purchase.tariff else None
|
||||
|
||||
recipient_contact_value = None
|
||||
if purchase.gift_recipient_value:
|
||||
recipient_contact_value = purchase.gift_recipient_value
|
||||
|
||||
is_code_only = purchase.is_gift and not purchase.gift_recipient_type
|
||||
|
||||
return GiftPurchaseStatusResponse(
|
||||
status=purchase.status,
|
||||
is_gift=True,
|
||||
is_code_only=is_code_only,
|
||||
purchase_token=purchase.token[:12] if is_code_only else None,
|
||||
recipient_contact_value=recipient_contact_value,
|
||||
gift_message=purchase.gift_message,
|
||||
tariff_name=tariff_name,
|
||||
period_days=purchase.period_days,
|
||||
warning=purchase.recipient_warning,
|
||||
)
|
||||
|
||||
|
||||
@router.get('/sent', response_model=list[SentGiftResponse])
|
||||
async def get_sent_gifts(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get all gifts the current user has sent."""
|
||||
result = await db.execute(
|
||||
select(GuestPurchase)
|
||||
.options(selectinload(GuestPurchase.tariff), selectinload(GuestPurchase.user))
|
||||
.where(
|
||||
GuestPurchase.buyer_user_id == user.id,
|
||||
GuestPurchase.is_gift.is_(True),
|
||||
)
|
||||
.order_by(GuestPurchase.created_at.desc())
|
||||
.limit(100)
|
||||
)
|
||||
purchases = result.scalars().all()
|
||||
|
||||
sent: list[SentGiftResponse] = []
|
||||
for p in purchases:
|
||||
activated_by_username = None
|
||||
if p.status == GuestPurchaseStatus.DELIVERED.value and p.user and p.user.username:
|
||||
activated_by_username = f'@{p.user.username}'
|
||||
|
||||
sent.append(
|
||||
SentGiftResponse(
|
||||
token=p.token[:12],
|
||||
tariff_name=p.tariff.name if p.tariff else None,
|
||||
period_days=p.period_days,
|
||||
device_limit=p.tariff.device_limit if p.tariff else 1,
|
||||
status=p.status,
|
||||
gift_recipient_value=p.gift_recipient_value,
|
||||
gift_message=p.gift_message,
|
||||
activated_by_username=activated_by_username,
|
||||
created_at=p.created_at,
|
||||
)
|
||||
)
|
||||
|
||||
return sent
|
||||
|
||||
|
||||
@router.get('/received', response_model=list[ReceivedGiftResponse])
|
||||
async def get_received_gifts(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get all gifts the current user has received."""
|
||||
result = await db.execute(
|
||||
select(GuestPurchase)
|
||||
.options(selectinload(GuestPurchase.tariff), selectinload(GuestPurchase.buyer))
|
||||
.where(
|
||||
GuestPurchase.user_id == user.id,
|
||||
GuestPurchase.is_gift.is_(True),
|
||||
)
|
||||
.order_by(GuestPurchase.created_at.desc())
|
||||
.limit(100)
|
||||
)
|
||||
purchases = result.scalars().all()
|
||||
|
||||
received: list[ReceivedGiftResponse] = []
|
||||
for p in purchases:
|
||||
sender_display = None
|
||||
if p.buyer and p.buyer.username:
|
||||
sender_display = f'@{p.buyer.username}'
|
||||
elif p.contact_value:
|
||||
sender_display = p.contact_value
|
||||
|
||||
received.append(
|
||||
ReceivedGiftResponse(
|
||||
token=p.token[:12],
|
||||
tariff_name=p.tariff.name if p.tariff else None,
|
||||
period_days=p.period_days,
|
||||
device_limit=p.tariff.device_limit if p.tariff else 1,
|
||||
status=p.status,
|
||||
sender_display=sender_display,
|
||||
gift_message=p.gift_message,
|
||||
created_at=p.created_at,
|
||||
)
|
||||
)
|
||||
|
||||
return received
|
||||
|
||||
|
||||
@router.post('/activate', response_model=ActivateGiftResponse)
|
||||
async def activate_gift_by_code(
|
||||
body: ActivateGiftRequest,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Activate a gift subscription by its code (token)."""
|
||||
from app.services.guest_purchase_service import activate_purchase as svc_activate
|
||||
|
||||
# Bug 2 fix: rate limit activation attempts to prevent brute-force token enumeration
|
||||
is_limited = await RateLimitCache.is_rate_limited(user.id, 'gift_activate', limit=10, window=60)
|
||||
if is_limited:
|
||||
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail='Too many requests')
|
||||
|
||||
code = body.code.strip()
|
||||
if code.upper().startswith('GIFT-') or code.upper().startswith('GIFT_'):
|
||||
code = code[5:]
|
||||
|
||||
if len(code) < 8:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Code too short')
|
||||
|
||||
# Support both full token and prefix-based lookup (displayed codes are truncated)
|
||||
if len(code) >= 64:
|
||||
# Full token — exact match
|
||||
token_filter = GuestPurchase.token == code
|
||||
else:
|
||||
# Prefix match — for short display codes like GIFT-XXXXXXXXXXXX
|
||||
token_filter = GuestPurchase.token.startswith(code)
|
||||
|
||||
result = await db.execute(
|
||||
select(GuestPurchase)
|
||||
.options(selectinload(GuestPurchase.tariff))
|
||||
.where(token_filter, GuestPurchase.is_gift.is_(True))
|
||||
.with_for_update()
|
||||
)
|
||||
purchase = result.scalars().first()
|
||||
|
||||
if purchase is None or not purchase.is_gift:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Gift not found',
|
||||
)
|
||||
|
||||
# Bug 1 fix: check ownership BEFORE leaking any status/tariff info
|
||||
if purchase.user_id is not None and purchase.user_id != user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Gift not found',
|
||||
)
|
||||
|
||||
# Prevent self-activation: buyer cannot activate their own gift
|
||||
if purchase.buyer_user_id is not None and purchase.buyer_user_id == user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Cannot activate your own gift',
|
||||
)
|
||||
|
||||
if purchase.status == GuestPurchaseStatus.DELIVERED.value:
|
||||
return ActivateGiftResponse(
|
||||
status='activated',
|
||||
tariff_name=purchase.tariff.name if purchase.tariff else None,
|
||||
period_days=purchase.period_days,
|
||||
)
|
||||
|
||||
# Code-only gifts are in PAID status; directed gifts are in PENDING_ACTIVATION
|
||||
activatable_statuses = {
|
||||
GuestPurchaseStatus.PENDING_ACTIVATION.value,
|
||||
GuestPurchaseStatus.PAID.value,
|
||||
}
|
||||
if purchase.status not in activatable_statuses:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='This gift cannot be activated',
|
||||
)
|
||||
|
||||
# For code-only gifts (user_id is None), link the purchase to the activating user
|
||||
if purchase.user_id is None:
|
||||
purchase.user_id = user.id
|
||||
|
||||
# Transition PAID → PENDING_ACTIVATION so activate_purchase() accepts it
|
||||
if purchase.status == GuestPurchaseStatus.PAID.value:
|
||||
purchase.status = GuestPurchaseStatus.PENDING_ACTIVATION.value
|
||||
|
||||
await db.flush()
|
||||
|
||||
try:
|
||||
await svc_activate(db, purchase.token, skip_notification=True)
|
||||
except GuestPurchaseError as exc:
|
||||
raise HTTPException(status_code=exc.status_code, detail=exc.message) from exc
|
||||
|
||||
return ActivateGiftResponse(
|
||||
status='activated',
|
||||
tariff_name=purchase.tariff.name if purchase.tariff else None,
|
||||
period_days=purchase.period_days,
|
||||
)
|
||||
@@ -91,7 +91,7 @@ class SupportConfigResponse(BaseModel):
|
||||
"""Support/tickets configuration for miniapp."""
|
||||
|
||||
tickets_enabled: bool
|
||||
support_type: str # "tickets", "profile", "url"
|
||||
support_type: str # "tickets", "profile", "url", "both"
|
||||
support_url: str | None = None
|
||||
support_username: str | None = None
|
||||
|
||||
@@ -160,7 +160,7 @@ async def get_rules(
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get service rules - uses same function as bot."""
|
||||
requested_lang = language.split('-')[0].lower()
|
||||
requested_lang = language.split('-', maxsplit=1)[0].lower()
|
||||
|
||||
# Use the same function as bot to ensure consistent content
|
||||
content = await get_current_rules_content(db, requested_lang)
|
||||
@@ -299,7 +299,7 @@ async def get_support_config():
|
||||
support_type = 'profile'
|
||||
else: # both
|
||||
tickets_enabled = True
|
||||
support_type = 'tickets'
|
||||
support_type = 'both'
|
||||
|
||||
return SupportConfigResponse(
|
||||
tickets_enabled=tickets_enabled,
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Public info page routes for cabinet."""
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path, Query, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.crud.info_pages import get_all_info_pages, get_info_page_by_slug, get_tab_replacements
|
||||
|
||||
from ..dependencies import get_cabinet_db
|
||||
from ..schemas.info_pages import InfoPageListItem, InfoPageResponse
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/info-pages', tags=['Cabinet Info Pages'])
|
||||
|
||||
|
||||
@router.get('', response_model=list[InfoPageListItem])
|
||||
async def list_active_info_pages(
|
||||
page_type: str | None = Query(None, pattern=r'^(page|faq)$'),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> list[InfoPageListItem]:
|
||||
"""Get all active info pages (public, no auth required)."""
|
||||
try:
|
||||
pages = await get_all_info_pages(db, include_inactive=False, page_type=page_type)
|
||||
return [InfoPageListItem.model_validate(p) for p in pages]
|
||||
except Exception:
|
||||
logger.exception('Failed to list active info pages')
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to load info pages',
|
||||
)
|
||||
|
||||
|
||||
@router.get('/tab-replacements')
|
||||
async def get_info_page_tab_replacements(
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> dict[str, str | None]:
|
||||
"""Get tab replacement mapping (public, no auth required).
|
||||
|
||||
Returns a dict mapping each replaceable tab to the info page slug that replaces it,
|
||||
or null if no replacement is set: ``{faq: slug_or_null, ...}``.
|
||||
"""
|
||||
try:
|
||||
return await get_tab_replacements(db)
|
||||
except Exception:
|
||||
logger.exception('Failed to get tab replacements')
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to load tab replacements',
|
||||
)
|
||||
|
||||
|
||||
@router.get('/{slug}', response_model=InfoPageResponse)
|
||||
async def get_info_page_by_slug_public(
|
||||
slug: str = Path(..., max_length=200, pattern=r'^[a-z0-9\-]+$'),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> InfoPageResponse:
|
||||
"""Get a single info page by slug (public, no auth required)."""
|
||||
page = await get_info_page_by_slug(db, slug)
|
||||
|
||||
if not page or not page.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Info page not found',
|
||||
)
|
||||
|
||||
return InfoPageResponse.model_validate(page)
|
||||
@@ -0,0 +1,731 @@
|
||||
"""Public landing page routes for guest quick-purchase flow."""
|
||||
|
||||
import re
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path, Query, Request, status
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.cabinet.dependencies import get_cabinet_db
|
||||
from app.cabinet.ip_utils import get_client_ip
|
||||
from app.cabinet.utils.locale import DEFAULT_LOCALE, resolve_locale_text
|
||||
from app.config import settings
|
||||
from app.database.crud.landing import get_active_landing_by_slug, get_purchase_by_token
|
||||
from app.database.models import GuestPurchase, GuestPurchaseStatus, LandingPage, Tariff
|
||||
from app.services.guest_purchase_service import (
|
||||
GuestPurchaseError,
|
||||
activate_purchase as activate_guest_purchase,
|
||||
create_purchase,
|
||||
validate_and_calculate,
|
||||
)
|
||||
from app.services.payment_method_config_service import _get_method_defaults
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.utils.cache import RateLimitCache, cache
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/landing', tags=['Landing Pages'])
|
||||
|
||||
|
||||
# ============ Schemas ============
|
||||
|
||||
|
||||
class LandingFeature(BaseModel):
|
||||
icon: str = ''
|
||||
title: str = ''
|
||||
description: str = ''
|
||||
|
||||
|
||||
class LandingTariffPeriod(BaseModel):
|
||||
days: int
|
||||
label: str
|
||||
price_kopeks: int
|
||||
price_label: str
|
||||
original_price_kopeks: int | None = None # set if discount active
|
||||
original_price_label: str | None = None
|
||||
discount_percent: int | None = None # effective discount for this tariff
|
||||
|
||||
|
||||
class LandingTariff(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
description: str | None = None
|
||||
traffic_limit_gb: int
|
||||
device_limit: int
|
||||
tier_level: int
|
||||
periods: list[LandingTariffPeriod]
|
||||
|
||||
|
||||
class LandingPaymentMethodSubOption(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
|
||||
|
||||
class LandingPaymentMethod(BaseModel):
|
||||
method_id: str
|
||||
display_name: str
|
||||
description: str | None = None
|
||||
icon_url: str | None = None
|
||||
sort_order: int = 0
|
||||
min_amount_kopeks: int | None = None
|
||||
max_amount_kopeks: int | None = None
|
||||
currency: str | None = None
|
||||
# Enabled sub-options with display labels (e.g. СБП, Карта).
|
||||
# None or empty means no sub-option selection needed.
|
||||
sub_options: list[LandingPaymentMethodSubOption] | None = None
|
||||
|
||||
|
||||
class LandingDiscountInfo(BaseModel):
|
||||
percent: int # default discount
|
||||
ends_at: str # ISO datetime
|
||||
badge_text: str | None = None # resolved locale text
|
||||
|
||||
|
||||
class LandingConfigResponse(BaseModel):
|
||||
slug: str
|
||||
title: str
|
||||
subtitle: str | None = None
|
||||
features: list[LandingFeature]
|
||||
footer_text: str | None = None
|
||||
tariffs: list[LandingTariff]
|
||||
payment_methods: list[LandingPaymentMethod]
|
||||
gift_enabled: bool
|
||||
custom_css: str | None = None
|
||||
meta_title: str | None = None
|
||||
meta_description: str | None = None
|
||||
discount: LandingDiscountInfo | None = None # null if no active discount
|
||||
background_config: dict | None = None
|
||||
sticky_pay_button: bool = False
|
||||
analytics_view_enabled: bool = False
|
||||
analytics_view_goal: str | None = None
|
||||
analytics_click_enabled: bool = False
|
||||
analytics_click_goal: str | None = None
|
||||
|
||||
|
||||
_EMAIL_RE = re.compile(r'^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$')
|
||||
_TELEGRAM_RE = re.compile(r'^@?[a-zA-Z][a-zA-Z0-9_]{4,31}$')
|
||||
|
||||
|
||||
def _validate_contact(contact_type: str, contact_value: str) -> None:
|
||||
"""Validate contact value matches the declared type format."""
|
||||
if contact_type == 'email' and not _EMAIL_RE.match(contact_value):
|
||||
raise ValueError('Invalid email format')
|
||||
if contact_type == 'telegram' and not _TELEGRAM_RE.match(contact_value):
|
||||
raise ValueError('Invalid Telegram username format')
|
||||
|
||||
|
||||
class PurchaseRequest(BaseModel):
|
||||
tariff_id: int
|
||||
period_days: int
|
||||
contact_type: str = Field(pattern=r'^(email|telegram)$')
|
||||
contact_value: str = Field(min_length=1, max_length=255)
|
||||
payment_method: str = Field(min_length=1, max_length=50, pattern=r'^[a-z0-9_]+$')
|
||||
is_gift: bool = False
|
||||
gift_recipient_type: str | None = Field(default=None, pattern=r'^(email|telegram)$')
|
||||
gift_recipient_value: str | None = Field(default=None, max_length=255)
|
||||
gift_message: str | None = Field(default=None, max_length=1000)
|
||||
yandex_cid: str | None = Field(default=None, max_length=128, pattern=r'^[A-Za-z0-9._:-]{4,128}$')
|
||||
referrer: str | None = Field(default=None, max_length=500)
|
||||
subid: str | None = Field(default=None, max_length=255)
|
||||
|
||||
@model_validator(mode='after')
|
||||
def validate_contacts(self) -> 'PurchaseRequest':
|
||||
_validate_contact(self.contact_type, self.contact_value)
|
||||
if self.is_gift:
|
||||
if not self.gift_recipient_type or not self.gift_recipient_value:
|
||||
raise ValueError('Gift recipient type and value are required for gift purchases')
|
||||
_validate_contact(self.gift_recipient_type, self.gift_recipient_value)
|
||||
return self
|
||||
|
||||
|
||||
class PurchaseResponse(BaseModel):
|
||||
purchase_token: str
|
||||
payment_url: str
|
||||
|
||||
|
||||
class PurchaseStatusResponse(BaseModel):
|
||||
status: str
|
||||
subscription_url: str | None = None
|
||||
subscription_crypto_link: str | None = None
|
||||
is_gift: bool = False
|
||||
contact_value: str | None = None
|
||||
recipient_contact_value: str | None = None
|
||||
period_days: int | None = None
|
||||
tariff_name: str | None = None
|
||||
gift_message: str | None = None
|
||||
contact_type: str | None = None
|
||||
cabinet_email: str | None = None
|
||||
cabinet_password: str | None = None
|
||||
auto_login_token: str | None = None
|
||||
recipient_in_bot: bool | None = None
|
||||
bot_link: str | None = None
|
||||
|
||||
|
||||
# ============ Helpers ============
|
||||
|
||||
|
||||
def _mask_contact(value: str) -> str:
|
||||
"""Mask contact value to avoid leaking PII in API responses."""
|
||||
if '@' in value and not value.startswith('@'):
|
||||
# Email: show first 2 chars + mask + domain
|
||||
local, domain = value.rsplit('@', 1)
|
||||
return f'{local[:2]}***@{domain}'
|
||||
if value.startswith('@'):
|
||||
# Telegram: show first 3 chars + mask
|
||||
return f'{value[:3]}***'
|
||||
return value[:3] + '***'
|
||||
|
||||
|
||||
_SUBSCRIPTION_URL_EXPIRY_HOURS = 24
|
||||
|
||||
|
||||
def _build_purchase_status_response(purchase: GuestPurchase) -> PurchaseStatusResponse:
|
||||
"""Build a PurchaseStatusResponse from a GuestPurchase record."""
|
||||
tariff_name = purchase.tariff.name if purchase.tariff else None
|
||||
|
||||
within_ttl = False
|
||||
subscription_url = None
|
||||
subscription_crypto_link = None
|
||||
if purchase.delivered_at and purchase.subscription_url and not purchase.is_gift:
|
||||
age = datetime.now(UTC) - purchase.delivered_at
|
||||
if age < timedelta(hours=_SUBSCRIPTION_URL_EXPIRY_HOURS):
|
||||
within_ttl = True
|
||||
subscription_url = purchase.subscription_url
|
||||
subscription_crypto_link = purchase.subscription_crypto_link
|
||||
|
||||
masked_contact = _mask_contact(purchase.contact_value) if purchase.contact_value else None
|
||||
|
||||
recipient_contact_value = None
|
||||
gift_message = None
|
||||
if purchase.is_gift:
|
||||
if purchase.gift_recipient_value:
|
||||
recipient_contact_value = _mask_contact(purchase.gift_recipient_value)
|
||||
gift_message = purchase.gift_message
|
||||
|
||||
# Determine effective contact type for the recipient
|
||||
if purchase.is_gift and purchase.gift_recipient_type:
|
||||
effective_contact_type = purchase.gift_recipient_type
|
||||
else:
|
||||
effective_contact_type = purchase.contact_type
|
||||
|
||||
# Cabinet credentials for email self-purchases (not gifts)
|
||||
cabinet_email = None
|
||||
cabinet_password = None
|
||||
auto_login_token = None
|
||||
is_terminal = purchase.status in (GuestPurchaseStatus.DELIVERED.value, GuestPurchaseStatus.PENDING_ACTIVATION.value)
|
||||
is_email_self_purchase = effective_contact_type == 'email' and not purchase.is_gift
|
||||
|
||||
if is_terminal and is_email_self_purchase:
|
||||
cabinet_email = purchase.contact_value
|
||||
# For PENDING_ACTIVATION: cap credential exposure at 72h from paid_at
|
||||
pending_within_ttl = (
|
||||
purchase.status == GuestPurchaseStatus.PENDING_ACTIVATION.value
|
||||
and purchase.paid_at
|
||||
and (datetime.now(UTC) - purchase.paid_at) < timedelta(hours=72)
|
||||
)
|
||||
if within_ttl or pending_within_ttl:
|
||||
cabinet_password = purchase.cabinet_password
|
||||
auto_login_token = purchase.auto_login_token
|
||||
|
||||
# For telegram gifts: indicate whether recipient is known to the bot
|
||||
recipient_in_bot: bool | None = None
|
||||
bot_link: str | None = None
|
||||
if purchase.is_gift and effective_contact_type == 'telegram':
|
||||
recipient_in_bot = purchase.user is not None and purchase.user.telegram_id is not None
|
||||
if not recipient_in_bot:
|
||||
bot_username = settings.get_bot_username()
|
||||
if bot_username:
|
||||
bot_link = f'https://t.me/{bot_username}'
|
||||
|
||||
return PurchaseStatusResponse(
|
||||
status=purchase.status,
|
||||
subscription_url=subscription_url,
|
||||
subscription_crypto_link=subscription_crypto_link,
|
||||
is_gift=purchase.is_gift,
|
||||
contact_value=masked_contact,
|
||||
recipient_contact_value=recipient_contact_value,
|
||||
period_days=purchase.period_days,
|
||||
tariff_name=tariff_name,
|
||||
gift_message=gift_message,
|
||||
contact_type=effective_contact_type,
|
||||
cabinet_email=cabinet_email,
|
||||
cabinet_password=cabinet_password,
|
||||
auto_login_token=auto_login_token,
|
||||
recipient_in_bot=recipient_in_bot,
|
||||
bot_link=bot_link,
|
||||
)
|
||||
|
||||
|
||||
def _period_label(days: int) -> str:
|
||||
"""Human-readable label for a period in days."""
|
||||
if days == 1:
|
||||
return '1 day'
|
||||
if days <= 6:
|
||||
return f'{days} days'
|
||||
if days == 7:
|
||||
return '1 week'
|
||||
if days == 14:
|
||||
return '2 weeks'
|
||||
if days == 30:
|
||||
return '1 month'
|
||||
if days == 60:
|
||||
return '2 months'
|
||||
if days == 90:
|
||||
return '3 months'
|
||||
if days == 180:
|
||||
return '6 months'
|
||||
if days == 365:
|
||||
return '1 year'
|
||||
if days == 456:
|
||||
return '1 year + 3 mo.'
|
||||
|
||||
months = days // 30
|
||||
remainder = days % 30
|
||||
if months > 0 and remainder == 0:
|
||||
return f'{months} mo.'
|
||||
if months > 0:
|
||||
return f'{months} mo. + {remainder} d.'
|
||||
return f'{days} days'
|
||||
|
||||
|
||||
def _get_active_discount(landing: LandingPage, lang: str) -> LandingDiscountInfo | None:
|
||||
"""Return discount info if currently active, else None."""
|
||||
if not landing.discount_percent or not landing.discount_starts_at or not landing.discount_ends_at:
|
||||
return None
|
||||
now = datetime.now(UTC)
|
||||
if not (landing.discount_starts_at <= now < landing.discount_ends_at):
|
||||
return None
|
||||
badge = resolve_locale_text(landing.discount_badge_text, lang) if landing.discount_badge_text else None
|
||||
return LandingDiscountInfo(
|
||||
percent=landing.discount_percent,
|
||||
ends_at=landing.discount_ends_at.isoformat(),
|
||||
badge_text=badge or None,
|
||||
)
|
||||
|
||||
|
||||
async def _load_landing_tariffs(
|
||||
db: AsyncSession, landing: LandingPage, discount: LandingDiscountInfo | None = None
|
||||
) -> list[LandingTariff]:
|
||||
"""Load tariffs for a landing page, filtered by allowed IDs and periods."""
|
||||
allowed_ids = landing.allowed_tariff_ids or []
|
||||
if not allowed_ids:
|
||||
return []
|
||||
|
||||
result = await db.execute(
|
||||
select(Tariff)
|
||||
.where(Tariff.id.in_(allowed_ids), Tariff.is_active.is_(True))
|
||||
.order_by(Tariff.display_order, Tariff.id)
|
||||
)
|
||||
tariffs = result.scalars().all()
|
||||
|
||||
allowed_periods = landing.allowed_periods or {}
|
||||
landing_tariffs = []
|
||||
|
||||
for tariff in tariffs:
|
||||
# Determine which periods to show
|
||||
tariff_period_override = allowed_periods.get(str(tariff.id))
|
||||
if tariff_period_override is not None:
|
||||
period_days_list = sorted(tariff_period_override)
|
||||
else:
|
||||
period_days_list = tariff.get_available_periods()
|
||||
|
||||
periods = []
|
||||
for days in period_days_list:
|
||||
price = tariff.get_price_for_period(days)
|
||||
if price is None:
|
||||
continue
|
||||
|
||||
original_price_kopeks = None
|
||||
original_price_label = None
|
||||
effective_discount = None
|
||||
|
||||
if discount:
|
||||
# Per-tariff override takes priority (read from landing model, not response DTO)
|
||||
overrides = landing.discount_overrides or {}
|
||||
tariff_override = overrides.get(str(tariff.id))
|
||||
effective_discount = tariff_override if tariff_override is not None else discount.percent
|
||||
original_price_kopeks = price
|
||||
original_price_label = settings.format_price(price)
|
||||
from app.services.pricing_engine import PricingEngine
|
||||
|
||||
price = max(1, PricingEngine.apply_discount(price, effective_discount))
|
||||
|
||||
periods.append(
|
||||
LandingTariffPeriod(
|
||||
days=days,
|
||||
label=_period_label(days),
|
||||
price_kopeks=price,
|
||||
price_label=settings.format_price(price),
|
||||
original_price_kopeks=original_price_kopeks,
|
||||
original_price_label=original_price_label,
|
||||
discount_percent=effective_discount,
|
||||
)
|
||||
)
|
||||
|
||||
if not periods:
|
||||
continue
|
||||
|
||||
landing_tariffs.append(
|
||||
LandingTariff(
|
||||
id=tariff.id,
|
||||
name=tariff.name,
|
||||
description=tariff.description,
|
||||
traffic_limit_gb=tariff.traffic_limit_gb,
|
||||
device_limit=tariff.device_limit,
|
||||
tier_level=tariff.tier_level,
|
||||
periods=periods,
|
||||
)
|
||||
)
|
||||
|
||||
return landing_tariffs
|
||||
|
||||
|
||||
# ============ Routes ============
|
||||
|
||||
# IMPORTANT: /purchase/{token} must come BEFORE /{slug} to avoid shadowing
|
||||
# (FastAPI checks routes in definition order; "purchase" would match {slug})
|
||||
|
||||
|
||||
@router.get('/purchase/{token}', response_model=PurchaseStatusResponse)
|
||||
async def get_purchase_status(
|
||||
token: str,
|
||||
raw_request: Request,
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get the status of a guest purchase by token.
|
||||
|
||||
No authentication required.
|
||||
"""
|
||||
client_ip = get_client_ip(raw_request)
|
||||
if await RateLimitCache.is_ip_rate_limited(client_ip, 'purchase_status', limit=30, window=60, fail_closed=True):
|
||||
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail='Too many requests')
|
||||
|
||||
purchase = await get_purchase_by_token(db, token)
|
||||
if purchase is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Purchase not found',
|
||||
)
|
||||
|
||||
response = _build_purchase_status_response(purchase)
|
||||
|
||||
# Cleanup: null expired credentials from DB
|
||||
needs_cleanup = False
|
||||
if purchase.delivered_at and (purchase.cabinet_password or purchase.auto_login_token):
|
||||
age = datetime.now(UTC) - purchase.delivered_at
|
||||
if age >= timedelta(hours=_SUBSCRIPTION_URL_EXPIRY_HOURS):
|
||||
needs_cleanup = True
|
||||
elif (
|
||||
purchase.status == GuestPurchaseStatus.PENDING_ACTIVATION.value
|
||||
and purchase.paid_at
|
||||
and (purchase.cabinet_password or purchase.auto_login_token)
|
||||
and (datetime.now(UTC) - purchase.paid_at) >= timedelta(hours=72)
|
||||
):
|
||||
needs_cleanup = True
|
||||
|
||||
if needs_cleanup:
|
||||
purchase.cabinet_password = None
|
||||
purchase.auto_login_token = None
|
||||
await db.commit()
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@router.post('/activate/{token}', response_model=PurchaseStatusResponse)
|
||||
async def activate_purchase(
|
||||
token: str,
|
||||
raw_request: Request,
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Activate a pending guest purchase, replacing the user's current subscription.
|
||||
|
||||
No authentication required (token is the secret).
|
||||
"""
|
||||
client_ip = get_client_ip(raw_request)
|
||||
if await RateLimitCache.is_ip_rate_limited(client_ip, 'activate_purchase', limit=5, window=60, fail_closed=True):
|
||||
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail='Too many requests')
|
||||
|
||||
try:
|
||||
purchase = await activate_guest_purchase(db, token)
|
||||
except GuestPurchaseError as exc:
|
||||
raise HTTPException(status_code=exc.status_code, detail=exc.message) from exc
|
||||
|
||||
return _build_purchase_status_response(purchase)
|
||||
|
||||
|
||||
@router.get('/{slug}', response_model=LandingConfigResponse)
|
||||
async def get_landing_config(
|
||||
raw_request: Request,
|
||||
slug: str = Path(max_length=100),
|
||||
lang: str = Query(DEFAULT_LOCALE, max_length=5, description='Locale: ru, en, zh, fa'),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get public landing page configuration with tariffs and payment methods.
|
||||
|
||||
No authentication required. Pass ``?lang=en`` to get localized text.
|
||||
"""
|
||||
client_ip = get_client_ip(raw_request)
|
||||
if await RateLimitCache.is_ip_rate_limited(client_ip, 'landing_config', limit=60, window=60, fail_closed=True):
|
||||
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail='Too many requests')
|
||||
|
||||
landing = await get_active_landing_by_slug(db, slug)
|
||||
if landing is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Landing page not found',
|
||||
)
|
||||
|
||||
discount = _get_active_discount(landing, lang)
|
||||
tariffs = await _load_landing_tariffs(db, landing, discount)
|
||||
|
||||
# Build payment methods from landing config
|
||||
raw_methods = landing.payment_methods or []
|
||||
method_defaults = _get_method_defaults()
|
||||
|
||||
payment_methods: list[LandingPaymentMethod] = []
|
||||
for m in raw_methods:
|
||||
method_id = m.get('method_id', '')
|
||||
raw_sub_options = m.get('sub_options') # dict[str, bool] | None
|
||||
|
||||
# Resolve sub-options: filter enabled ones and attach display names
|
||||
resolved_sub_options: list[LandingPaymentMethodSubOption] | None = None
|
||||
method_def = method_defaults.get(method_id)
|
||||
available = method_def.get('available_sub_options') if method_def else None
|
||||
if available:
|
||||
resolved = []
|
||||
for opt in available:
|
||||
opt_id = opt['id']
|
||||
# If landing has explicit sub_options config, respect it; otherwise all enabled
|
||||
if raw_sub_options is None or raw_sub_options.get(opt_id, True):
|
||||
resolved.append(LandingPaymentMethodSubOption(id=opt_id, name=opt['name']))
|
||||
if resolved:
|
||||
resolved_sub_options = resolved
|
||||
|
||||
payment_methods.append(
|
||||
LandingPaymentMethod(
|
||||
method_id=method_id,
|
||||
display_name=m.get('display_name', ''),
|
||||
description=m.get('description'),
|
||||
icon_url=m.get('icon_url'),
|
||||
sort_order=m.get('sort_order', 0),
|
||||
min_amount_kopeks=m.get('min_amount_kopeks'),
|
||||
max_amount_kopeks=m.get('max_amount_kopeks'),
|
||||
currency=m.get('currency'),
|
||||
sub_options=resolved_sub_options,
|
||||
)
|
||||
)
|
||||
|
||||
# Resolve locale dicts to flat strings for the requested language
|
||||
features = [
|
||||
LandingFeature(
|
||||
icon=f.get('icon', ''),
|
||||
title=resolve_locale_text(f.get('title'), lang),
|
||||
description=resolve_locale_text(f.get('description'), lang),
|
||||
)
|
||||
for f in (landing.features or [])
|
||||
]
|
||||
|
||||
return LandingConfigResponse(
|
||||
slug=landing.slug,
|
||||
title=resolve_locale_text(landing.title, lang),
|
||||
subtitle=resolve_locale_text(landing.subtitle, lang) or None,
|
||||
features=features,
|
||||
footer_text=resolve_locale_text(landing.footer_text, lang) or None,
|
||||
tariffs=tariffs,
|
||||
payment_methods=payment_methods,
|
||||
gift_enabled=landing.gift_enabled,
|
||||
custom_css=landing.custom_css,
|
||||
meta_title=resolve_locale_text(landing.meta_title, lang) or None,
|
||||
meta_description=resolve_locale_text(landing.meta_description, lang) or None,
|
||||
discount=discount,
|
||||
background_config=landing.background_config,
|
||||
sticky_pay_button=landing.sticky_pay_button,
|
||||
analytics_view_enabled=landing.analytics_view_enabled,
|
||||
analytics_view_goal=landing.analytics_view_goal,
|
||||
analytics_click_enabled=landing.analytics_click_enabled,
|
||||
analytics_click_goal=landing.analytics_click_goal,
|
||||
)
|
||||
|
||||
|
||||
@router.post('/{slug}/purchase', response_model=PurchaseResponse)
|
||||
async def create_landing_purchase(
|
||||
slug: str,
|
||||
body: PurchaseRequest,
|
||||
raw_request: Request,
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Create a guest purchase on a landing page.
|
||||
|
||||
No authentication required.
|
||||
"""
|
||||
client_ip = get_client_ip(raw_request)
|
||||
if await RateLimitCache.is_ip_rate_limited(client_ip, 'landing_purchase', limit=30, window=60, fail_closed=True):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail='Too many purchase attempts, please try again later',
|
||||
)
|
||||
|
||||
landing = await get_active_landing_by_slug(db, slug)
|
||||
if landing is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Landing page not found',
|
||||
)
|
||||
|
||||
if body.is_gift and not landing.gift_enabled:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Gift purchases are not enabled for this landing page',
|
||||
)
|
||||
|
||||
# Validate payment method is available on this landing.
|
||||
# The frontend may send a suffixed method ID (e.g. "platega_2", "yookassa_sbp")
|
||||
# to select a specific sub-option. We match against the base method_id and
|
||||
# validate the suffix against known & enabled sub-options.
|
||||
raw_methods = landing.payment_methods or []
|
||||
method_defaults = _get_method_defaults()
|
||||
|
||||
method_config = next((m for m in raw_methods if m.get('method_id') == body.payment_method), None)
|
||||
if method_config is None:
|
||||
# Try matching by prefix: "platega_2" → base "platega"
|
||||
# Sort by length descending so "freekassa_sbp" is checked before "freekassa"
|
||||
sorted_methods = sorted(raw_methods, key=lambda m: len(m.get('method_id', '')), reverse=True)
|
||||
for m in sorted_methods:
|
||||
mid = m.get('method_id', '')
|
||||
if body.payment_method.startswith(mid + '_'):
|
||||
suffix = body.payment_method[len(mid) + 1 :]
|
||||
# Validate suffix is a known sub-option
|
||||
method_def = method_defaults.get(mid)
|
||||
available = (method_def.get('available_sub_options') if method_def else None) or []
|
||||
valid_ids = {opt['id'] for opt in available}
|
||||
if suffix not in valid_ids:
|
||||
break # invalid suffix → reject
|
||||
# Validate suffix is enabled on this landing
|
||||
raw_sub_options = m.get('sub_options') # dict[str, bool] | None
|
||||
if raw_sub_options is not None and not raw_sub_options.get(suffix, True):
|
||||
break # disabled sub-option → reject
|
||||
method_config = m
|
||||
break
|
||||
if method_config is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Payment method is not available on this landing page',
|
||||
)
|
||||
|
||||
# Validate tariff + period + calculate price
|
||||
try:
|
||||
tariff, amount_kopeks = await validate_and_calculate(db, landing, body.tariff_id, body.period_days)
|
||||
except GuestPurchaseError as exc:
|
||||
raise HTTPException(status_code=exc.status_code, detail=exc.message) from exc
|
||||
|
||||
# Gift purchases require the tariff to be visible in the gift section
|
||||
if body.is_gift and not tariff.show_in_gift:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='This tariff is not available for gift purchases',
|
||||
)
|
||||
|
||||
# Validate amount against per-method min/max limits (before creating purchase record)
|
||||
min_amount = method_config.get('min_amount_kopeks')
|
||||
max_amount = method_config.get('max_amount_kopeks')
|
||||
if min_amount is not None and amount_kopeks < min_amount:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Amount is below the minimum ({settings.format_price(min_amount)}) for this payment method',
|
||||
)
|
||||
if max_amount is not None and amount_kopeks > max_amount:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Amount exceeds the maximum ({settings.format_price(max_amount)}) for this payment method',
|
||||
)
|
||||
|
||||
# Create purchase record (no commit yet — wait for payment creation)
|
||||
purchase = await create_purchase(
|
||||
db,
|
||||
landing=landing,
|
||||
tariff=tariff,
|
||||
period_days=body.period_days,
|
||||
amount_kopeks=amount_kopeks,
|
||||
contact_type=body.contact_type,
|
||||
contact_value=body.contact_value,
|
||||
payment_method=body.payment_method,
|
||||
is_gift=body.is_gift,
|
||||
gift_recipient_type=body.gift_recipient_type,
|
||||
gift_recipient_value=body.gift_recipient_value,
|
||||
gift_message=body.gift_message,
|
||||
subid=body.subid,
|
||||
referrer=body.referrer,
|
||||
commit=False,
|
||||
)
|
||||
|
||||
# Fallback to HTTP Referer header if body did not supply one
|
||||
if not purchase.referrer:
|
||||
http_referrer = raw_request.headers.get('referer') or raw_request.headers.get('referrer')
|
||||
if http_referrer and len(http_referrer) <= 500:
|
||||
purchase.referrer = http_referrer
|
||||
|
||||
# Determine return URL: per-method override → default cabinet URL
|
||||
cabinet_base = (settings.CABINET_URL or '').rstrip('/')
|
||||
default_return_url = f'{cabinet_base}/buy/success/{purchase.token}'
|
||||
method_return_url = method_config.get('return_url')
|
||||
if method_return_url:
|
||||
# Allow {token} placeholder in custom return URLs
|
||||
return_url = method_return_url.replace('{token}', purchase.token)
|
||||
else:
|
||||
return_url = default_return_url
|
||||
|
||||
payment_service = PaymentService()
|
||||
payment_result = await payment_service.create_guest_payment(
|
||||
db=db,
|
||||
amount_kopeks=amount_kopeks,
|
||||
payment_method=body.payment_method,
|
||||
description=f'{tariff.name} — {body.period_days}d',
|
||||
purchase_token=purchase.token,
|
||||
return_url=return_url,
|
||||
)
|
||||
|
||||
if payment_result is None:
|
||||
await db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail='Payment provider is unavailable, please try again later',
|
||||
)
|
||||
|
||||
payment_url = payment_result.get('payment_url')
|
||||
if not payment_url:
|
||||
await db.rollback()
|
||||
logger.error(
|
||||
'Payment created but no payment_url returned',
|
||||
purchase_token=purchase.token[:5],
|
||||
provider=payment_result.get('provider'),
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail='Payment provider returned an invalid response',
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(purchase)
|
||||
|
||||
# Persist Yandex CID in cache so fulfill_purchase can link it to the user later
|
||||
if body.yandex_cid and settings.YANDEX_OFFLINE_CONV_ENABLED:
|
||||
try:
|
||||
await cache.set(f'yacid:purchase:{purchase.token}', body.yandex_cid, expire=86400)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Persist subid in cache for S2S postback
|
||||
if body.subid:
|
||||
try:
|
||||
await cache.set(f'subid:purchase:{purchase.token}', body.subid, expire=86400)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return PurchaseResponse(
|
||||
purchase_token=purchase.token,
|
||||
payment_url=payment_url,
|
||||
)
|
||||
+13
-11
@@ -3,13 +3,11 @@
|
||||
import mimetypes
|
||||
|
||||
import structlog
|
||||
from aiogram import Bot
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
from aiogram.types import BufferedInputFile
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, Response, UploadFile, status
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.bot_factory import create_bot
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
|
||||
@@ -98,31 +96,38 @@ async def upload_media(
|
||||
target_chat_id = _resolve_target_chat_id()
|
||||
upload = BufferedInputFile(file_bytes, filename=file.filename or 'upload')
|
||||
|
||||
bot = Bot(
|
||||
token=settings.BOT_TOKEN,
|
||||
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
|
||||
)
|
||||
bot = create_bot()
|
||||
|
||||
try:
|
||||
# Send with disable_notification to avoid pinging admins — this is just staging
|
||||
if media_type_normalized == 'photo':
|
||||
message = await bot.send_photo(
|
||||
chat_id=target_chat_id,
|
||||
photo=upload,
|
||||
disable_notification=True,
|
||||
)
|
||||
media = message.photo[-1]
|
||||
elif media_type_normalized == 'video':
|
||||
message = await bot.send_video(
|
||||
chat_id=target_chat_id,
|
||||
video=upload,
|
||||
disable_notification=True,
|
||||
)
|
||||
media = message.video
|
||||
else:
|
||||
message = await bot.send_document(
|
||||
chat_id=target_chat_id,
|
||||
document=upload,
|
||||
disable_notification=True,
|
||||
)
|
||||
media = message.document
|
||||
|
||||
# Delete the staging message immediately — file_id persists after deletion
|
||||
try:
|
||||
await bot.delete_message(chat_id=target_chat_id, message_id=message.message_id)
|
||||
except Exception:
|
||||
pass # Best-effort cleanup — file_id is already captured
|
||||
|
||||
media_url = _build_media_url(request, media.file_id)
|
||||
|
||||
logger.info(
|
||||
@@ -158,10 +163,7 @@ async def download_media(
|
||||
Download media file by file_id.
|
||||
Used to display images/documents in ticket messages.
|
||||
"""
|
||||
bot = Bot(
|
||||
token=settings.BOT_TOKEN,
|
||||
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
|
||||
)
|
||||
bot = create_bot()
|
||||
|
||||
try:
|
||||
file = await bot.get_file(file_id)
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
"""Public news routes for cabinet - user-facing news/blog section."""
|
||||
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path, Query, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.crud.news import (
|
||||
get_news_article_by_slug,
|
||||
get_news_categories,
|
||||
get_published_news,
|
||||
get_published_news_count,
|
||||
increment_views,
|
||||
)
|
||||
from app.database.models import NewsArticle, User
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
from ..schemas.news import (
|
||||
NewsArticleListItem,
|
||||
NewsArticleResponse,
|
||||
NewsListResponse,
|
||||
)
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
# Slug constraint: alphanumeric, hyphens, underscores, max 500 chars
|
||||
_SLUG_MAX_LENGTH: int = 500
|
||||
_SLUG_PATTERN: str = r'^[a-zA-Z0-9_-]+$'
|
||||
|
||||
# --- View counter deduplication ---
|
||||
# In-memory TTL cache to prevent a single user from inflating view counts.
|
||||
# Key: (user_id, article_id), Value: timestamp of last counted view.
|
||||
# Views from the same user on the same article within _VIEW_DEDUP_SECONDS are ignored.
|
||||
_VIEW_DEDUP_SECONDS: int = 300 # 5 minutes
|
||||
_VIEW_DEDUP_MAX_SIZE: int = 10_000 # max entries before eviction
|
||||
_view_dedup_cache: dict[tuple[int, int], float] = {}
|
||||
|
||||
|
||||
def _should_count_view(user_id: int, article_id: int) -> bool:
|
||||
"""Return True if this view should be counted (not a duplicate within TTL)."""
|
||||
now = time.monotonic()
|
||||
key = (user_id, article_id)
|
||||
last_seen = _view_dedup_cache.get(key)
|
||||
|
||||
if last_seen is not None and (now - last_seen) < _VIEW_DEDUP_SECONDS:
|
||||
return False
|
||||
|
||||
# Evict stale entries if cache grows too large
|
||||
if len(_view_dedup_cache) >= _VIEW_DEDUP_MAX_SIZE:
|
||||
cutoff = now - _VIEW_DEDUP_SECONDS
|
||||
stale_keys = [k for k, v in _view_dedup_cache.items() if v < cutoff]
|
||||
for k in stale_keys:
|
||||
del _view_dedup_cache[k]
|
||||
|
||||
_view_dedup_cache[key] = now
|
||||
return True
|
||||
|
||||
|
||||
router = APIRouter(prefix='/news', tags=['Cabinet News'])
|
||||
|
||||
|
||||
def _article_to_response(article: NewsArticle, *, include_content: bool = True) -> dict[str, Any]:
|
||||
"""Convert NewsArticle ORM instance to response dict.
|
||||
|
||||
``author_name`` is only resolved when ``include_content=True`` (single-article
|
||||
detail view) because the author relationship is not eagerly loaded for list
|
||||
queries -- accessing it there would trigger a lazy-load or raise
|
||||
``MissingGreenlet`` in async context.
|
||||
"""
|
||||
data: dict[str, Any] = {
|
||||
'id': article.id,
|
||||
'title': article.title,
|
||||
'slug': article.slug,
|
||||
'excerpt': article.excerpt,
|
||||
'category': article.category,
|
||||
'category_color': article.category_color,
|
||||
'tag': article.tag,
|
||||
'featured_image_url': article.featured_image_url,
|
||||
'is_published': article.is_published,
|
||||
'is_featured': article.is_featured,
|
||||
'published_at': article.published_at,
|
||||
'read_time_minutes': article.read_time_minutes,
|
||||
'views_count': article.views_count,
|
||||
}
|
||||
|
||||
if include_content:
|
||||
author_name: str | None = None
|
||||
if article.author:
|
||||
author_name = article.author.first_name or article.author.username or f'#{article.author.id}'
|
||||
data['content'] = article.content
|
||||
data['author_name'] = author_name
|
||||
data['created_at'] = article.created_at
|
||||
data['updated_at'] = article.updated_at
|
||||
|
||||
return data
|
||||
|
||||
|
||||
# NOTE: /categories MUST be declared before /{slug} to avoid route conflict
|
||||
@router.get('/categories', response_model=list[str])
|
||||
async def list_categories(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> list[str]:
|
||||
"""Get list of distinct news categories."""
|
||||
try:
|
||||
return await get_news_categories(db)
|
||||
except Exception:
|
||||
logger.exception('Failed to get news categories')
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to load categories',
|
||||
)
|
||||
|
||||
|
||||
@router.get('', response_model=NewsListResponse)
|
||||
async def list_published_news(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
category: str | None = Query(None, max_length=100),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
offset: int = Query(0, ge=0),
|
||||
) -> NewsListResponse:
|
||||
"""Get paginated list of published news articles.
|
||||
|
||||
SQLAlchemy AsyncSession does not support concurrent operations, so
|
||||
queries run sequentially.
|
||||
"""
|
||||
try:
|
||||
articles = await get_published_news(db, category=category, limit=limit, offset=offset)
|
||||
total = await get_published_news_count(db, category=category)
|
||||
categories = await get_news_categories(db)
|
||||
|
||||
items = [NewsArticleListItem(**_article_to_response(a, include_content=False)) for a in articles]
|
||||
|
||||
return NewsListResponse(items=items, total=total, categories=categories)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception('Failed to list published news')
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to load news',
|
||||
)
|
||||
|
||||
|
||||
@router.get('/{slug}', response_model=NewsArticleResponse)
|
||||
async def get_article_by_slug(
|
||||
slug: str = Path(..., max_length=_SLUG_MAX_LENGTH, pattern=_SLUG_PATTERN),
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> NewsArticleResponse:
|
||||
"""Get a single published news article by slug. Increments view count."""
|
||||
article = await get_news_article_by_slug(db, slug)
|
||||
|
||||
if not article or not article.is_published:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Article not found',
|
||||
)
|
||||
|
||||
# Build response dict while session attributes are still loaded.
|
||||
# increment_views() calls db.commit() which expires all ORM attributes;
|
||||
# accessing them afterwards triggers lazy-load → MissingGreenlet in async.
|
||||
response_data = _article_to_response(article, include_content=True)
|
||||
|
||||
# Increment views with per-user deduplication (5-min TTL).
|
||||
if _should_count_view(user.id, article.id):
|
||||
try:
|
||||
new_count = await increment_views(db, article.id)
|
||||
response_data['views_count'] = new_count
|
||||
except Exception:
|
||||
logger.warning('Failed to increment views', article_id=article.id)
|
||||
|
||||
return NewsArticleResponse(**response_data)
|
||||
@@ -28,7 +28,7 @@ class NotificationSettingsResponse(BaseModel):
|
||||
subscription_expiry_days: int = 3
|
||||
traffic_warning_enabled: bool = True
|
||||
traffic_warning_percent: int = 80
|
||||
balance_low_enabled: bool = True
|
||||
balance_low_enabled: bool = False
|
||||
balance_low_threshold: int = 100 # kopeks
|
||||
news_enabled: bool = True
|
||||
promo_offers_enabled: bool = True
|
||||
@@ -60,7 +60,7 @@ def _get_notification_settings(user: User) -> dict[str, Any]:
|
||||
'subscription_expiry_days': settings_data.get('subscription_expiry_days', 3),
|
||||
'traffic_warning_enabled': settings_data.get('traffic_warning_enabled', True),
|
||||
'traffic_warning_percent': settings_data.get('traffic_warning_percent', 80),
|
||||
'balance_low_enabled': settings_data.get('balance_low_enabled', True),
|
||||
'balance_low_enabled': settings_data.get('balance_low_enabled', False),
|
||||
'balance_low_threshold': settings_data.get('balance_low_threshold', 100),
|
||||
'news_enabled': settings_data.get('news_enabled', True),
|
||||
'promo_offers_enabled': settings_data.get('promo_offers_enabled', True),
|
||||
|
||||
@@ -40,6 +40,8 @@ async def _finalize_oauth_login(
|
||||
provider: str,
|
||||
campaign_slug: str | None = None,
|
||||
referral_code: str | None = None,
|
||||
*,
|
||||
is_new_user: bool = False,
|
||||
) -> AuthResponse:
|
||||
"""Update last login, create tokens, store refresh token."""
|
||||
user.cabinet_last_login = datetime.now(UTC)
|
||||
@@ -47,10 +49,10 @@ async def _finalize_oauth_login(
|
||||
auth_response = await _create_auth_response(user, db)
|
||||
await _store_refresh_token(db, user.id, auth_response.refresh_token, device_info=f'oauth:{provider}')
|
||||
|
||||
# Process referral code (before campaign bonus, which may also set referrer)
|
||||
# Process referral code (only for new users — existing users cannot be assigned a referrer)
|
||||
from .auth import _process_referral_code, _user_to_response
|
||||
|
||||
await _process_referral_code(db, user, referral_code)
|
||||
await _process_referral_code(db, user, referral_code, is_new_user=is_new_user)
|
||||
|
||||
auth_response.campaign_bonus = await _process_campaign_bonus(db, user, campaign_slug)
|
||||
if auth_response.campaign_bonus:
|
||||
@@ -82,7 +84,9 @@ class OAuthCallbackRequest(BaseModel):
|
||||
campaign_slug: str | None = Field(
|
||||
None, min_length=1, max_length=64, pattern=r'^[a-zA-Z0-9_-]+$', description='Campaign slug from web link'
|
||||
)
|
||||
referral_code: str | None = Field(None, max_length=32, description='Referral code of inviter')
|
||||
referral_code: str | None = Field(
|
||||
None, max_length=32, pattern=r'^[a-zA-Z0-9_-]+$', description='Referral code of inviter'
|
||||
)
|
||||
|
||||
|
||||
# --- Endpoints ---
|
||||
@@ -230,4 +234,19 @@ async def oauth_callback(
|
||||
referred_by_id=referrer_id,
|
||||
)
|
||||
logger.info('New OAuth user created', provider=provider, user_id=user.id)
|
||||
return await _finalize_oauth_login(db, user, provider, request.campaign_slug, request.referral_code)
|
||||
|
||||
# Commit user before panel sync (sync does its own commit/rollback)
|
||||
await db.commit()
|
||||
|
||||
# Sync existing panel subscriptions by email (if verified)
|
||||
if user_info.email and user_info.email_verified:
|
||||
try:
|
||||
from app.cabinet.routes.auth import _sync_subscription_from_panel_by_email
|
||||
|
||||
await _sync_subscription_from_panel_by_email(db, user)
|
||||
except Exception:
|
||||
logger.warning('Failed to sync panel subscription for new OAuth user', user_id=user.id, exc_info=True)
|
||||
|
||||
return await _finalize_oauth_login(
|
||||
db, user, provider, request.campaign_slug, request.referral_code, is_new_user=True
|
||||
)
|
||||
|
||||
@@ -178,12 +178,11 @@ async def apply_for_partner(
|
||||
|
||||
# Уведомляем админов о новой заявке
|
||||
try:
|
||||
from aiogram import Bot
|
||||
|
||||
from app.bot_factory import create_bot
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
if getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) and settings.BOT_TOKEN:
|
||||
bot = Bot(token=settings.BOT_TOKEN)
|
||||
bot = create_bot()
|
||||
try:
|
||||
notification_service = AdminNotificationService(bot)
|
||||
await notification_service.send_partner_application_notification(
|
||||
|
||||
@@ -144,7 +144,7 @@ async def get_available_polls(
|
||||
selectinload(PollResponse.poll).selectinload(Poll.questions),
|
||||
selectinload(PollResponse.answers),
|
||||
)
|
||||
.order_by(PollResponse.created_at.desc())
|
||||
.order_by(PollResponse.sent_at.desc())
|
||||
)
|
||||
responses = result.scalars().all()
|
||||
|
||||
|
||||
+17
-12
@@ -204,15 +204,11 @@ async def get_loyalty_tiers(
|
||||
total_spent_kopeks = await get_user_total_spent_kopeks(db, user.id)
|
||||
total_spent_rubles = total_spent_kopeks / 100
|
||||
|
||||
# Get user's current promo group
|
||||
await db.refresh(user, ['promo_group', 'user_promo_groups'])
|
||||
current_promo_group = user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else None
|
||||
current_tier_name = current_promo_group.name if current_promo_group else None
|
||||
|
||||
# Get all auto-assign promo groups (sorted by threshold ascending)
|
||||
auto_groups = await get_auto_assign_promo_groups(db)
|
||||
|
||||
tiers: list[LoyaltyTierInfo] = []
|
||||
current_tier_name: str | None = None
|
||||
next_tier_name: str | None = None
|
||||
next_tier_threshold: float | None = None
|
||||
|
||||
@@ -220,7 +216,15 @@ async def get_loyalty_tiers(
|
||||
threshold_kopeks = group.auto_assign_total_spent_kopeks or 0
|
||||
threshold_rubles = threshold_kopeks / 100
|
||||
is_achieved = total_spent_kopeks >= threshold_kopeks
|
||||
is_current = current_promo_group and current_promo_group.id == group.id
|
||||
|
||||
# Track highest achieved tier as "current" (by spending, not by assignment)
|
||||
if is_achieved:
|
||||
current_tier_name = group.name
|
||||
|
||||
# Find next tier (first not achieved)
|
||||
if not is_achieved and next_tier_name is None:
|
||||
next_tier_name = group.name
|
||||
next_tier_threshold = threshold_rubles
|
||||
|
||||
# Get period discounts
|
||||
period_discounts = {}
|
||||
@@ -241,15 +245,16 @@ async def get_loyalty_tiers(
|
||||
traffic_discount_percent=group.traffic_discount_percent or 0,
|
||||
device_discount_percent=group.device_discount_percent or 0,
|
||||
period_discounts=period_discounts,
|
||||
is_current=is_current,
|
||||
is_current=False,
|
||||
is_achieved=is_achieved,
|
||||
)
|
||||
)
|
||||
|
||||
# Find next tier (first not achieved)
|
||||
if not is_achieved and next_tier_name is None:
|
||||
next_tier_name = group.name
|
||||
next_tier_threshold = threshold_rubles
|
||||
# Mark only the highest achieved tier as "current"
|
||||
for tier in reversed(tiers):
|
||||
if tier.is_achieved:
|
||||
tier.is_current = True
|
||||
break
|
||||
|
||||
# Calculate progress to next tier
|
||||
progress_percent = 0.0
|
||||
@@ -304,7 +309,7 @@ async def claim_promo_offer(
|
||||
|
||||
# Handle test access offers
|
||||
if effect_type == 'test_access':
|
||||
await db.refresh(user, ['subscription'])
|
||||
await db.refresh(user, ['subscriptions'])
|
||||
success, newly_added, expires_at, error_code = await promo_offer_service.grant_test_access(
|
||||
db,
|
||||
user,
|
||||
|
||||
@@ -5,6 +5,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.services.promocode_service import PromoCodeService
|
||||
|
||||
@@ -20,6 +21,7 @@ class PromocodeActivateRequest(BaseModel):
|
||||
"""Request to activate a promo code."""
|
||||
|
||||
code: str = Field(..., min_length=1, max_length=50, description='Promo code to activate')
|
||||
subscription_id: int | None = Field(None, description='Subscription ID for multi-tariff promo codes')
|
||||
|
||||
|
||||
class PromocodeActivateResponse(BaseModel):
|
||||
@@ -41,7 +43,7 @@ class PromocodeDeactivateResponse(BaseModel):
|
||||
discount_percent: int = 0
|
||||
|
||||
|
||||
@router.post('/activate', response_model=PromocodeActivateResponse)
|
||||
@router.post('/activate')
|
||||
async def activate_promocode(
|
||||
request: PromocodeActivateRequest,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
@@ -50,12 +52,45 @@ async def activate_promocode(
|
||||
"""Activate a promo code for the current user."""
|
||||
promocode_service = PromoCodeService()
|
||||
|
||||
result = await promocode_service.activate_promocode(db=db, user_id=user.id, code=request.code.strip())
|
||||
result = await promocode_service.activate_promocode(
|
||||
db=db, user_id=user.id, code=request.code.strip(), subscription_id=request.subscription_id
|
||||
)
|
||||
|
||||
if result.get('error') == 'select_subscription':
|
||||
return {
|
||||
'success': False,
|
||||
'error': 'select_subscription',
|
||||
'eligible_subscriptions': result.get('eligible_subscriptions', []),
|
||||
'code': result.get('code', request.code.strip()),
|
||||
}
|
||||
|
||||
if result['success']:
|
||||
balance_before_rubles = result.get('balance_before_kopeks', 0) / 100
|
||||
balance_after_rubles = result.get('balance_after_kopeks', 0) / 100
|
||||
|
||||
# Send admin notification (same as bot handler)
|
||||
if getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) and settings.BOT_TOKEN:
|
||||
try:
|
||||
from aiogram import Bot
|
||||
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
bot = Bot(token=settings.BOT_TOKEN)
|
||||
try:
|
||||
notification_service = AdminNotificationService(bot)
|
||||
await notification_service.send_promocode_activation_notification(
|
||||
db,
|
||||
user,
|
||||
result.get('promocode', {'code': request.code.strip()}),
|
||||
result.get('description', ''),
|
||||
result.get('balance_before_kopeks'),
|
||||
result.get('balance_after_kopeks'),
|
||||
)
|
||||
finally:
|
||||
await bot.session.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return PromocodeActivateResponse(
|
||||
success=True,
|
||||
message='Promo code activated successfully',
|
||||
@@ -68,10 +103,13 @@ async def activate_promocode(
|
||||
error_messages = {
|
||||
'not_found': 'Promo code not found',
|
||||
'expired': 'Promo code has expired',
|
||||
'inactive': 'Promo code is deactivated',
|
||||
'not_yet_valid': 'Promo code is not yet active',
|
||||
'used': 'Promo code has been fully used',
|
||||
'already_used_by_user': 'You have already used this promo code',
|
||||
'active_discount_exists': 'You already have an active discount. Deactivate it first via /deactivate-discount',
|
||||
'no_subscription_for_days': 'This promo code requires an active or expired subscription',
|
||||
'subscription_not_found': 'Subscription not found',
|
||||
'not_first_purchase': 'This promo code is only available for first purchase',
|
||||
'daily_limit': 'Too many promo code activations today',
|
||||
'user_not_found': 'User not found',
|
||||
|
||||
@@ -91,13 +91,14 @@ async def get_referral_info(
|
||||
referral_entitlement = max(0, total_earnings - withdrawn - pending)
|
||||
available_balance = min(user.balance_kopeks, referral_entitlement)
|
||||
|
||||
# Build referral link
|
||||
bot_username = settings.get_bot_username() or 'bot'
|
||||
referral_link = f'https://t.me/{bot_username}?start={user.referral_code}'
|
||||
# Build referral links
|
||||
referral_link = (settings.get_cabinet_referral_link(user.referral_code) or '') if user.referral_code else ''
|
||||
bot_referral_link = settings.get_bot_referral_link(user.referral_code) if user.referral_code else ''
|
||||
|
||||
return ReferralInfoResponse(
|
||||
referral_code=user.referral_code or '',
|
||||
referral_link=referral_link,
|
||||
bot_referral_link=bot_referral_link,
|
||||
total_referrals=total_referrals,
|
||||
active_referrals=active_referrals,
|
||||
total_earnings_kopeks=total_earnings,
|
||||
@@ -118,7 +119,11 @@ async def get_referral_list(
|
||||
):
|
||||
"""Get list of invited users."""
|
||||
# Base query with eager loading of subscription relationship
|
||||
query = select(User).options(selectinload(User.subscription)).where(User.referred_by_id == user.id)
|
||||
query = (
|
||||
select(User)
|
||||
.options(selectinload(User.subscriptions).selectinload(Subscription.tariff))
|
||||
.where(User.referred_by_id == user.id)
|
||||
)
|
||||
|
||||
# Get total count
|
||||
count_query = select(func.count()).select_from(User).where(User.referred_by_id == user.id)
|
||||
@@ -138,7 +143,7 @@ async def get_referral_list(
|
||||
username=r.username,
|
||||
first_name=r.first_name,
|
||||
created_at=r.created_at,
|
||||
has_subscription=r.subscription is not None,
|
||||
has_subscription=bool(getattr(r, 'subscriptions', None)),
|
||||
has_paid=r.has_had_paid_subscription,
|
||||
)
|
||||
for r in referrals
|
||||
@@ -243,5 +248,6 @@ async def get_referral_terms():
|
||||
first_topup_bonus_rubles=settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS / 100,
|
||||
inviter_bonus_kopeks=settings.REFERRAL_INVITER_BONUS_KOPEKS,
|
||||
inviter_bonus_rubles=settings.REFERRAL_INVITER_BONUS_KOPEKS / 100,
|
||||
max_commission_payments=settings.REFERRAL_MAX_COMMISSION_PAYMENTS,
|
||||
partner_section_visible=settings.REFERRAL_PARTNER_SECTION_VISIBLE,
|
||||
)
|
||||
|
||||
+39
-4765
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,32 @@
|
||||
"""Subscription sub-modules for cabinet API.
|
||||
|
||||
Each module contains a subset of endpoints from the original monolithic subscription.py.
|
||||
The main subscription.py includes all sub-routers for backward compatibility.
|
||||
"""
|
||||
|
||||
from .autopay import router as autopay_router
|
||||
from .daily import router as daily_router
|
||||
from .devices import router as devices_router
|
||||
from .multi_tariff import router as multi_tariff_router
|
||||
from .purchase import router as purchase_router
|
||||
from .renewal import router as renewal_router
|
||||
from .revoke import router as revoke_router
|
||||
from .servers import router as servers_router
|
||||
from .status import router as status_router
|
||||
from .tariff_switch import router as tariff_switch_router
|
||||
from .traffic import router as traffic_router
|
||||
|
||||
|
||||
__all__ = [
|
||||
'autopay_router',
|
||||
'daily_router',
|
||||
'devices_router',
|
||||
'multi_tariff_router',
|
||||
'purchase_router',
|
||||
'renewal_router',
|
||||
'revoke_router',
|
||||
'servers_router',
|
||||
'status_router',
|
||||
'tariff_switch_router',
|
||||
'traffic_router',
|
||||
]
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Autopay settings endpoint.
|
||||
|
||||
PATCH /subscription/autopay
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import User
|
||||
|
||||
from ...dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
from ...schemas.subscription import AutopayUpdateRequest
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.patch('/autopay')
|
||||
async def update_autopay(
|
||||
request: AutopayUpdateRequest,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
subscription_id: int | None = Query(None, description='Subscription ID for multi-tariff'),
|
||||
):
|
||||
"""Update autopay settings."""
|
||||
from .helpers import resolve_subscription
|
||||
|
||||
subscription = await resolve_subscription(db, user, subscription_id)
|
||||
|
||||
if not subscription:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='No subscription found',
|
||||
)
|
||||
|
||||
if request.enabled:
|
||||
# Classic subscriptions cannot use autopay when tariff mode is enabled
|
||||
from app.config import settings
|
||||
|
||||
if settings.is_tariffs_mode() and not subscription.tariff_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Autopay is not available for classic subscriptions. Please purchase a tariff.',
|
||||
)
|
||||
|
||||
# Триальные подписки — пробник, автопродление не имеет смысла
|
||||
# NULL-safe: is_trial can be None in legacy rows — treat as trial
|
||||
if subscription.is_trial is not False:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Autopay is not available for trial subscriptions',
|
||||
)
|
||||
|
||||
# Суточные подписки имеют свой механизм продления (DailySubscriptionService),
|
||||
# глобальный autopay для них запрещён
|
||||
await db.refresh(subscription, ['tariff'])
|
||||
if subscription.tariff and getattr(subscription.tariff, 'is_daily', False):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Autopay is not available for daily subscriptions',
|
||||
)
|
||||
|
||||
subscription.autopay_enabled = request.enabled
|
||||
|
||||
if request.days_before is not None:
|
||||
subscription.autopay_days_before = request.days_before
|
||||
|
||||
await db.commit()
|
||||
|
||||
return {
|
||||
'message': 'Autopay settings updated',
|
||||
'autopay_enabled': subscription.autopay_enabled,
|
||||
'autopay_days_before': subscription.autopay_days_before,
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
"""Daily subscription management endpoints.
|
||||
|
||||
POST /subscription/pause
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query as QueryParam, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.tariff import get_tariff_by_id
|
||||
from app.database.models import User
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
|
||||
from ...dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
from .helpers import resolve_subscription
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post('/pause')
|
||||
async def toggle_subscription_pause(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
subscription_id: int | None = QueryParam(None, description='Subscription ID for multi-tariff'),
|
||||
) -> dict[str, Any]:
|
||||
"""Toggle pause/resume for daily subscription."""
|
||||
subscription = await resolve_subscription(db, user, subscription_id)
|
||||
|
||||
if not subscription:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='No subscription found',
|
||||
)
|
||||
|
||||
tariff_id = getattr(subscription, 'tariff_id', None)
|
||||
if not tariff_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Subscription has no tariff',
|
||||
)
|
||||
|
||||
tariff = await get_tariff_by_id(db, tariff_id)
|
||||
if not tariff or not getattr(tariff, 'is_daily', False):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Pause is only available for daily tariffs',
|
||||
)
|
||||
|
||||
# Determine current state
|
||||
from app.database.models import SubscriptionStatus
|
||||
|
||||
is_currently_paused = getattr(subscription, 'is_daily_paused', False)
|
||||
was_disabled = subscription.status in (
|
||||
SubscriptionStatus.DISABLED.value,
|
||||
SubscriptionStatus.EXPIRED.value,
|
||||
SubscriptionStatus.LIMITED.value,
|
||||
)
|
||||
|
||||
# System-DISABLED subs (insufficient balance) should always be treated as needing resume,
|
||||
# even if is_daily_paused is False (it's set by the system, not the user)
|
||||
if was_disabled and not is_currently_paused:
|
||||
new_paused_state = False # Force resume path
|
||||
else:
|
||||
new_paused_state = not is_currently_paused
|
||||
|
||||
raw_daily_price = getattr(tariff, 'daily_price_kopeks', 0)
|
||||
|
||||
# Lock user BEFORE discount computation to prevent TOCTOU on promo group
|
||||
# IMPORTANT: must happen BEFORE modifying subscription — lock_user_for_pricing
|
||||
# reloads subscriptions via selectinload which resets in-memory changes
|
||||
from app.database.crud.user import lock_user_for_pricing
|
||||
|
||||
user = await lock_user_for_pricing(db, user.id)
|
||||
|
||||
# Re-fetch subscription after lock (selectinload may have replaced the ORM object)
|
||||
subscription = await resolve_subscription(db, user, subscription_id)
|
||||
if not subscription:
|
||||
raise HTTPException(status_code=404, detail='Subscription not found after lock')
|
||||
|
||||
subscription.is_daily_paused = new_paused_state
|
||||
|
||||
# Apply group discount to daily price (consistent with DailySubscriptionService and miniapp resume)
|
||||
from app.services.pricing_engine import PricingEngine
|
||||
|
||||
promo_group = PricingEngine.resolve_promo_group(user)
|
||||
daily_group_pct = promo_group.get_discount_percent('period', 1) if promo_group else 0
|
||||
daily_price = (
|
||||
PricingEngine.apply_discount(raw_daily_price, daily_group_pct) if daily_group_pct > 0 else raw_daily_price
|
||||
)
|
||||
|
||||
# If resuming, check balance and charge
|
||||
if not new_paused_state:
|
||||
if daily_price > 0 and user.balance_kopeks < daily_price:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_402_PAYMENT_REQUIRED,
|
||||
detail={
|
||||
'code': 'insufficient_balance',
|
||||
'message': 'Insufficient balance to resume daily subscription',
|
||||
'required': daily_price,
|
||||
'balance': user.balance_kopeks,
|
||||
},
|
||||
)
|
||||
|
||||
# Charge daily fee FIRST, then restore ACTIVE status
|
||||
if was_disabled:
|
||||
if daily_price > 0:
|
||||
from app.database.crud.user import subtract_user_balance
|
||||
|
||||
deducted = await subtract_user_balance(
|
||||
db,
|
||||
user,
|
||||
daily_price,
|
||||
f'Суточная оплата тарифа «{tariff.name}» (возобновление)',
|
||||
mark_as_paid_subscription=True,
|
||||
)
|
||||
if not deducted:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_402_PAYMENT_REQUIRED,
|
||||
detail={
|
||||
'code': 'insufficient_balance',
|
||||
'message': 'Balance deduction failed',
|
||||
'required': daily_price,
|
||||
'balance': user.balance_kopeks,
|
||||
},
|
||||
)
|
||||
|
||||
from app.database.crud.transaction import create_transaction
|
||||
from app.database.models import TransactionType
|
||||
|
||||
try:
|
||||
await create_transaction(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
amount_kopeks=daily_price,
|
||||
description=f'Суточная оплата тарифа «{tariff.name}» (возобновление)',
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning('Failed to create resume transaction', error=exc)
|
||||
|
||||
# Balance deducted successfully — now activate
|
||||
subscription.status = SubscriptionStatus.ACTIVE.value
|
||||
subscription.last_daily_charge_at = datetime.now(UTC)
|
||||
subscription.end_date = datetime.now(UTC) + timedelta(days=1)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
await db.refresh(user)
|
||||
|
||||
# Sync with RemnaWave only when resuming from DISABLED state
|
||||
if not new_paused_state and was_disabled:
|
||||
try:
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.create_remnawave_user(
|
||||
db,
|
||||
subscription,
|
||||
reset_traffic=False,
|
||||
reset_reason=None,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error('Error syncing RemnaWave user on resume', error=e)
|
||||
from app.services.remnawave_retry_queue import remnawave_retry_queue
|
||||
|
||||
remnawave_retry_queue.enqueue(
|
||||
subscription_id=subscription.id,
|
||||
user_id=user.id,
|
||||
action='create',
|
||||
)
|
||||
|
||||
if new_paused_state:
|
||||
message = 'Daily subscription paused'
|
||||
else:
|
||||
message = 'Daily subscription resumed'
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'message': message,
|
||||
'is_paused': new_paused_state,
|
||||
'balance_kopeks': user.balance_kopeks,
|
||||
'balance_label': settings.format_price(user.balance_kopeks),
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,234 @@
|
||||
"""Shared helper functions for subscription modules."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import structlog
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import Subscription, User
|
||||
|
||||
from ...schemas.subscription import (
|
||||
ServerInfo,
|
||||
SubscriptionResponse,
|
||||
)
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def resolve_subscription(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
subscription_id: int | None,
|
||||
) -> Subscription | None:
|
||||
"""Resolve target subscription: by ID in multi-tariff mode, or legacy fallback.
|
||||
|
||||
Args:
|
||||
db: Database session.
|
||||
user: Current user.
|
||||
subscription_id: Optional subscription ID (from query param).
|
||||
|
||||
Returns:
|
||||
Target Subscription or None if not found.
|
||||
|
||||
Raises:
|
||||
HTTPException: If subscription_id provided but not found for this user.
|
||||
"""
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.database.crud.subscription import get_subscription_by_id_for_user
|
||||
|
||||
if subscription_id and settings.is_multi_tariff_enabled():
|
||||
subscription = await get_subscription_by_id_for_user(db, subscription_id, user.id)
|
||||
if not subscription:
|
||||
raise HTTPException(status_code=404, detail='Subscription not found')
|
||||
return subscription
|
||||
|
||||
if settings.is_multi_tariff_enabled() and not subscription_id:
|
||||
from app.database.crud.subscription import get_active_subscriptions_by_user_id
|
||||
|
||||
active_subs = await get_active_subscriptions_by_user_id(db, user.id)
|
||||
if active_subs:
|
||||
non_daily = [s for s in active_subs if not getattr(s, 'is_daily_tariff', False)]
|
||||
pool = non_daily or active_subs
|
||||
return max(pool, key=lambda s: s.days_left)
|
||||
return None
|
||||
|
||||
await db.refresh(user, ['subscriptions'])
|
||||
return user.subscription
|
||||
|
||||
|
||||
def _get_addon_discount_percent(
|
||||
user: User | None,
|
||||
category: str,
|
||||
period_days_hint: int | None = None,
|
||||
) -> int:
|
||||
"""Get addon discount percent for user — delegates to PricingEngine."""
|
||||
from app.services.pricing_engine import PricingEngine
|
||||
|
||||
return PricingEngine.get_addon_discount_percent(user, category, period_days_hint)
|
||||
|
||||
|
||||
def _apply_addon_discount(
|
||||
user: User,
|
||||
category: str,
|
||||
amount: int,
|
||||
period_days: int | None = None,
|
||||
) -> dict[str, int]:
|
||||
"""Apply addon discount to amount.
|
||||
|
||||
Returns dict with keys: discounted, discount, percent
|
||||
"""
|
||||
from app.utils.pricing_utils import apply_percentage_discount
|
||||
|
||||
percent = _get_addon_discount_percent(user, category, period_days)
|
||||
if percent <= 0 or amount <= 0:
|
||||
return {'discounted': amount, 'discount': 0, 'percent': 0}
|
||||
|
||||
discounted_amount, discount_value = apply_percentage_discount(amount, percent)
|
||||
return {
|
||||
'discounted': discounted_amount,
|
||||
'discount': discount_value,
|
||||
'percent': percent,
|
||||
}
|
||||
|
||||
|
||||
def _subscription_to_response(
|
||||
subscription: Subscription,
|
||||
servers: list[ServerInfo] | None = None,
|
||||
tariff_name: str | None = None,
|
||||
traffic_purchases: list[dict[str, Any]] | None = None,
|
||||
user: User | None = None,
|
||||
) -> SubscriptionResponse:
|
||||
"""Convert Subscription model to response."""
|
||||
now = datetime.now(UTC)
|
||||
|
||||
# Use actual_status property for correct status (same as bot uses)
|
||||
actual_status = subscription.actual_status
|
||||
is_expired = actual_status == 'expired'
|
||||
is_active = actual_status in ('active', 'trial')
|
||||
is_limited = actual_status == 'limited'
|
||||
|
||||
# Calculate time remaining
|
||||
days_left = 0
|
||||
hours_left = 0
|
||||
minutes_left = 0
|
||||
time_left_display = ''
|
||||
|
||||
if subscription.end_date and not is_expired:
|
||||
time_delta = subscription.end_date - now
|
||||
total_seconds = max(0, int(time_delta.total_seconds()))
|
||||
|
||||
days_left = total_seconds // 86400 # 86400 seconds in a day
|
||||
remaining_seconds = total_seconds % 86400
|
||||
hours_left = remaining_seconds // 3600
|
||||
minutes_left = (remaining_seconds % 3600) // 60
|
||||
|
||||
# Create human-readable display
|
||||
if days_left > 0:
|
||||
time_left_display = f'{days_left}d {hours_left}h'
|
||||
elif hours_left > 0:
|
||||
time_left_display = f'{hours_left}h {minutes_left}m'
|
||||
elif minutes_left > 0:
|
||||
time_left_display = f'{minutes_left}m'
|
||||
else:
|
||||
time_left_display = '0m'
|
||||
else:
|
||||
time_left_display = '0m'
|
||||
|
||||
traffic_limit_gb = subscription.traffic_limit_gb or 0
|
||||
traffic_used_gb = subscription.traffic_used_gb or 0.0
|
||||
|
||||
if traffic_limit_gb > 0:
|
||||
traffic_used_percent = min(100, (traffic_used_gb / traffic_limit_gb) * 100)
|
||||
else:
|
||||
traffic_used_percent = 0
|
||||
|
||||
# Check if this is a daily tariff
|
||||
is_daily_paused = getattr(subscription, 'is_daily_paused', False) or False
|
||||
tariff_id = getattr(subscription, 'tariff_id', None)
|
||||
|
||||
# Use subscription's is_daily_tariff property if available
|
||||
is_daily = False
|
||||
daily_price_kopeks = None
|
||||
|
||||
if hasattr(subscription, 'is_daily_tariff'):
|
||||
is_daily = subscription.is_daily_tariff
|
||||
elif tariff_id and hasattr(subscription, 'tariff') and subscription.tariff:
|
||||
is_daily = getattr(subscription.tariff, 'is_daily', False)
|
||||
|
||||
# Get daily_price_kopeks, tariff_name, traffic_reset_mode from tariff
|
||||
traffic_reset_mode = None
|
||||
if tariff_id and hasattr(subscription, 'tariff') and subscription.tariff:
|
||||
daily_price_kopeks = getattr(subscription.tariff, 'daily_price_kopeks', None)
|
||||
# Применяем скидку промогруппы + promo-offer для отображения
|
||||
if daily_price_kopeks and daily_price_kopeks > 0 and user:
|
||||
from app.services.pricing_engine import PricingEngine
|
||||
from app.utils.promo_offer import get_user_active_promo_discount_percent
|
||||
|
||||
_promo_group = user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else None
|
||||
_group_pct = _promo_group.get_discount_percent('period', 1) if _promo_group else 0
|
||||
_offer_pct = get_user_active_promo_discount_percent(user)
|
||||
if _group_pct > 0 or _offer_pct > 0:
|
||||
daily_price_kopeks, _, _ = PricingEngine.apply_stacked_discounts(
|
||||
daily_price_kopeks, _group_pct, _offer_pct
|
||||
)
|
||||
if not tariff_name: # Only set if not passed as parameter
|
||||
tariff_name = getattr(subscription.tariff, 'name', None)
|
||||
traffic_reset_mode = (
|
||||
getattr(subscription.tariff, 'traffic_reset_mode', None) or settings.DEFAULT_TRAFFIC_RESET_STRATEGY
|
||||
)
|
||||
|
||||
# Calculate next daily charge time (24 hours after last charge)
|
||||
next_daily_charge_at = None
|
||||
if is_daily and not is_daily_paused:
|
||||
last_charge = getattr(subscription, 'last_daily_charge_at', None)
|
||||
if last_charge:
|
||||
next_charge = last_charge + timedelta(days=1)
|
||||
# Если время списания уже прошло — не показываем (DailySubscriptionService обработает)
|
||||
if next_charge > datetime.now(UTC):
|
||||
next_daily_charge_at = next_charge
|
||||
|
||||
# Проверяем настройку скрытия ссылки (скрывается только текст, кнопки работают)
|
||||
hide_link = settings.should_hide_subscription_link()
|
||||
|
||||
return SubscriptionResponse(
|
||||
id=subscription.id,
|
||||
status=actual_status, # Use actual_status instead of raw status
|
||||
is_trial=subscription.is_trial or actual_status == 'trial',
|
||||
start_date=subscription.start_date,
|
||||
end_date=subscription.end_date,
|
||||
days_left=days_left,
|
||||
hours_left=hours_left,
|
||||
minutes_left=minutes_left,
|
||||
time_left_display=time_left_display,
|
||||
traffic_limit_gb=traffic_limit_gb,
|
||||
traffic_used_gb=round(traffic_used_gb, 2),
|
||||
traffic_used_percent=round(traffic_used_percent, 1),
|
||||
device_limit=subscription.device_limit or 0,
|
||||
connected_squads=subscription.connected_squads or [],
|
||||
servers=servers or [],
|
||||
autopay_enabled=subscription.autopay_enabled or False,
|
||||
autopay_days_before=subscription.autopay_days_before or 3,
|
||||
subscription_url=subscription.subscription_url,
|
||||
hide_subscription_link=hide_link,
|
||||
is_active=is_active,
|
||||
is_expired=is_expired,
|
||||
is_limited=is_limited,
|
||||
traffic_purchases=traffic_purchases or [],
|
||||
is_daily=is_daily,
|
||||
is_daily_paused=is_daily_paused,
|
||||
daily_price_kopeks=daily_price_kopeks,
|
||||
next_daily_charge_at=next_daily_charge_at,
|
||||
tariff_id=tariff_id,
|
||||
tariff_name=tariff_name,
|
||||
traffic_reset_mode=traffic_reset_mode,
|
||||
)
|
||||
@@ -0,0 +1,156 @@
|
||||
"""Multi-tariff subscription endpoints for cabinet API.
|
||||
|
||||
GET /subscriptions — list all user subscriptions (multi-tariff)
|
||||
GET /subscriptions/{id} — get specific subscription details
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.subscription import (
|
||||
decrement_subscription_server_counts,
|
||||
get_all_subscriptions_by_user_id,
|
||||
get_subscription_by_id_for_user,
|
||||
)
|
||||
from app.database.models import SubscriptionStatus, User
|
||||
|
||||
from ...dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/subscriptions', tags=['Cabinet Multi-Tariff'], redirect_slashes=False)
|
||||
|
||||
|
||||
class SubscriptionListItem(BaseModel):
|
||||
id: int
|
||||
status: str
|
||||
tariff_id: int | None = None
|
||||
tariff_name: str | None = None
|
||||
traffic_limit_gb: int = 0
|
||||
traffic_used_gb: float = 0.0
|
||||
device_limit: int = 1
|
||||
end_date: str | None = None
|
||||
subscription_url: str | None = None
|
||||
subscription_crypto_link: str | None = None
|
||||
is_trial: bool = False
|
||||
is_daily: bool = False
|
||||
is_daily_paused: bool = False
|
||||
autopay_enabled: bool = False
|
||||
connected_squads: list[str] | None = None
|
||||
|
||||
|
||||
class SubscriptionsListResponse(BaseModel):
|
||||
subscriptions: list[SubscriptionListItem]
|
||||
multi_tariff_enabled: bool
|
||||
|
||||
|
||||
def _subscription_to_list_item(sub) -> SubscriptionListItem:
|
||||
tariff_name = None
|
||||
if sub.tariff:
|
||||
tariff_name = sub.tariff.name
|
||||
|
||||
return SubscriptionListItem(
|
||||
id=sub.id,
|
||||
status=sub.actual_status,
|
||||
tariff_id=sub.tariff_id,
|
||||
tariff_name=tariff_name,
|
||||
traffic_limit_gb=sub.traffic_limit_gb or 0,
|
||||
traffic_used_gb=sub.traffic_used_gb or 0.0,
|
||||
device_limit=sub.device_limit or 1,
|
||||
end_date=sub.end_date.isoformat() if sub.end_date else None,
|
||||
subscription_url=sub.subscription_url,
|
||||
subscription_crypto_link=sub.subscription_crypto_link,
|
||||
is_trial=sub.is_trial or False,
|
||||
is_daily=bool(sub.tariff and getattr(sub.tariff, 'is_daily', False)),
|
||||
is_daily_paused=bool(getattr(sub, 'is_daily_paused', False)),
|
||||
autopay_enabled=sub.autopay_enabled or False,
|
||||
connected_squads=sub.connected_squads,
|
||||
)
|
||||
|
||||
|
||||
@router.get('', response_model=SubscriptionsListResponse)
|
||||
async def list_subscriptions(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> SubscriptionsListResponse:
|
||||
"""List all user subscriptions. Returns all subscriptions regardless of multi-tariff mode."""
|
||||
subscriptions = await get_all_subscriptions_by_user_id(db, user.id)
|
||||
items = [_subscription_to_list_item(sub) for sub in subscriptions]
|
||||
return SubscriptionsListResponse(
|
||||
subscriptions=items,
|
||||
multi_tariff_enabled=settings.is_multi_tariff_enabled(),
|
||||
)
|
||||
|
||||
|
||||
@router.get('/{subscription_id}', response_model=SubscriptionListItem)
|
||||
async def get_subscription_detail(
|
||||
subscription_id: int,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> SubscriptionListItem:
|
||||
"""Get specific subscription details with ownership check."""
|
||||
subscription = await get_subscription_by_id_for_user(db, subscription_id, user.id)
|
||||
if not subscription:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Subscription not found',
|
||||
)
|
||||
return _subscription_to_list_item(subscription)
|
||||
|
||||
|
||||
@router.delete('/{subscription_id}')
|
||||
async def delete_subscription(
|
||||
subscription_id: int,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> dict:
|
||||
"""Delete an expired/disabled subscription. Active subscriptions cannot be deleted."""
|
||||
subscription = await get_subscription_by_id_for_user(db, subscription_id, user.id)
|
||||
if not subscription:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Subscription not found',
|
||||
)
|
||||
|
||||
# Only expired/disabled subscriptions can be deleted
|
||||
deletable_statuses = {
|
||||
SubscriptionStatus.EXPIRED.value,
|
||||
SubscriptionStatus.DISABLED.value,
|
||||
}
|
||||
if getattr(subscription, 'actual_status', subscription.status) not in deletable_statuses:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Only expired or disabled subscriptions can be deleted',
|
||||
)
|
||||
|
||||
# Delete from RemnaWave panel (stops webhooks / phantom notifications)
|
||||
if subscription.remnawave_uuid:
|
||||
try:
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
|
||||
service = SubscriptionService()
|
||||
await service.delete_remnawave_user(subscription.remnawave_uuid)
|
||||
except Exception as e:
|
||||
logger.warning('Failed to delete RemnaWave user on subscription delete', error=e)
|
||||
|
||||
# Decrement server counts
|
||||
await decrement_subscription_server_counts(db, subscription)
|
||||
|
||||
# Delete the subscription
|
||||
await db.delete(subscription)
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
'Subscription deleted by user',
|
||||
subscription_id=subscription_id,
|
||||
user_id=user.id,
|
||||
tariff_id=subscription.tariff_id,
|
||||
)
|
||||
|
||||
return {'message': 'Subscription deleted'}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,281 @@
|
||||
"""Subscription renewal endpoints.
|
||||
|
||||
GET /subscription/renewal-options
|
||||
POST /subscription/renew
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.tariff import get_tariff_by_id
|
||||
from app.database.models import PaymentMethod, SubscriptionStatus, User
|
||||
from app.services.pricing_engine import pricing_engine
|
||||
from app.services.subscription_renewal_service import (
|
||||
SubscriptionRenewalChargeError,
|
||||
SubscriptionRenewalService,
|
||||
)
|
||||
from app.services.user_cart_service import user_cart_service
|
||||
|
||||
from ...dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
from ...schemas.subscription import (
|
||||
RenewalOptionResponse,
|
||||
RenewalRequest,
|
||||
)
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get('/renewal-options', response_model=list[RenewalOptionResponse])
|
||||
async def get_renewal_options(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
subscription_id: int | None = Query(None, description='Subscription ID for multi-tariff'),
|
||||
):
|
||||
"""Get available subscription renewal options with prices."""
|
||||
from .helpers import resolve_subscription
|
||||
|
||||
subscription = await resolve_subscription(db, user, subscription_id)
|
||||
if not subscription:
|
||||
return []
|
||||
|
||||
# Classic subscriptions cannot be renewed when tariff mode is enabled
|
||||
if settings.is_tariffs_mode() and not subscription.tariff_id:
|
||||
return []
|
||||
|
||||
_non_renewable = {SubscriptionStatus.DISABLED.value, SubscriptionStatus.PENDING.value}
|
||||
_actual_status = getattr(subscription, 'actual_status', subscription.status)
|
||||
if _actual_status in _non_renewable:
|
||||
return []
|
||||
|
||||
# Determine available periods
|
||||
# Скрытый/неактивный тариф (например, триальный после промокода) —
|
||||
# не показываем его периоды, используем стандартные
|
||||
if (
|
||||
subscription.tariff_id
|
||||
and subscription.tariff
|
||||
and subscription.tariff.is_active
|
||||
and subscription.tariff.period_prices
|
||||
):
|
||||
periods = sorted(int(k) for k in subscription.tariff.period_prices.keys())
|
||||
else:
|
||||
periods = settings.get_available_renewal_periods()
|
||||
|
||||
options = []
|
||||
|
||||
for period in periods:
|
||||
pricing = await pricing_engine.calculate_renewal_price(db, subscription, period, user=user)
|
||||
|
||||
if pricing.final_total <= 0 and pricing.original_total <= 0:
|
||||
continue
|
||||
|
||||
original_price = pricing.original_total
|
||||
combined_discount = 0
|
||||
if original_price > 0 and original_price != pricing.final_total:
|
||||
combined_discount = int((original_price - pricing.final_total) * 100 / original_price)
|
||||
|
||||
options.append(
|
||||
RenewalOptionResponse(
|
||||
period_days=period,
|
||||
price_kopeks=pricing.final_total,
|
||||
price_rubles=pricing.final_total / 100,
|
||||
discount_percent=combined_discount,
|
||||
original_price_kopeks=original_price if combined_discount > 0 else None,
|
||||
)
|
||||
)
|
||||
|
||||
return options
|
||||
|
||||
|
||||
@router.post('/renew')
|
||||
async def renew_subscription(
|
||||
request: RenewalRequest,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
subscription_id: int | None = Query(None, description='Subscription ID for multi-tariff'),
|
||||
):
|
||||
"""Renew subscription (pay from balance)."""
|
||||
if getattr(user, 'restriction_subscription', False):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Subscription renewal is restricted for this account',
|
||||
)
|
||||
|
||||
# Support subscription_id from both query param and body (backward compat)
|
||||
from .helpers import resolve_subscription
|
||||
|
||||
_sub_id = subscription_id or request.subscription_id
|
||||
subscription = await resolve_subscription(db, user, _sub_id)
|
||||
if not subscription:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='No subscription found',
|
||||
)
|
||||
|
||||
# Classic subscriptions cannot be renewed when tariff mode is enabled
|
||||
if settings.is_tariffs_mode() and not subscription.tariff_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Classic subscriptions cannot be renewed. Please purchase a tariff.',
|
||||
)
|
||||
|
||||
_non_renewable = {SubscriptionStatus.DISABLED.value, SubscriptionStatus.PENDING.value}
|
||||
_actual_status = getattr(subscription, 'actual_status', subscription.status)
|
||||
if _actual_status in _non_renewable:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Cannot renew subscription with status: {_actual_status}',
|
||||
)
|
||||
|
||||
if (
|
||||
subscription.tariff_id
|
||||
and subscription.tariff
|
||||
and subscription.tariff.is_active
|
||||
and subscription.tariff.period_prices
|
||||
):
|
||||
available_periods = [int(p) for p in subscription.tariff.period_prices.keys()]
|
||||
else:
|
||||
available_periods = settings.get_available_renewal_periods()
|
||||
|
||||
if request.period_days not in available_periods:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Selected renewal period is not available',
|
||||
)
|
||||
|
||||
# Lock user row to prevent TOCTOU on promo-offer state
|
||||
from app.database.crud.user import lock_user_for_pricing
|
||||
|
||||
user = await lock_user_for_pricing(db, user.id)
|
||||
|
||||
# Unified pricing via PricingEngine
|
||||
pricing = await pricing_engine.calculate_renewal_price(
|
||||
db,
|
||||
subscription,
|
||||
request.period_days,
|
||||
user=user,
|
||||
)
|
||||
price_kopeks = pricing.final_total
|
||||
promo_offer_discount_value = pricing.promo_offer_discount
|
||||
promo_offer_discount_percent = pricing.breakdown.get('offer_discount_pct', 0)
|
||||
|
||||
if price_kopeks <= 0 and pricing.original_total <= 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Invalid renewal period',
|
||||
)
|
||||
|
||||
original_price_kopeks = pricing.original_total
|
||||
discount_percent = 0
|
||||
if original_price_kopeks > 0 and original_price_kopeks != price_kopeks:
|
||||
discount_percent = int((original_price_kopeks - price_kopeks) * 100 / original_price_kopeks)
|
||||
|
||||
tariff = subscription.tariff if subscription.tariff_id else None
|
||||
|
||||
# Check balance (skip for 100% discount)
|
||||
if price_kopeks > 0 and user.balance_kopeks < price_kopeks:
|
||||
missing = price_kopeks - user.balance_kopeks
|
||||
|
||||
# Get tariff info for cart
|
||||
tariff_id = subscription.tariff_id
|
||||
tariff_name = None
|
||||
tariff_traffic_limit_gb = None
|
||||
tariff_allowed_squads = None
|
||||
|
||||
if tariff_id:
|
||||
tariff = await get_tariff_by_id(db, tariff_id)
|
||||
if tariff:
|
||||
tariff_name = tariff.name
|
||||
tariff_traffic_limit_gb = tariff.traffic_limit_gb
|
||||
tariff_allowed_squads = tariff.allowed_squads or []
|
||||
|
||||
# Save cart for auto-purchase after balance top-up
|
||||
cart_data: dict[str, Any] = {
|
||||
'cart_mode': 'extend',
|
||||
'subscription_id': subscription.id,
|
||||
'tariff_id': tariff_id,
|
||||
'period_days': request.period_days,
|
||||
'total_price': price_kopeks,
|
||||
'user_id': user.id,
|
||||
'saved_cart': True,
|
||||
'missing_amount': missing,
|
||||
'return_to_cart': True,
|
||||
'description': f'Продление подписки на {request.period_days} дней'
|
||||
+ (f' ({tariff_name})' if tariff_name else ''),
|
||||
'discount_percent': discount_percent,
|
||||
'consume_promo_offer': promo_offer_discount_value > 0,
|
||||
'source': 'cabinet',
|
||||
}
|
||||
|
||||
# Add subscription parameters for auto-purchase
|
||||
if tariff_id:
|
||||
cart_data['traffic_limit_gb'] = tariff_traffic_limit_gb
|
||||
# Сохраняем актуальный device_limit подписки (включая докупленные устройства)
|
||||
cart_data['device_limit'] = subscription.device_limit
|
||||
cart_data['allowed_squads'] = tariff_allowed_squads
|
||||
else:
|
||||
# Classic mode: сохраняем текущие параметры подписки для корректной автопокупки
|
||||
cart_data['device_limit'] = subscription.device_limit
|
||||
cart_data['traffic_limit_gb'] = subscription.traffic_limit_gb
|
||||
|
||||
try:
|
||||
await user_cart_service.save_user_cart(user.id, cart_data)
|
||||
logger.info('Cart saved for auto-renewal (cabinet) user', user_id=user.id)
|
||||
except Exception as e:
|
||||
logger.error('Error saving cart for auto-renewal (cabinet)', error=e)
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_402_PAYMENT_REQUIRED,
|
||||
detail={
|
||||
'code': 'insufficient_funds',
|
||||
'message': f'Недостаточно средств. Не хватает {settings.format_price(missing)}',
|
||||
'missing_amount': missing,
|
||||
'cart_saved': True,
|
||||
'cart_mode': 'extend',
|
||||
},
|
||||
)
|
||||
|
||||
# Centralized renewal: balance deduction, extension, RemnaWave sync, admin notification,
|
||||
# server price recording, and compensating refund on failure.
|
||||
renewal_description = f'Продление подписки на {request.period_days} дней' + (f' ({tariff.name})' if tariff else '')
|
||||
renewal_service = SubscriptionRenewalService()
|
||||
|
||||
try:
|
||||
result = await renewal_service.finalize(
|
||||
db,
|
||||
user,
|
||||
subscription,
|
||||
pricing,
|
||||
description=renewal_description,
|
||||
payment_method=PaymentMethod.BALANCE,
|
||||
)
|
||||
except SubscriptionRenewalChargeError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_402_PAYMENT_REQUIRED,
|
||||
detail={
|
||||
'code': 'insufficient_funds',
|
||||
'message': 'Недостаточно средств (concurrent check)',
|
||||
},
|
||||
)
|
||||
|
||||
response: dict[str, Any] = {
|
||||
'message': 'Subscription renewed successfully',
|
||||
'new_end_date': result.subscription.end_date.isoformat(),
|
||||
'amount_paid_kopeks': price_kopeks,
|
||||
}
|
||||
|
||||
# Add discount info to response
|
||||
if promo_offer_discount_value > 0:
|
||||
response['promo_discount_percent'] = promo_offer_discount_percent
|
||||
response['promo_discount_amount_kopeks'] = promo_offer_discount_value
|
||||
response['original_price_kopeks'] = original_price_kopeks
|
||||
|
||||
return response
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Cabinet API endpoint for subscription reissue.
|
||||
|
||||
POST /subscription/revoke
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
|
||||
from ...dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
from .helpers import resolve_subscription
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post('/revoke')
|
||||
async def revoke_subscription(
|
||||
subscription_id: int | None = Query(None, description='Subscription ID for multi-tariff'),
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> dict:
|
||||
"""Revoke and reissue subscription (generate new connection link)."""
|
||||
if not settings.is_subscription_revoke_enabled():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Subscription reissue is not available',
|
||||
)
|
||||
|
||||
# Reload user from current session
|
||||
from app.database.crud.user import get_user_by_id
|
||||
|
||||
fresh_user = await get_user_by_id(db, user.id)
|
||||
if not fresh_user:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='User not found')
|
||||
|
||||
subscription = await resolve_subscription(db, fresh_user, subscription_id)
|
||||
if not subscription:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='Subscription not found')
|
||||
|
||||
if not subscription.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Only active subscriptions can be reissued',
|
||||
)
|
||||
|
||||
# Check cooldown
|
||||
if subscription.last_revoke_at:
|
||||
elapsed = (datetime.now(UTC) - subscription.last_revoke_at).total_seconds()
|
||||
cooldown = settings.SUBSCRIPTION_REVOKE_COOLDOWN_SECONDS
|
||||
if elapsed < cooldown:
|
||||
remaining = int(cooldown - elapsed)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail=f'Cooldown active. Try again in {remaining} seconds.',
|
||||
headers={'Retry-After': str(remaining)},
|
||||
)
|
||||
|
||||
# Execute revoke
|
||||
sub_service = SubscriptionService()
|
||||
new_url = await sub_service.revoke_subscription(db, subscription)
|
||||
|
||||
if not new_url:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to reissue subscription',
|
||||
)
|
||||
|
||||
# Update cooldown timestamp
|
||||
subscription.last_revoke_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
'Subscription revoked via cabinet API',
|
||||
user_id=user.id,
|
||||
subscription_id=subscription.id,
|
||||
)
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'cooldown_seconds': settings.SUBSCRIPTION_REVOKE_COOLDOWN_SECONDS,
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
"""Server/country management endpoints.
|
||||
|
||||
GET /subscription/countries
|
||||
POST /subscription/countries
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query as QueryParam, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import User
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
|
||||
from ...dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
from .helpers import resolve_subscription
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get('/countries')
|
||||
async def get_available_countries(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
subscription_id: int | None = QueryParam(None, description='Subscription ID for multi-tariff'),
|
||||
) -> dict[str, Any]:
|
||||
"""Get available countries/servers for the user."""
|
||||
from app.database.crud.server_squad import get_available_server_squads
|
||||
from app.utils.pricing_utils import apply_percentage_discount, calculate_prorated_price
|
||||
|
||||
subscription = await resolve_subscription(db, user, subscription_id)
|
||||
|
||||
promo_group_id = user.promo_group_id
|
||||
available_servers = await get_available_server_squads(db, promo_group_id=promo_group_id)
|
||||
|
||||
connected_squads = []
|
||||
days_left = 0
|
||||
if subscription:
|
||||
connected_squads = subscription.connected_squads or []
|
||||
if subscription.end_date:
|
||||
delta = subscription.end_date - datetime.now(UTC)
|
||||
days_left = max(0, delta.days)
|
||||
|
||||
# Get discount from promo group via PricingEngine (respects apply_discounts_to_addons flag)
|
||||
from app.services.pricing_engine import PricingEngine
|
||||
|
||||
servers_discount_percent = PricingEngine.get_addon_discount_percent(user, 'servers', None)
|
||||
|
||||
countries = []
|
||||
for server in available_servers:
|
||||
base_price = server.price_kopeks
|
||||
|
||||
# Apply discount
|
||||
if servers_discount_percent > 0:
|
||||
discounted_price, _ = apply_percentage_discount(base_price, servers_discount_percent)
|
||||
else:
|
||||
discounted_price = base_price
|
||||
|
||||
# Calculate prorated price if subscription exists
|
||||
prorated_price = discounted_price
|
||||
if subscription and subscription.end_date:
|
||||
prorated_price, _ = calculate_prorated_price(
|
||||
discounted_price,
|
||||
subscription.end_date,
|
||||
)
|
||||
|
||||
countries.append(
|
||||
{
|
||||
'uuid': server.squad_uuid,
|
||||
'name': server.display_name,
|
||||
'country_code': server.country_code,
|
||||
'base_price_kopeks': base_price,
|
||||
'price_kopeks': prorated_price, # Prorated price with discount
|
||||
'price_per_month_kopeks': discounted_price, # Monthly price with discount
|
||||
'price_rubles': prorated_price / 100,
|
||||
'is_available': server.is_available and not server.is_full,
|
||||
'is_connected': server.squad_uuid in connected_squads,
|
||||
'has_discount': servers_discount_percent > 0,
|
||||
'discount_percent': servers_discount_percent,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
'countries': countries,
|
||||
'connected_count': len(connected_squads),
|
||||
'has_subscription': subscription is not None,
|
||||
'days_left': days_left,
|
||||
'discount_percent': servers_discount_percent,
|
||||
}
|
||||
|
||||
|
||||
@router.post('/countries')
|
||||
async def update_countries(
|
||||
request: dict[str, Any],
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
subscription_id: int | None = QueryParam(None, description='Subscription ID for multi-tariff'),
|
||||
) -> dict[str, Any]:
|
||||
"""Update subscription countries/servers."""
|
||||
from app.database.crud.server_squad import add_user_to_servers, get_available_server_squads, get_server_ids_by_uuids
|
||||
from app.database.crud.subscription import add_subscription_servers
|
||||
from app.database.crud.transaction import create_transaction
|
||||
from app.database.crud.user import subtract_user_balance
|
||||
from app.database.models import TransactionType
|
||||
from app.utils.pricing_utils import apply_percentage_discount, calculate_prorated_price
|
||||
|
||||
subscription = await resolve_subscription(db, user, subscription_id)
|
||||
|
||||
if not subscription:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='No subscription found',
|
||||
)
|
||||
|
||||
if subscription.is_trial:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Country management is not available for trial subscriptions',
|
||||
)
|
||||
|
||||
selected_countries = request.get('countries', [])
|
||||
if not selected_countries:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='At least one country must be selected',
|
||||
)
|
||||
|
||||
current_countries = subscription.connected_squads or []
|
||||
promo_group_id = user.promo_group_id
|
||||
|
||||
available_servers = await get_available_server_squads(db, promo_group_id=promo_group_id)
|
||||
allowed_country_ids = {server.squad_uuid for server in available_servers}
|
||||
|
||||
# Validate selected countries
|
||||
for country_uuid in selected_countries:
|
||||
if country_uuid not in allowed_country_ids:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Country {country_uuid} is not available',
|
||||
)
|
||||
|
||||
added = [c for c in selected_countries if c not in current_countries]
|
||||
removed = [c for c in current_countries if c not in selected_countries]
|
||||
|
||||
if not added and not removed:
|
||||
return {
|
||||
'message': 'No changes detected',
|
||||
'connected_squads': current_countries,
|
||||
}
|
||||
|
||||
# Lock user row to prevent TOCTOU on promo-offer state
|
||||
from app.database.crud.user import lock_user_for_pricing
|
||||
|
||||
user = await lock_user_for_pricing(db, user.id)
|
||||
|
||||
# Calculate cost for added servers
|
||||
total_cost = 0
|
||||
added_names = []
|
||||
removed_names = []
|
||||
|
||||
from app.services.pricing_engine import PricingEngine
|
||||
|
||||
servers_discount_percent = PricingEngine.get_addon_discount_percent(user, 'servers', None)
|
||||
|
||||
added_server_prices = []
|
||||
|
||||
for server in available_servers:
|
||||
if server.squad_uuid in added:
|
||||
server_price_per_month = server.price_kopeks
|
||||
if servers_discount_percent > 0:
|
||||
discounted_per_month, _ = apply_percentage_discount(
|
||||
server_price_per_month,
|
||||
servers_discount_percent,
|
||||
)
|
||||
else:
|
||||
discounted_per_month = server_price_per_month
|
||||
|
||||
charged_price, charged_days = calculate_prorated_price(
|
||||
discounted_per_month,
|
||||
subscription.end_date,
|
||||
)
|
||||
|
||||
total_cost += charged_price
|
||||
added_names.append(server.display_name)
|
||||
added_server_prices.append(charged_price)
|
||||
|
||||
if server.squad_uuid in removed:
|
||||
removed_names.append(server.display_name)
|
||||
|
||||
# Check balance
|
||||
if total_cost > 0 and user.balance_kopeks < total_cost:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_402_PAYMENT_REQUIRED,
|
||||
detail=f'Insufficient balance. Need {total_cost / 100:.2f} RUB, have {user.balance_kopeks / 100:.2f} RUB',
|
||||
)
|
||||
|
||||
# Deduct balance and update subscription
|
||||
if added and total_cost > 0:
|
||||
success = await subtract_user_balance(db, user, total_cost, f'Adding countries: {", ".join(added_names)}')
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to charge balance',
|
||||
)
|
||||
|
||||
await create_transaction(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
amount_kopeks=total_cost,
|
||||
description=f'Adding countries to subscription: {", ".join(added_names)}',
|
||||
)
|
||||
|
||||
# Add servers to subscription
|
||||
if added:
|
||||
added_server_ids = await get_server_ids_by_uuids(db, added)
|
||||
if added_server_ids:
|
||||
await add_subscription_servers(db, subscription, added_server_ids, added_server_prices)
|
||||
try:
|
||||
await add_user_to_servers(db, added_server_ids)
|
||||
except Exception as e:
|
||||
logger.error('Ошибка обновления счётчика серверов', error=e)
|
||||
|
||||
# Update connected squads
|
||||
subscription.connected_squads = selected_countries
|
||||
subscription.updated_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
|
||||
# Sync with RemnaWave
|
||||
try:
|
||||
from app.config import settings
|
||||
|
||||
subscription_service = SubscriptionService()
|
||||
_has_panel = (
|
||||
getattr(subscription, 'remnawave_uuid', None)
|
||||
if settings.is_multi_tariff_enabled()
|
||||
else getattr(user, 'remnawave_uuid', None)
|
||||
)
|
||||
if _has_panel:
|
||||
await subscription_service.update_remnawave_user(db, subscription, sync_squads=True)
|
||||
else:
|
||||
await subscription_service.create_remnawave_user(db, subscription)
|
||||
except Exception as e:
|
||||
logger.error('Failed to sync countries with RemnaWave', error=e)
|
||||
from app.services.remnawave_retry_queue import remnawave_retry_queue
|
||||
|
||||
remnawave_retry_queue.enqueue(
|
||||
subscription_id=subscription.id,
|
||||
user_id=user.id,
|
||||
action='update' if _has_panel else 'create',
|
||||
)
|
||||
|
||||
await db.refresh(subscription)
|
||||
|
||||
return {
|
||||
'message': 'Countries updated successfully',
|
||||
'added': added_names,
|
||||
'removed': removed_names,
|
||||
'amount_paid_kopeks': total_cost,
|
||||
'connected_squads': subscription.connected_squads,
|
||||
}
|
||||
@@ -0,0 +1,526 @@
|
||||
"""Subscription status endpoints.
|
||||
|
||||
GET /subscription — subscription info
|
||||
GET /subscription/connection-link
|
||||
GET /subscription/happ-downloads
|
||||
GET /subscription/app-config
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import re
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.tariff import get_tariff_by_id
|
||||
from app.database.models import ServerSquad, User
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
from app.services.system_settings_service import bot_configuration_service
|
||||
|
||||
from ...dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
from ...schemas.subscription import (
|
||||
ServerInfo,
|
||||
SubscriptionStatusResponse,
|
||||
)
|
||||
from .helpers import _subscription_to_response, resolve_subscription
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get('/info', response_model=SubscriptionStatusResponse)
|
||||
async def get_subscription(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
subscription_id: int | None = Query(None, description='Subscription ID for multi-tariff'),
|
||||
):
|
||||
"""Get current user's subscription details."""
|
||||
# Reload user from current session to get fresh data
|
||||
# (user object is from different session in get_current_cabinet_user)
|
||||
from app.database.crud.user import get_user_by_id
|
||||
|
||||
fresh_user = await get_user_by_id(db, user.id)
|
||||
|
||||
if not fresh_user:
|
||||
return SubscriptionStatusResponse(has_subscription=False, subscription=None)
|
||||
|
||||
subscription = await resolve_subscription(db, fresh_user, subscription_id)
|
||||
|
||||
if not subscription:
|
||||
# Return 200 with has_subscription: false instead of 404
|
||||
return SubscriptionStatusResponse(has_subscription=False, subscription=None)
|
||||
|
||||
# Load tariff for daily subscription check and tariff name
|
||||
tariff_name = None
|
||||
if subscription.tariff_id:
|
||||
tariff = await get_tariff_by_id(db, subscription.tariff_id)
|
||||
if tariff:
|
||||
subscription.tariff = tariff
|
||||
tariff_name = tariff.name
|
||||
|
||||
# Fetch server names for connected squads
|
||||
servers: list[ServerInfo] = []
|
||||
connected_squads = subscription.connected_squads or []
|
||||
if connected_squads:
|
||||
result = await db.execute(select(ServerSquad).where(ServerSquad.squad_uuid.in_(connected_squads)))
|
||||
server_squads = result.scalars().all()
|
||||
servers = [
|
||||
ServerInfo(uuid=sq.squad_uuid, name=sq.display_name, country_code=sq.country_code) for sq in server_squads
|
||||
]
|
||||
|
||||
# Fetch traffic purchases (monthly packages)
|
||||
traffic_purchases_data = []
|
||||
from app.database.models import TrafficPurchase
|
||||
|
||||
now = datetime.now(UTC)
|
||||
purchases_query = (
|
||||
select(TrafficPurchase)
|
||||
.where(TrafficPurchase.subscription_id == subscription.id)
|
||||
.where(TrafficPurchase.expires_at > now)
|
||||
.order_by(TrafficPurchase.expires_at.asc())
|
||||
)
|
||||
purchases_result = await db.execute(purchases_query)
|
||||
purchases = purchases_result.scalars().all()
|
||||
|
||||
for purchase in purchases:
|
||||
time_remaining = purchase.expires_at - now
|
||||
days_remaining = max(0, int(time_remaining.total_seconds() / 86400))
|
||||
total_duration_seconds = (purchase.expires_at - purchase.created_at).total_seconds()
|
||||
elapsed_seconds = (now - purchase.created_at).total_seconds()
|
||||
progress_percent = min(
|
||||
100.0, max(0.0, (elapsed_seconds / total_duration_seconds * 100) if total_duration_seconds > 0 else 0)
|
||||
)
|
||||
|
||||
traffic_purchases_data.append(
|
||||
{
|
||||
'id': purchase.id,
|
||||
'traffic_gb': purchase.traffic_gb,
|
||||
'expires_at': purchase.expires_at,
|
||||
'created_at': purchase.created_at,
|
||||
'days_remaining': days_remaining,
|
||||
'progress_percent': round(progress_percent, 1),
|
||||
}
|
||||
)
|
||||
|
||||
subscription_data = _subscription_to_response(
|
||||
subscription, servers, tariff_name, traffic_purchases_data, user=fresh_user
|
||||
)
|
||||
return SubscriptionStatusResponse(has_subscription=True, subscription=subscription_data)
|
||||
|
||||
|
||||
# ============ Connection Link ============
|
||||
|
||||
|
||||
@router.get('/connection-link')
|
||||
async def get_connection_link(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
subscription_id: int | None = Query(None, description='Subscription ID for multi-tariff'),
|
||||
) -> dict[str, Any]:
|
||||
"""Get subscription connection link and instructions."""
|
||||
from app.utils.subscription_utils import (
|
||||
convert_subscription_link_to_happ_scheme,
|
||||
get_display_subscription_link,
|
||||
get_happ_cryptolink_redirect_link,
|
||||
)
|
||||
|
||||
subscription = await resolve_subscription(db, user, subscription_id)
|
||||
|
||||
if not subscription:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='No subscription found',
|
||||
)
|
||||
|
||||
subscription_url = subscription.subscription_url
|
||||
if not subscription_url:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Subscription link not yet generated',
|
||||
)
|
||||
|
||||
display_link = get_display_subscription_link(subscription)
|
||||
happ_redirect = get_happ_cryptolink_redirect_link(subscription_url) if settings.is_happ_cryptolink_mode() else None
|
||||
happ_scheme_link = (
|
||||
convert_subscription_link_to_happ_scheme(subscription_url) if settings.is_happ_cryptolink_mode() else None
|
||||
)
|
||||
|
||||
connect_mode = settings.CONNECT_BUTTON_MODE
|
||||
hide_subscription_link = settings.should_hide_subscription_link()
|
||||
|
||||
return {
|
||||
'subscription_url': subscription_url if not hide_subscription_link else None,
|
||||
'display_link': display_link if not hide_subscription_link else None,
|
||||
'happ_redirect_link': happ_redirect,
|
||||
'happ_scheme_link': happ_scheme_link,
|
||||
'connect_mode': connect_mode,
|
||||
'hide_link': hide_subscription_link,
|
||||
'instructions': {
|
||||
'steps': [
|
||||
'Copy the subscription link',
|
||||
'Open your VPN application',
|
||||
"Find 'Add subscription' or 'Import' option",
|
||||
'Paste the copied link',
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ============ hApp Downloads ============
|
||||
|
||||
|
||||
@router.get('/happ-downloads')
|
||||
async def get_happ_downloads(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
) -> dict[str, Any]:
|
||||
"""Get hApp download links for different platforms."""
|
||||
platforms = {
|
||||
'ios': {
|
||||
'name': 'iOS (iPhone/iPad)',
|
||||
'icon': '🍎',
|
||||
'link': settings.get_happ_download_link('ios'),
|
||||
},
|
||||
'android': {
|
||||
'name': 'Android',
|
||||
'icon': '🤖',
|
||||
'link': settings.get_happ_download_link('android'),
|
||||
},
|
||||
'macos': {
|
||||
'name': 'macOS',
|
||||
'icon': '🖥️',
|
||||
'link': settings.get_happ_download_link('macos'),
|
||||
},
|
||||
'windows': {
|
||||
'name': 'Windows',
|
||||
'icon': '💻',
|
||||
'link': settings.get_happ_download_link('windows'),
|
||||
},
|
||||
}
|
||||
|
||||
# Filter out platforms without links
|
||||
available_platforms = {k: v for k, v in platforms.items() if v['link']}
|
||||
|
||||
return {
|
||||
'platforms': available_platforms,
|
||||
'happ_enabled': bool(available_platforms),
|
||||
}
|
||||
|
||||
|
||||
# ============ App Config for Connection ============
|
||||
|
||||
|
||||
def _get_remnawave_config_uuid() -> str | None:
|
||||
"""Get RemnaWave config UUID from system settings or env."""
|
||||
try:
|
||||
return bot_configuration_service.get_current_value('CABINET_REMNA_SUB_CONFIG')
|
||||
except Exception:
|
||||
return settings.CABINET_REMNA_SUB_CONFIG
|
||||
|
||||
|
||||
def _extract_scheme_from_buttons(buttons: list[dict[str, Any]]) -> tuple[str, bool]:
|
||||
"""Extract URL scheme from buttons list.
|
||||
|
||||
Returns:
|
||||
Tuple of (scheme, uses_crypto_link).
|
||||
uses_crypto_link=True when the template is {{HAPP_CRYPT4_LINK}},
|
||||
meaning subscription_crypto_link should be used as payload.
|
||||
"""
|
||||
for btn in buttons:
|
||||
if not isinstance(btn, dict):
|
||||
continue
|
||||
link = btn.get('link', '') or btn.get('url', '') or btn.get('buttonLink', '')
|
||||
if not link:
|
||||
continue
|
||||
link_upper = link.upper()
|
||||
|
||||
# Check for {{HAPP_CRYPT4_LINK}} -- uses crypto link as payload
|
||||
if '{{HAPP_CRYPT4_LINK}}' in link_upper or 'HAPP_CRYPT4_LINK' in link_upper:
|
||||
scheme = re.sub(r'\{\{HAPP_CRYPT4_LINK\}\}', '', link, flags=re.IGNORECASE)
|
||||
if scheme and '://' in scheme:
|
||||
return scheme, True
|
||||
|
||||
# Check for {{SUBSCRIPTION_LINK}} -- uses plain subscription_url as payload
|
||||
if '{{SUBSCRIPTION_LINK}}' in link_upper or 'SUBSCRIPTION_LINK' in link_upper:
|
||||
scheme = re.sub(r'\{\{SUBSCRIPTION_LINK\}\}', '', link, flags=re.IGNORECASE)
|
||||
if scheme and '://' in scheme:
|
||||
return scheme, False
|
||||
|
||||
# Also check for type="subscriptionLink" buttons with custom schemes
|
||||
btn_type = btn.get('type', '')
|
||||
if btn_type == 'subscriptionLink' and '://' in link and not link.startswith('http'):
|
||||
scheme = link.split('{{')[0] if '{{' in link else link
|
||||
if scheme and '://' in scheme:
|
||||
return scheme, False
|
||||
return '', False
|
||||
|
||||
|
||||
def _get_url_scheme_for_app(app: dict[str, Any]) -> tuple[str, bool]:
|
||||
"""Get URL scheme for app - from config, buttons, or fallback by name.
|
||||
|
||||
Returns:
|
||||
Tuple of (scheme, uses_crypto_link).
|
||||
uses_crypto_link=True means the app template uses {{HAPP_CRYPT4_LINK}},
|
||||
so subscription_crypto_link should be used as the deep link payload.
|
||||
"""
|
||||
# 1. Check urlScheme field (cabinet format stores usesCryptoLink alongside)
|
||||
scheme = str(app.get('urlScheme', '')).strip()
|
||||
if scheme:
|
||||
uses_crypto = bool(app.get('usesCryptoLink', False))
|
||||
return scheme, uses_crypto
|
||||
|
||||
# 2. Extract from buttons in blocks (RemnaWave format)
|
||||
blocks = app.get('blocks', [])
|
||||
for block in blocks:
|
||||
if not isinstance(block, dict):
|
||||
continue
|
||||
buttons = block.get('buttons', [])
|
||||
scheme, uses_crypto = _extract_scheme_from_buttons(buttons)
|
||||
if scheme:
|
||||
return scheme, uses_crypto
|
||||
|
||||
# 3. Check buttons directly in app (alternative structure)
|
||||
direct_buttons = app.get('buttons', [])
|
||||
if direct_buttons:
|
||||
scheme, uses_crypto = _extract_scheme_from_buttons(direct_buttons)
|
||||
if scheme:
|
||||
return scheme, uses_crypto
|
||||
|
||||
# No scheme found
|
||||
logger.debug(
|
||||
'_get_url_scheme_for_app: No scheme found for app has blocks: has buttons: has urlScheme',
|
||||
get=app.get('name'),
|
||||
get_2=bool(app.get('blocks')),
|
||||
get_3=bool(app.get('buttons')),
|
||||
get_4=bool(app.get('urlScheme')),
|
||||
)
|
||||
return '', False
|
||||
|
||||
|
||||
async def _load_app_config_async() -> dict[str, Any] | None:
|
||||
"""Load app config from RemnaWave API (if configured).
|
||||
|
||||
Returns None when no Remnawave config is set or API fails.
|
||||
"""
|
||||
remnawave_uuid = _get_remnawave_config_uuid()
|
||||
|
||||
if remnawave_uuid:
|
||||
try:
|
||||
service = RemnaWaveService()
|
||||
async with service.get_api_client() as api:
|
||||
config = await api.get_subscription_page_config(remnawave_uuid)
|
||||
if config and config.config:
|
||||
logger.debug('Loaded app config from RemnaWave', remnawave_uuid=remnawave_uuid)
|
||||
raw = dict(config.config)
|
||||
raw['_isRemnawave'] = True
|
||||
return raw
|
||||
except Exception as e:
|
||||
logger.warning('Failed to load RemnaWave config', error=e)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _create_deep_link(
|
||||
app: dict[str, Any], subscription_url: str, subscription_crypto_link: str | None = None
|
||||
) -> str | None:
|
||||
"""Create deep link for app with subscription URL.
|
||||
|
||||
Uses urlScheme from RemnaWave config (e.g. "happ://add/", "v2rayng://install-config?url=")
|
||||
combined with the appropriate payload URL.
|
||||
|
||||
Two Happ schemes exist in RemnaWave:
|
||||
- happ://add/{{SUBSCRIPTION_LINK}} -> uses plain subscription_url
|
||||
- happ://crypt4/{{HAPP_CRYPT4_LINK}} -> uses subscription_crypto_link
|
||||
"""
|
||||
if not isinstance(app, dict):
|
||||
return None
|
||||
|
||||
if not subscription_url and not subscription_crypto_link:
|
||||
return None
|
||||
|
||||
scheme, uses_crypto = _get_url_scheme_for_app(app)
|
||||
if not scheme:
|
||||
logger.debug('_create_deep_link: no urlScheme for app', get=app.get('name', 'unknown'))
|
||||
return None
|
||||
|
||||
# Pick the correct payload based on which template the app uses
|
||||
if uses_crypto:
|
||||
if not subscription_crypto_link:
|
||||
logger.debug(
|
||||
'_create_deep_link: app requires crypto link but none available', get=app.get('name', 'unknown')
|
||||
)
|
||||
return None
|
||||
payload = subscription_crypto_link
|
||||
else:
|
||||
if not subscription_url:
|
||||
logger.debug(
|
||||
'_create_deep_link: app requires subscription_url but none available', get=app.get('name', 'unknown')
|
||||
)
|
||||
return None
|
||||
payload = subscription_url
|
||||
|
||||
if app.get('isNeedBase64Encoding'):
|
||||
try:
|
||||
payload = base64.b64encode(payload.encode('utf-8')).decode('utf-8')
|
||||
except Exception as e:
|
||||
logger.warning('Failed to encode payload to base64', error=e)
|
||||
|
||||
return f'{scheme}{payload}'
|
||||
|
||||
|
||||
def _resolve_button_url(
|
||||
url: str,
|
||||
subscription_url: str | None,
|
||||
subscription_crypto_link: str | None,
|
||||
) -> str:
|
||||
"""Resolve template variables in button URLs.
|
||||
|
||||
Matches remnawave/subscription-page frontend TemplateEngine:
|
||||
- {{SUBSCRIPTION_LINK}} -> plain subscription URL
|
||||
- {{HAPP_CRYPT3_LINK}} -> crypto link
|
||||
- {{HAPP_CRYPT4_LINK}} -> crypto link
|
||||
"""
|
||||
if not url:
|
||||
return url
|
||||
result = url
|
||||
if subscription_url:
|
||||
result = result.replace('{{SUBSCRIPTION_LINK}}', subscription_url)
|
||||
if subscription_crypto_link:
|
||||
result = result.replace('{{HAPP_CRYPT3_LINK}}', subscription_crypto_link)
|
||||
result = result.replace('{{HAPP_CRYPT4_LINK}}', subscription_crypto_link)
|
||||
return result
|
||||
|
||||
|
||||
@router.get('/app-config')
|
||||
async def get_app_config(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
subscription_id: int | None = Query(None, description='Subscription ID for multi-tariff'),
|
||||
) -> dict[str, Any]:
|
||||
"""Get app configuration for connection with deep links."""
|
||||
subscription = await resolve_subscription(db, user, subscription_id)
|
||||
|
||||
subscription_url = None
|
||||
subscription_crypto_link = None
|
||||
if subscription:
|
||||
subscription_url = subscription.subscription_url
|
||||
subscription_crypto_link = subscription.subscription_crypto_link
|
||||
|
||||
# Generate crypto link on the fly if subscription_url exists but crypto link is missing.
|
||||
# This covers synced users where enrich_happ_links was not called.
|
||||
if subscription_url and not subscription_crypto_link:
|
||||
try:
|
||||
service = RemnaWaveService()
|
||||
async with service.get_api_client() as api:
|
||||
encrypted = await api.encrypt_happ_crypto_link(subscription_url)
|
||||
if encrypted:
|
||||
subscription_crypto_link = encrypted
|
||||
if subscription:
|
||||
subscription.subscription_crypto_link = encrypted
|
||||
await db.commit()
|
||||
logger.info(
|
||||
'Generated and saved crypto link for user',
|
||||
user_id=user.id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug('Could not generate crypto link', error=e)
|
||||
|
||||
config = await _load_app_config_async()
|
||||
|
||||
if not config:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='App configuration not set up.',
|
||||
)
|
||||
|
||||
config.pop('_isRemnawave', None)
|
||||
hide_link = settings.should_hide_subscription_link()
|
||||
|
||||
# Build platformNames from displayName of each platform
|
||||
platform_names: dict[str, Any] = {}
|
||||
for pk, pd in config.get('platforms', {}).items():
|
||||
if isinstance(pd, dict) and 'displayName' in pd:
|
||||
platform_names[pk] = pd['displayName']
|
||||
fallback_names = {
|
||||
'ios': {'en': 'iPhone/iPad'},
|
||||
'android': {'en': 'Android'},
|
||||
'macos': {'en': 'macOS'},
|
||||
'windows': {'en': 'Windows'},
|
||||
'linux': {'en': 'Linux'},
|
||||
'androidTV': {'en': 'Android TV'},
|
||||
'appleTV': {'en': 'Apple TV'},
|
||||
}
|
||||
for k, v in fallback_names.items():
|
||||
if k not in platform_names:
|
||||
platform_names[k] = v
|
||||
|
||||
# Serve original blocks/svgLibrary enriched with deep links and resolved URLs.
|
||||
platforms: dict[str, Any] = {}
|
||||
for platform_key, platform_data in config.get('platforms', {}).items():
|
||||
if not isinstance(platform_data, dict):
|
||||
continue
|
||||
apps = platform_data.get('apps', [])
|
||||
if not isinstance(apps, list):
|
||||
continue
|
||||
|
||||
enriched_apps = []
|
||||
for app in apps:
|
||||
if not isinstance(app, dict):
|
||||
continue
|
||||
|
||||
# Generate deep link
|
||||
deep_link = None
|
||||
if subscription_url or subscription_crypto_link:
|
||||
deep_link = _create_deep_link(app, subscription_url, subscription_crypto_link)
|
||||
app['deepLink'] = deep_link
|
||||
|
||||
# Resolve templates only for subscriptionLink and copyButton (not external)
|
||||
for block in app.get('blocks', []):
|
||||
if not isinstance(block, dict):
|
||||
continue
|
||||
for btn in block.get('buttons', []):
|
||||
if not isinstance(btn, dict):
|
||||
continue
|
||||
btn_type = btn.get('type', '')
|
||||
if btn_type in ('subscriptionLink', 'copyButton'):
|
||||
url = btn.get('url', '') or btn.get('link', '')
|
||||
if url and '{{' in url:
|
||||
resolved = _resolve_button_url(
|
||||
url,
|
||||
subscription_url,
|
||||
subscription_crypto_link,
|
||||
)
|
||||
# Only set resolvedUrl if ALL templates were resolved;
|
||||
# otherwise let the frontend fall through to deepLink/subscriptionUrl
|
||||
if '{{' not in resolved:
|
||||
btn['resolvedUrl'] = resolved
|
||||
|
||||
enriched_apps.append(app)
|
||||
|
||||
if enriched_apps:
|
||||
platform_output = {k: v for k, v in platform_data.items() if k != 'apps'}
|
||||
platform_output['apps'] = enriched_apps
|
||||
platforms[platform_key] = platform_output
|
||||
|
||||
return {
|
||||
'isRemnawave': True,
|
||||
'platforms': platforms,
|
||||
'svgLibrary': config.get('svgLibrary', {}),
|
||||
'baseTranslations': config.get('baseTranslations'),
|
||||
'baseSettings': config.get('baseSettings'),
|
||||
'uiConfig': config.get('uiConfig', {}),
|
||||
'platformNames': platform_names,
|
||||
'hasSubscription': bool(subscription_url or subscription_crypto_link),
|
||||
'subscriptionUrl': subscription_url,
|
||||
'subscriptionCryptoLink': subscription_crypto_link,
|
||||
'hideLink': hide_link,
|
||||
'branding': config.get('brandingSettings', {}),
|
||||
}
|
||||
@@ -0,0 +1,532 @@
|
||||
"""Tariff switching endpoints.
|
||||
|
||||
POST /subscription/tariff/switch/preview
|
||||
POST /subscription/tariff/switch
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query as QueryParam, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.tariff import get_tariff_by_id
|
||||
from app.database.crud.transaction import create_transaction
|
||||
from app.database.crud.user import subtract_user_balance
|
||||
from app.database.models import PaymentMethod, Subscription, TransactionType, User
|
||||
from app.services.pricing_engine import pricing_engine
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
|
||||
from ...dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
from ...schemas.subscription import TariffPurchaseRequest
|
||||
from .helpers import _subscription_to_response, resolve_subscription
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post('/tariff/switch/preview')
|
||||
async def preview_tariff_switch(
|
||||
request: TariffPurchaseRequest,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
subscription_id: int | None = QueryParam(None, description='Subscription ID for multi-tariff'),
|
||||
) -> dict[str, Any]:
|
||||
"""Preview tariff switch - shows cost calculation."""
|
||||
if not settings.is_tariffs_mode():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Tariffs mode is not enabled',
|
||||
)
|
||||
|
||||
subscription = await resolve_subscription(db, user, subscription_id)
|
||||
|
||||
if not subscription or not subscription.tariff_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='No active subscription with tariff',
|
||||
)
|
||||
|
||||
# Use actual_status for correct status check (handles time-based expiration)
|
||||
actual_status = subscription.actual_status
|
||||
if actual_status == 'expired':
|
||||
# For expired subscriptions, user should purchase a new tariff, not switch
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={
|
||||
'code': 'subscription_expired',
|
||||
'message': 'Subscription is expired. Please purchase a new tariff instead of switching.',
|
||||
'use_purchase_flow': True,
|
||||
},
|
||||
)
|
||||
if actual_status not in ('active', 'trial'):
|
||||
# For disabled/pending subscriptions, block switching with generic error
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={
|
||||
'code': 'subscription_not_active',
|
||||
'message': f'Subscription is not active (status: {actual_status}). Cannot switch tariff.',
|
||||
},
|
||||
)
|
||||
|
||||
current_tariff = await get_tariff_by_id(db, subscription.tariff_id)
|
||||
new_tariff = await get_tariff_by_id(db, request.tariff_id)
|
||||
|
||||
if not new_tariff or not new_tariff.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Tariff not found or inactive',
|
||||
)
|
||||
|
||||
if subscription.tariff_id == request.tariff_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Already on this tariff',
|
||||
)
|
||||
|
||||
# Check tariff availability for user's promo group
|
||||
# Use get_primary_promo_group() for correct promo group resolution
|
||||
promo_group = user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else None
|
||||
if promo_group is None:
|
||||
promo_group = getattr(user, 'promo_group', None)
|
||||
promo_group_id = promo_group.id if promo_group else None
|
||||
if not new_tariff.is_available_for_promo_group(promo_group_id):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Tariff not available for your promo group',
|
||||
)
|
||||
|
||||
# Calculate remaining days
|
||||
remaining_days = 0
|
||||
if subscription.end_date and subscription.end_date > datetime.now(UTC):
|
||||
delta = subscription.end_date - datetime.now(UTC)
|
||||
remaining_days = max(0, delta.days)
|
||||
|
||||
# Calculate switch cost (PricingEngine handles all cases: periodic<->periodic, daily->periodic, periodic->daily)
|
||||
switch_result = pricing_engine.calculate_tariff_switch_cost(
|
||||
current_tariff,
|
||||
new_tariff,
|
||||
remaining_days,
|
||||
user=user,
|
||||
)
|
||||
upgrade_cost = switch_result.upgrade_cost
|
||||
is_upgrade = switch_result.is_upgrade
|
||||
|
||||
# Проверяем разрешение на смену в данном направлении
|
||||
if is_upgrade and not settings.TARIFF_SWITCH_UPGRADE_ENABLED:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Повышение тарифа недоступно',
|
||||
)
|
||||
if not is_upgrade and not settings.TARIFF_SWITCH_DOWNGRADE_ENABLED:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Понижение тарифа недоступно',
|
||||
)
|
||||
base_upgrade_cost = switch_result.raw_cost
|
||||
discount_value = switch_result.discount_value
|
||||
period_discount_percent = switch_result.effective_discount_pct
|
||||
|
||||
balance = user.balance_kopeks or 0
|
||||
has_enough = balance >= upgrade_cost
|
||||
missing = max(0, upgrade_cost - balance) if not has_enough else 0
|
||||
|
||||
response: dict[str, Any] = {
|
||||
'can_switch': has_enough,
|
||||
'current_tariff_id': current_tariff.id if current_tariff else None,
|
||||
'current_tariff_name': current_tariff.name if current_tariff else None,
|
||||
'new_tariff_id': new_tariff.id,
|
||||
'new_tariff_name': new_tariff.name,
|
||||
'remaining_days': remaining_days,
|
||||
'upgrade_cost_kopeks': upgrade_cost,
|
||||
'upgrade_cost_label': settings.format_price(upgrade_cost) if upgrade_cost > 0 else 'Бесплатно',
|
||||
'balance_kopeks': balance,
|
||||
'balance_label': settings.format_price(balance),
|
||||
'has_enough_balance': has_enough,
|
||||
'missing_amount_kopeks': missing,
|
||||
'missing_amount_label': settings.format_price(missing) if missing > 0 else '',
|
||||
'is_upgrade': is_upgrade,
|
||||
}
|
||||
|
||||
# Add discount info if applicable
|
||||
if period_discount_percent > 0 and discount_value > 0:
|
||||
response['discount_percent'] = period_discount_percent
|
||||
response['discount_kopeks'] = discount_value
|
||||
response['base_upgrade_cost_kopeks'] = base_upgrade_cost
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@router.post('/tariff/switch')
|
||||
async def switch_tariff(
|
||||
request: TariffPurchaseRequest,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
subscription_id: int | None = QueryParam(None, description='Subscription ID for multi-tariff'),
|
||||
) -> dict[str, Any]:
|
||||
"""Switch to a different tariff without changing end date."""
|
||||
if not settings.is_tariffs_mode():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Tariffs mode is not enabled',
|
||||
)
|
||||
|
||||
resolved = await resolve_subscription(db, user, subscription_id)
|
||||
|
||||
if not resolved or not resolved.tariff_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='No active subscription with tariff',
|
||||
)
|
||||
|
||||
# Guard: prevent switching to a tariff the user already owns (multi-tariff)
|
||||
if settings.is_multi_tariff_enabled() and request.tariff_id:
|
||||
from app.database.crud.subscription import get_subscription_by_user_and_tariff
|
||||
|
||||
existing_target = await get_subscription_by_user_and_tariff(db, user.id, request.tariff_id)
|
||||
if existing_target and existing_target.id != resolved.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail='You already have an active subscription for the target tariff',
|
||||
)
|
||||
|
||||
# Lock subscription row to prevent concurrent tariff switches
|
||||
locked_result = await db.execute(
|
||||
select(Subscription)
|
||||
.where(Subscription.id == resolved.id)
|
||||
.with_for_update()
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
subscription = locked_result.scalar_one()
|
||||
|
||||
# Use actual_status for correct status check (handles time-based expiration)
|
||||
actual_status = subscription.actual_status
|
||||
if actual_status == 'expired':
|
||||
# For expired subscriptions, user should purchase a new tariff, not switch
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={
|
||||
'code': 'subscription_expired',
|
||||
'message': 'Subscription is expired. Please purchase a new tariff instead of switching.',
|
||||
'use_purchase_flow': True,
|
||||
},
|
||||
)
|
||||
if actual_status not in ('active', 'trial'):
|
||||
# For disabled/pending subscriptions, block switching with generic error
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={
|
||||
'code': 'subscription_not_active',
|
||||
'message': f'Subscription is not active (status: {actual_status}). Cannot switch tariff.',
|
||||
},
|
||||
)
|
||||
|
||||
current_tariff = await get_tariff_by_id(db, subscription.tariff_id)
|
||||
new_tariff = await get_tariff_by_id(db, request.tariff_id)
|
||||
|
||||
if not new_tariff or not new_tariff.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Tariff not found or inactive',
|
||||
)
|
||||
|
||||
if subscription.tariff_id == request.tariff_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Already on this tariff',
|
||||
)
|
||||
|
||||
# Check tariff availability
|
||||
# Use get_primary_promo_group() for correct promo group resolution
|
||||
promo_group = user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else None
|
||||
if promo_group is None:
|
||||
promo_group = getattr(user, 'promo_group', None)
|
||||
promo_group_id = promo_group.id if promo_group else None
|
||||
if not new_tariff.is_available_for_promo_group(promo_group_id):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Tariff not available',
|
||||
)
|
||||
|
||||
# Lock user BEFORE price computation to prevent TOCTOU on promo offer
|
||||
from app.database.crud.user import lock_user_for_pricing
|
||||
|
||||
user = await lock_user_for_pricing(db, user.id)
|
||||
|
||||
# Calculate remaining days
|
||||
remaining_days = 0
|
||||
if subscription.end_date and subscription.end_date > datetime.now(UTC):
|
||||
delta = subscription.end_date - datetime.now(UTC)
|
||||
remaining_days = max(0, delta.days)
|
||||
|
||||
# Calculate cost (PricingEngine handles all cases: periodic<->periodic, daily->periodic, periodic->daily)
|
||||
switch_result = pricing_engine.calculate_tariff_switch_cost(
|
||||
current_tariff,
|
||||
new_tariff,
|
||||
remaining_days,
|
||||
user=user,
|
||||
)
|
||||
upgrade_cost = switch_result.upgrade_cost
|
||||
is_upgrade = switch_result.is_upgrade
|
||||
base_upgrade_cost = switch_result.raw_cost
|
||||
discount_value = switch_result.discount_value
|
||||
period_discount_percent = switch_result.effective_discount_pct
|
||||
new_period_days = switch_result.new_period_days
|
||||
|
||||
# Проверяем разрешение на смену в данном направлении
|
||||
if is_upgrade and not settings.TARIFF_SWITCH_UPGRADE_ENABLED:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Повышение тарифа недоступно',
|
||||
)
|
||||
if not is_upgrade and not settings.TARIFF_SWITCH_DOWNGRADE_ENABLED:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Понижение тарифа недоступно',
|
||||
)
|
||||
|
||||
# Validate daily price for switching TO daily
|
||||
new_is_daily = getattr(new_tariff, 'is_daily', False)
|
||||
current_is_daily = getattr(current_tariff, 'is_daily', False) if current_tariff else False
|
||||
switching_to_daily = not current_is_daily and new_is_daily
|
||||
switching_from_daily = current_is_daily and not new_is_daily
|
||||
|
||||
if switching_to_daily and (getattr(new_tariff, 'daily_price_kopeks', 0) or 0) <= 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Daily tariff has invalid price',
|
||||
)
|
||||
|
||||
# Charge if upgrade
|
||||
switch_transaction = None
|
||||
if upgrade_cost > 0:
|
||||
if user.balance_kopeks < upgrade_cost:
|
||||
missing = upgrade_cost - user.balance_kopeks
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_402_PAYMENT_REQUIRED,
|
||||
detail={
|
||||
'code': 'insufficient_funds',
|
||||
'message': f'Insufficient funds. Missing {settings.format_price(missing)}',
|
||||
'missing_amount': missing,
|
||||
},
|
||||
)
|
||||
|
||||
if switching_to_daily:
|
||||
description = f"Переход на суточный тариф '{new_tariff.name}'"
|
||||
elif switching_from_daily:
|
||||
description = f"Переход с суточного на тариф '{new_tariff.name}' ({new_period_days} дней)"
|
||||
else:
|
||||
description = f"Переход на тариф '{new_tariff.name}' (доплата за {remaining_days} дней)"
|
||||
|
||||
# Add discount info to description if applicable
|
||||
if period_discount_percent > 0 and discount_value > 0:
|
||||
description += f' (скидка {period_discount_percent}%)'
|
||||
|
||||
success = await subtract_user_balance(
|
||||
db,
|
||||
user,
|
||||
upgrade_cost,
|
||||
description,
|
||||
consume_promo_offer=switch_result.offer_discount_pct > 0,
|
||||
mark_as_paid_subscription=True,
|
||||
commit=False,
|
||||
)
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to charge balance',
|
||||
)
|
||||
|
||||
# Create transaction (commit=False to keep FOR UPDATE lock held)
|
||||
switch_transaction = await create_transaction(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
amount_kopeks=upgrade_cost,
|
||||
description=description,
|
||||
payment_method=PaymentMethod.BALANCE,
|
||||
commit=False,
|
||||
)
|
||||
else:
|
||||
# Free switch (downgrade) — record in history
|
||||
description = f"Переход на тариф '{new_tariff.name}'"
|
||||
await create_transaction(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
amount_kopeks=0,
|
||||
description=description,
|
||||
commit=False,
|
||||
)
|
||||
|
||||
# Update subscription
|
||||
old_tariff_name = current_tariff.name if current_tariff else 'Unknown'
|
||||
|
||||
# Reset device limit to new tariff base (extra purchased devices are not carried over)
|
||||
from app.database.crud.subscription import calc_device_limit_on_tariff_switch
|
||||
|
||||
# Re-load subscription to avoid MissingGreenlet from expired lazy relationship
|
||||
# (subtract_user_balance re-selects User with populate_existing=True which expires relationships)
|
||||
await db.refresh(subscription)
|
||||
|
||||
subscription.tariff_id = new_tariff.id
|
||||
subscription.traffic_limit_gb = new_tariff.traffic_limit_gb
|
||||
subscription.device_limit = calc_device_limit_on_tariff_switch(
|
||||
current_device_limit=subscription.device_limit,
|
||||
old_tariff_device_limit=current_tariff.device_limit if current_tariff else None,
|
||||
new_tariff_device_limit=new_tariff.device_limit,
|
||||
max_device_limit=new_tariff.max_device_limit,
|
||||
)
|
||||
subscription.connected_squads = new_tariff.allowed_squads or []
|
||||
|
||||
# Reset purchased traffic and delete TrafficPurchase records on tariff switch
|
||||
from sqlalchemy import delete as sql_delete
|
||||
|
||||
from app.database.models import TrafficPurchase
|
||||
|
||||
await db.execute(sql_delete(TrafficPurchase).where(TrafficPurchase.subscription_id == subscription.id))
|
||||
subscription.purchased_traffic_gb = 0
|
||||
subscription.traffic_reset_at = None
|
||||
|
||||
if settings.RESET_TRAFFIC_ON_TARIFF_SWITCH:
|
||||
subscription.traffic_used_gb = 0.0
|
||||
|
||||
if switching_to_daily:
|
||||
# Switching TO daily - reset end_date to 1 day, set last_daily_charge_at
|
||||
subscription.end_date = datetime.now(UTC) + timedelta(days=1)
|
||||
subscription.last_daily_charge_at = datetime.now(UTC)
|
||||
subscription.is_daily_paused = False
|
||||
elif switching_from_daily:
|
||||
subscription.end_date = datetime.now(UTC) + timedelta(days=new_period_days)
|
||||
subscription.is_daily_paused = False
|
||||
|
||||
subscription.updated_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
|
||||
# Emit deferred side-effects after atomic commit
|
||||
if upgrade_cost > 0 and switch_transaction:
|
||||
from app.database.crud.transaction import emit_transaction_side_effects
|
||||
|
||||
await emit_transaction_side_effects(
|
||||
db,
|
||||
switch_transaction,
|
||||
amount_kopeks=upgrade_cost,
|
||||
user_id=user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
payment_method=PaymentMethod.BALANCE,
|
||||
)
|
||||
|
||||
# Sync with RemnaWave (optionally reset traffic based on admin setting)
|
||||
should_reset_traffic = settings.RESET_TRAFFIC_ON_TARIFF_SWITCH
|
||||
# Refresh subscription after commit (all objects are expired)
|
||||
await db.refresh(subscription)
|
||||
|
||||
try:
|
||||
subscription_service = SubscriptionService()
|
||||
_has_panel = (
|
||||
getattr(subscription, 'remnawave_uuid', None)
|
||||
if settings.is_multi_tariff_enabled()
|
||||
else getattr(user, 'remnawave_uuid', None)
|
||||
)
|
||||
if _has_panel:
|
||||
await subscription_service.update_remnawave_user(
|
||||
db,
|
||||
subscription,
|
||||
reset_traffic=should_reset_traffic,
|
||||
reset_reason='смена тарифа',
|
||||
sync_squads=True,
|
||||
)
|
||||
else:
|
||||
await subscription_service.create_remnawave_user(
|
||||
db,
|
||||
subscription,
|
||||
reset_traffic=should_reset_traffic,
|
||||
reset_reason='смена тарифа',
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error('Failed to sync tariff switch with RemnaWave', error=e)
|
||||
from app.services.remnawave_retry_queue import remnawave_retry_queue
|
||||
|
||||
remnawave_retry_queue.enqueue(
|
||||
subscription_id=subscription.id,
|
||||
user_id=user.id,
|
||||
action='update' if _has_panel else 'create',
|
||||
)
|
||||
|
||||
# Reset all devices on tariff switch
|
||||
devices_reset = False
|
||||
_switch_uuid = (
|
||||
subscription.remnawave_uuid
|
||||
if settings.is_multi_tariff_enabled() and subscription.remnawave_uuid
|
||||
else user.remnawave_uuid
|
||||
)
|
||||
if _switch_uuid:
|
||||
try:
|
||||
service = RemnaWaveService()
|
||||
async with service.get_api_client() as api:
|
||||
await api.reset_user_devices(_switch_uuid)
|
||||
devices_reset = True
|
||||
logger.info('Reset all devices for user on tariff switch', user_id=user.id)
|
||||
except Exception as e:
|
||||
logger.error('Failed to reset devices on tariff switch', error=e)
|
||||
|
||||
await db.refresh(user)
|
||||
await db.refresh(subscription)
|
||||
|
||||
# Отправляем уведомление админам о смене тарифа
|
||||
try:
|
||||
from aiogram import Bot
|
||||
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
if getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) and settings.BOT_TOKEN:
|
||||
bot = Bot(token=settings.BOT_TOKEN)
|
||||
try:
|
||||
notification_service = AdminNotificationService(bot)
|
||||
await notification_service.send_subscription_purchase_notification(
|
||||
db=db,
|
||||
user=user,
|
||||
subscription=subscription,
|
||||
transaction=switch_transaction if upgrade_cost > 0 else None,
|
||||
period_days=remaining_days if remaining_days > 0 else new_period_days,
|
||||
was_trial_conversion=False,
|
||||
amount_kopeks=upgrade_cost,
|
||||
purchase_type='tariff_switch',
|
||||
)
|
||||
finally:
|
||||
await bot.session.close()
|
||||
except Exception as e:
|
||||
logger.error('Failed to send admin notification for tariff switch', error=e)
|
||||
|
||||
# Refresh expired objects after db.commit() in _record_subscription_event
|
||||
await db.refresh(subscription)
|
||||
await db.refresh(user)
|
||||
|
||||
response: dict[str, Any] = {
|
||||
'success': True,
|
||||
'message': f"Switched from '{old_tariff_name}' to '{new_tariff.name}'"
|
||||
+ (' (devices reset)' if devices_reset else ''),
|
||||
'subscription': _subscription_to_response(subscription, user=user),
|
||||
'old_tariff_name': old_tariff_name,
|
||||
'new_tariff_id': new_tariff.id,
|
||||
'new_tariff_name': new_tariff.name,
|
||||
'charged_kopeks': upgrade_cost,
|
||||
'balance_kopeks': user.balance_kopeks,
|
||||
'balance_label': settings.format_price(user.balance_kopeks),
|
||||
}
|
||||
|
||||
# Add discount info if applicable
|
||||
if period_discount_percent > 0 and discount_value > 0:
|
||||
response['discount_percent'] = period_discount_percent
|
||||
response['discount_kopeks'] = discount_value
|
||||
response['base_charged_kopeks'] = base_upgrade_cost
|
||||
|
||||
return response
|
||||
@@ -0,0 +1,792 @@
|
||||
"""Traffic management endpoints.
|
||||
|
||||
GET /subscription/traffic-packages
|
||||
POST /subscription/traffic
|
||||
PUT /subscription/traffic
|
||||
POST /subscription/refresh-traffic
|
||||
POST /subscription/traffic/save-cart
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query as QueryParam, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.tariff import get_tariff_by_id
|
||||
from app.database.crud.transaction import create_transaction
|
||||
from app.database.crud.user import subtract_user_balance
|
||||
from app.database.models import TransactionType, User
|
||||
from app.services.pricing_engine import pricing_engine
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
from app.services.user_cart_service import user_cart_service
|
||||
from app.utils.cache import RateLimitCache, cache, cache_key
|
||||
|
||||
from ...dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
from ...schemas.subscription import (
|
||||
TrafficPackageResponse,
|
||||
TrafficPurchaseRequest,
|
||||
)
|
||||
from .helpers import _apply_addon_discount, resolve_subscription
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get('/traffic-packages', response_model=list[TrafficPackageResponse])
|
||||
async def get_traffic_packages(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
subscription_id: int | None = QueryParam(None, description='Subscription ID for multi-tariff'),
|
||||
):
|
||||
"""Get available traffic packages."""
|
||||
from app.database.crud.tariff import get_tariff_by_id
|
||||
|
||||
subscription = await resolve_subscription(db, user, subscription_id)
|
||||
if not subscription:
|
||||
return []
|
||||
|
||||
# Режим тарифов - берём пакеты из тарифа
|
||||
if settings.is_tariffs_mode() and subscription.tariff_id:
|
||||
tariff = await get_tariff_by_id(db, subscription.tariff_id)
|
||||
if not tariff:
|
||||
return []
|
||||
|
||||
# Проверяем, разрешена ли докупка для этого тарифа
|
||||
if not getattr(tariff, 'traffic_topup_enabled', False):
|
||||
return []
|
||||
|
||||
# Проверяем безлимит
|
||||
if tariff.traffic_limit_gb == 0:
|
||||
return []
|
||||
|
||||
packages = tariff.get_traffic_topup_packages() if hasattr(tariff, 'get_traffic_topup_packages') else {}
|
||||
result = []
|
||||
|
||||
for gb, price in packages.items():
|
||||
if price <= 0:
|
||||
continue
|
||||
result.append(
|
||||
TrafficPackageResponse(
|
||||
gb=gb,
|
||||
price_kopeks=price,
|
||||
price_rubles=price / 100,
|
||||
is_unlimited=False,
|
||||
)
|
||||
)
|
||||
|
||||
return sorted(result, key=lambda x: x.gb)
|
||||
|
||||
# Classic режим - глобальные настройки
|
||||
if not settings.is_traffic_topup_enabled():
|
||||
return []
|
||||
|
||||
# Проверяем настройку тарифа пользователя (allow_traffic_topup)
|
||||
if subscription.tariff_id:
|
||||
tariff = await get_tariff_by_id(db, subscription.tariff_id)
|
||||
if tariff and not tariff.allow_traffic_topup:
|
||||
return []
|
||||
|
||||
packages = settings.get_traffic_topup_packages()
|
||||
result = []
|
||||
|
||||
for pkg in packages:
|
||||
if not pkg.get('enabled', True):
|
||||
continue
|
||||
if pkg['price'] <= 0:
|
||||
continue
|
||||
|
||||
result.append(
|
||||
TrafficPackageResponse(
|
||||
gb=pkg['gb'],
|
||||
price_kopeks=pkg['price'],
|
||||
price_rubles=pkg['price'] / 100,
|
||||
is_unlimited=pkg['gb'] == 0,
|
||||
)
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.post('/traffic')
|
||||
async def purchase_traffic(
|
||||
request: TrafficPurchaseRequest,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
subscription_id: int | None = QueryParam(None, description='Subscription ID for multi-tariff'),
|
||||
):
|
||||
"""Purchase additional traffic."""
|
||||
if getattr(user, 'restriction_subscription', False):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Subscription purchases are restricted for this account',
|
||||
)
|
||||
|
||||
from app.database.crud.subscription import add_subscription_traffic
|
||||
from app.database.crud.tariff import get_tariff_by_id
|
||||
from app.utils.pricing_utils import calculate_prorated_price
|
||||
|
||||
subscription = await resolve_subscription(db, user, subscription_id)
|
||||
|
||||
if not subscription:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='No subscription found',
|
||||
)
|
||||
tariff = None
|
||||
base_price_kopeks = 0
|
||||
is_tariff_mode = settings.is_tariffs_mode() and subscription.tariff_id
|
||||
|
||||
# Режим тарифов
|
||||
if is_tariff_mode:
|
||||
tariff = await get_tariff_by_id(db, subscription.tariff_id)
|
||||
if not tariff:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Tariff not found',
|
||||
)
|
||||
|
||||
# Проверяем, разрешена ли докупка
|
||||
if not getattr(tariff, 'traffic_topup_enabled', False):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Traffic top-up is disabled for this tariff',
|
||||
)
|
||||
|
||||
# Проверяем безлимит
|
||||
if tariff.traffic_limit_gb == 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Cannot add traffic to unlimited subscription',
|
||||
)
|
||||
|
||||
# Проверяем лимит докупки
|
||||
max_topup_limit = getattr(tariff, 'max_topup_traffic_gb', 0) or 0
|
||||
if max_topup_limit > 0:
|
||||
current_traffic = subscription.traffic_limit_gb or 0
|
||||
new_traffic = current_traffic + request.gb
|
||||
if new_traffic > max_topup_limit:
|
||||
available_gb = max(0, max_topup_limit - current_traffic)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Traffic limit exceeded. Max: {max_topup_limit} GB, available: {available_gb} GB',
|
||||
)
|
||||
|
||||
# Получаем цену из тарифа
|
||||
packages = tariff.get_traffic_topup_packages() if hasattr(tariff, 'get_traffic_topup_packages') else {}
|
||||
if request.gb not in packages:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Traffic package {request.gb}GB is not available',
|
||||
)
|
||||
base_price_kopeks = packages[request.gb]
|
||||
if base_price_kopeks <= 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Traffic package {request.gb}GB has no price configured',
|
||||
)
|
||||
|
||||
else:
|
||||
# Classic режим
|
||||
if not settings.is_traffic_topup_enabled():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Traffic top-up feature is disabled',
|
||||
)
|
||||
|
||||
# Проверяем настройку тарифа (allow_traffic_topup)
|
||||
if subscription.tariff_id:
|
||||
tariff = await get_tariff_by_id(db, subscription.tariff_id)
|
||||
if tariff and not tariff.allow_traffic_topup:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Traffic top-up is not available for your tariff',
|
||||
)
|
||||
|
||||
# Получаем цену из глобальных настроек
|
||||
packages = settings.get_traffic_topup_packages()
|
||||
matching_pkg = next((pkg for pkg in packages if pkg['gb'] == request.gb and pkg.get('enabled', True)), None)
|
||||
if not matching_pkg:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Invalid traffic package',
|
||||
)
|
||||
base_price_kopeks = matching_pkg['price']
|
||||
if base_price_kopeks <= 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Traffic package has no price configured',
|
||||
)
|
||||
|
||||
# На тарифах пакеты трафика покупаются на 1 месяц (30 дней),
|
||||
# цена в тарифе уже месячная — не умножаем на оставшиеся месяцы подписки.
|
||||
# Пропорциональный расчёт применяем только в классическом режиме.
|
||||
if is_tariff_mode:
|
||||
prorated_price = base_price_kopeks
|
||||
days_charged = 30
|
||||
else:
|
||||
prorated_price, days_charged = calculate_prorated_price(
|
||||
base_price_kopeks,
|
||||
subscription.end_date,
|
||||
)
|
||||
|
||||
# Lock user row to prevent TOCTOU on promo-offer state
|
||||
from app.database.crud.user import lock_user_for_pricing
|
||||
|
||||
user = await lock_user_for_pricing(db, user.id)
|
||||
|
||||
# Apply discount from promo group using proper method
|
||||
period_hint_days = days_charged if days_charged > 0 else 30
|
||||
discount_result = _apply_addon_discount(user, 'traffic', prorated_price, period_hint_days)
|
||||
final_price = discount_result['discounted']
|
||||
traffic_discount_percent = discount_result['percent']
|
||||
discount_value = discount_result['discount']
|
||||
|
||||
# Ensure minimum price after discount (except for 100% discount)
|
||||
if traffic_discount_percent < 100 and final_price > 0:
|
||||
final_price = max(100, final_price)
|
||||
|
||||
# Проверяем баланс
|
||||
if final_price > 0 and user.balance_kopeks < final_price:
|
||||
missing = final_price - user.balance_kopeks
|
||||
|
||||
# Save cart for auto-purchase after balance top-up
|
||||
cart_data = {
|
||||
'cart_mode': 'add_traffic',
|
||||
'subscription_id': subscription.id,
|
||||
'traffic_gb': request.gb,
|
||||
'price_kopeks': final_price,
|
||||
'base_price_kopeks': prorated_price,
|
||||
'discount_percent': traffic_discount_percent,
|
||||
'source': 'cabinet',
|
||||
'description': f'Докупка {request.gb} ГБ трафика',
|
||||
}
|
||||
|
||||
try:
|
||||
await user_cart_service.save_user_cart(user.id, cart_data)
|
||||
logger.info(
|
||||
'Cart saved for traffic purchase (cabinet) user + discount',
|
||||
user_id=user.id,
|
||||
gb=request.gb,
|
||||
traffic_discount_percent=traffic_discount_percent,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error('Error saving cart for traffic purchase (cabinet)', error=e)
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_402_PAYMENT_REQUIRED,
|
||||
detail={
|
||||
'code': 'insufficient_funds',
|
||||
'message': f'Недостаточно средств. Не хватает {settings.format_price(missing)}',
|
||||
'missing_amount': missing,
|
||||
'cart_saved': True,
|
||||
'cart_mode': 'add_traffic',
|
||||
},
|
||||
)
|
||||
|
||||
# Формируем описание
|
||||
if traffic_discount_percent > 0:
|
||||
traffic_description = f'Докупка {request.gb} ГБ трафика (скидка {traffic_discount_percent}%)'
|
||||
else:
|
||||
traffic_description = f'Докупка {request.gb} ГБ трафика'
|
||||
|
||||
# Списываем баланс
|
||||
success = await subtract_user_balance(db, user, final_price, traffic_description)
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to charge balance',
|
||||
)
|
||||
|
||||
# Добавляем трафик (add_subscription_traffic обновляет purchased_traffic_gb, traffic_reset_at и коммитит)
|
||||
await add_subscription_traffic(db, subscription, request.gb)
|
||||
|
||||
# Реактивируем подписку если она была DISABLED/EXPIRED (например, после LIMITED/EXPIRED в RemnaWave)
|
||||
from app.database.crud.subscription import reactivate_subscription
|
||||
|
||||
await reactivate_subscription(db, subscription)
|
||||
|
||||
# Синхронизируем с RemnaWave
|
||||
try:
|
||||
subscription_service = SubscriptionService()
|
||||
if settings.is_multi_tariff_enabled():
|
||||
_should_create = not subscription.remnawave_uuid
|
||||
else:
|
||||
_should_create = not getattr(user, 'remnawave_uuid', None)
|
||||
|
||||
if _should_create:
|
||||
await subscription_service.create_remnawave_user(db, subscription)
|
||||
else:
|
||||
await subscription_service.update_remnawave_user(db, subscription)
|
||||
if subscription.status == 'active':
|
||||
_enable_uuid = (
|
||||
subscription.remnawave_uuid
|
||||
if settings.is_multi_tariff_enabled()
|
||||
else getattr(user, 'remnawave_uuid', None)
|
||||
)
|
||||
if _enable_uuid:
|
||||
await subscription_service.enable_remnawave_user(_enable_uuid)
|
||||
except Exception as e:
|
||||
logger.error('Failed to sync traffic with RemnaWave', error=e)
|
||||
from app.services.remnawave_retry_queue import remnawave_retry_queue
|
||||
|
||||
remnawave_retry_queue.enqueue(
|
||||
subscription_id=subscription.id,
|
||||
user_id=user.id,
|
||||
action='create' if _should_create else 'update',
|
||||
)
|
||||
|
||||
# Создаём транзакцию
|
||||
await create_transaction(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
amount_kopeks=final_price,
|
||||
description=traffic_description,
|
||||
)
|
||||
|
||||
await db.refresh(user)
|
||||
await db.refresh(subscription)
|
||||
|
||||
# Отправляем уведомление админам
|
||||
try:
|
||||
from aiogram import Bot
|
||||
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
if getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) and settings.BOT_TOKEN:
|
||||
bot = Bot(token=settings.BOT_TOKEN)
|
||||
try:
|
||||
notification_service = AdminNotificationService(bot)
|
||||
old_traffic = subscription.traffic_limit_gb - request.gb
|
||||
await notification_service.send_subscription_update_notification(
|
||||
db=db,
|
||||
user=user,
|
||||
subscription=subscription,
|
||||
update_type='traffic',
|
||||
old_value=old_traffic,
|
||||
new_value=subscription.traffic_limit_gb,
|
||||
price_paid=final_price,
|
||||
)
|
||||
finally:
|
||||
await bot.session.close()
|
||||
except Exception as e:
|
||||
logger.error('Failed to send admin notification for traffic purchase', error=e)
|
||||
|
||||
response: dict[str, Any] = {
|
||||
'success': True,
|
||||
'message': 'Traffic purchased successfully',
|
||||
'gb_added': request.gb,
|
||||
'new_traffic_limit_gb': subscription.traffic_limit_gb,
|
||||
'amount_paid_kopeks': final_price,
|
||||
'new_balance_kopeks': user.balance_kopeks,
|
||||
}
|
||||
|
||||
if traffic_discount_percent > 0:
|
||||
response['discount_percent'] = traffic_discount_percent
|
||||
response['discount_kopeks'] = discount_value
|
||||
response['base_price_kopeks'] = prorated_price
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@router.post('/traffic/save-cart')
|
||||
async def save_traffic_cart(
|
||||
request: TrafficPurchaseRequest,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
subscription_id: int | None = QueryParam(None, description='Subscription ID for multi-tariff'),
|
||||
) -> dict[str, bool]:
|
||||
"""Save cart for traffic purchase (for insufficient balance flow)."""
|
||||
|
||||
subscription = await resolve_subscription(db, user, subscription_id)
|
||||
|
||||
if not subscription:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='У вас нет активной подписки',
|
||||
)
|
||||
|
||||
if subscription.status not in ['active', 'trial']:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Ваша подписка неактивна',
|
||||
)
|
||||
|
||||
if subscription.is_trial:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Докупка трафика недоступна на пробном периоде',
|
||||
)
|
||||
|
||||
if subscription.traffic_limit_gb == 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='У вас уже безлимитный трафик',
|
||||
)
|
||||
|
||||
# Get traffic price from tariff or settings
|
||||
tariff = None
|
||||
base_price_kopeks = 0
|
||||
is_tariff_mode = settings.is_tariffs_mode() and subscription.tariff_id
|
||||
|
||||
if is_tariff_mode:
|
||||
tariff = await get_tariff_by_id(db, subscription.tariff_id)
|
||||
if not tariff:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Тариф не найден',
|
||||
)
|
||||
|
||||
if not getattr(tariff, 'traffic_topup_enabled', False):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Докупка трафика недоступна на вашем тарифе',
|
||||
)
|
||||
|
||||
packages = tariff.get_traffic_topup_packages() if hasattr(tariff, 'get_traffic_topup_packages') else {}
|
||||
if request.gb not in packages:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Пакет трафика {request.gb} ГБ недоступен',
|
||||
)
|
||||
base_price_kopeks = packages[request.gb]
|
||||
else:
|
||||
if not settings.is_traffic_topup_enabled():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Докупка трафика отключена',
|
||||
)
|
||||
|
||||
packages = settings.get_traffic_topup_packages()
|
||||
matching_pkg = next((pkg for pkg in packages if pkg['gb'] == request.gb and pkg.get('enabled', True)), None)
|
||||
if not matching_pkg:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Недоступный пакет трафика',
|
||||
)
|
||||
base_price_kopeks = matching_pkg['price']
|
||||
|
||||
# Calculate prorated price (days-based), then apply discount
|
||||
from app.utils.pricing_utils import calculate_prorated_price as _calc_prorated
|
||||
|
||||
now = datetime.now(UTC)
|
||||
days_left = max(1, math.ceil((subscription.end_date - now).total_seconds() / 86400))
|
||||
prorated_price, _ = _calc_prorated(
|
||||
base_price_kopeks,
|
||||
subscription.end_date,
|
||||
)
|
||||
discount_result = _apply_addon_discount(user, 'traffic', prorated_price, days_left)
|
||||
final_price = discount_result['discounted']
|
||||
traffic_discount_percent = discount_result['percent']
|
||||
|
||||
# Save cart for auto-purchase after balance top-up
|
||||
cart_data = {
|
||||
'cart_mode': 'add_traffic',
|
||||
'subscription_id': subscription.id,
|
||||
'traffic_gb': request.gb,
|
||||
'price_kopeks': final_price,
|
||||
'base_price_kopeks': base_price_kopeks,
|
||||
'discount_percent': traffic_discount_percent,
|
||||
'source': 'cabinet',
|
||||
'description': f'Докупка {request.gb} ГБ трафика',
|
||||
}
|
||||
await user_cart_service.save_user_cart(user.id, cart_data)
|
||||
logger.info('Cart saved for traffic purchase (cabinet save-cart) user +', user_id=user.id, gb=request.gb)
|
||||
|
||||
return {'success': True, 'cart_saved': True}
|
||||
|
||||
|
||||
# ============ Traffic Switch (Change Traffic Package) ============
|
||||
|
||||
|
||||
@router.put('/traffic')
|
||||
async def switch_traffic_package(
|
||||
request: TrafficPurchaseRequest,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
subscription_id: int | None = QueryParam(None, description='Subscription ID for multi-tariff'),
|
||||
) -> dict[str, Any]:
|
||||
"""Switch to a different traffic package (change limit)."""
|
||||
from app.utils.pricing_utils import calculate_prorated_price
|
||||
|
||||
subscription = await resolve_subscription(db, user, subscription_id)
|
||||
|
||||
if not subscription:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='No subscription found',
|
||||
)
|
||||
|
||||
if subscription.is_trial:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Traffic management is only available for paid subscriptions',
|
||||
)
|
||||
|
||||
current_traffic = subscription.traffic_limit_gb or 0
|
||||
new_traffic = request.gb
|
||||
|
||||
if current_traffic == new_traffic:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Already on this traffic package',
|
||||
)
|
||||
|
||||
# Get available packages
|
||||
packages = settings.get_traffic_packages()
|
||||
current_pkg = next((p for p in packages if p['gb'] == current_traffic and p.get('enabled', True)), None)
|
||||
new_pkg = next((p for p in packages if p['gb'] == new_traffic and p.get('enabled', True)), None)
|
||||
|
||||
if not new_pkg:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Invalid traffic package',
|
||||
)
|
||||
|
||||
# Calculate price difference (only charge for upgrade)
|
||||
current_price = current_pkg['price'] if current_pkg else 0
|
||||
new_price = new_pkg['price']
|
||||
|
||||
if new_price > current_price:
|
||||
# Upgrade - charge difference
|
||||
price_diff = new_price - current_price
|
||||
|
||||
# Lock user row to prevent TOCTOU on promo-offer state
|
||||
from app.database.crud.user import lock_user_for_pricing
|
||||
|
||||
user = await lock_user_for_pricing(db, user.id)
|
||||
|
||||
# Apply promo discount via PricingEngine
|
||||
price_diff, _discount_val, traffic_discount_percent = pricing_engine.calculate_traffic_discount(
|
||||
price_diff,
|
||||
user,
|
||||
)
|
||||
|
||||
# Prorated calculation
|
||||
final_price, days_charged = calculate_prorated_price(price_diff, subscription.end_date)
|
||||
|
||||
if final_price > 0 and user.balance_kopeks < final_price:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_402_PAYMENT_REQUIRED,
|
||||
detail=f'Insufficient balance. Need {final_price / 100:.2f} RUB',
|
||||
)
|
||||
|
||||
# Charge balance
|
||||
description = f'Traffic upgrade from {current_traffic}GB to {new_traffic}GB'
|
||||
success = await subtract_user_balance(db, user, final_price, description)
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to charge balance',
|
||||
)
|
||||
|
||||
# Create transaction
|
||||
await create_transaction(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
amount_kopeks=final_price,
|
||||
description=description,
|
||||
)
|
||||
|
||||
charged = final_price
|
||||
else:
|
||||
# Downgrade - no charge, no refund
|
||||
charged = 0
|
||||
|
||||
# Update subscription — delete TrafficPurchase records before resetting purchased_traffic_gb
|
||||
from sqlalchemy import delete as sql_delete
|
||||
|
||||
from app.database.models import TrafficPurchase
|
||||
|
||||
await db.execute(sql_delete(TrafficPurchase).where(TrafficPurchase.subscription_id == subscription.id))
|
||||
subscription.traffic_limit_gb = new_traffic
|
||||
subscription.purchased_traffic_gb = 0 # Reset purchased traffic on switch
|
||||
subscription.traffic_reset_at = None # Reset traffic reset date
|
||||
subscription.updated_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
|
||||
# Sync with RemnaWave
|
||||
try:
|
||||
subscription_service = SubscriptionService()
|
||||
if settings.is_multi_tariff_enabled():
|
||||
_should_create = not subscription.remnawave_uuid
|
||||
else:
|
||||
_should_create = not getattr(user, 'remnawave_uuid', None)
|
||||
|
||||
if _should_create:
|
||||
await subscription_service.create_remnawave_user(db, subscription)
|
||||
else:
|
||||
await subscription_service.update_remnawave_user(db, subscription)
|
||||
except Exception as e:
|
||||
logger.error('Failed to sync traffic switch with RemnaWave', error=e)
|
||||
from app.services.remnawave_retry_queue import remnawave_retry_queue
|
||||
|
||||
if hasattr(subscription, 'id') and hasattr(subscription, 'user_id'):
|
||||
remnawave_retry_queue.enqueue(
|
||||
subscription_id=subscription.id,
|
||||
user_id=subscription.user_id,
|
||||
action='create' if _should_create else 'update',
|
||||
)
|
||||
|
||||
await db.refresh(user)
|
||||
await db.refresh(subscription)
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'message': f'Traffic changed from {current_traffic}GB to {new_traffic}GB',
|
||||
'old_traffic_gb': current_traffic,
|
||||
'new_traffic_gb': new_traffic,
|
||||
'charged_kopeks': charged,
|
||||
'balance_kopeks': user.balance_kopeks,
|
||||
'balance_label': settings.format_price(user.balance_kopeks),
|
||||
}
|
||||
|
||||
|
||||
# ============ Traffic Refresh ============
|
||||
|
||||
# Rate limit: 1 request per 60 seconds per user
|
||||
TRAFFIC_REFRESH_RATE_LIMIT = 1
|
||||
TRAFFIC_REFRESH_RATE_WINDOW = 60 # seconds
|
||||
TRAFFIC_CACHE_TTL = 60 # Cache traffic data for 60 seconds
|
||||
|
||||
|
||||
@router.post('/refresh-traffic')
|
||||
async def refresh_traffic(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
subscription_id: int | None = QueryParam(None, description='Subscription ID for multi-tariff'),
|
||||
):
|
||||
"""
|
||||
Refresh traffic usage from RemnaWave panel.
|
||||
Rate limited to 1 request per 60 seconds.
|
||||
"""
|
||||
subscription = await resolve_subscription(db, user, subscription_id)
|
||||
if not subscription:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='No active subscription',
|
||||
)
|
||||
|
||||
# Use per-subscription key when subscription_id is available so that refreshing
|
||||
# Sub B is not blocked by a previous refresh of Sub A (multi-tariff mode).
|
||||
cache_suffix = f'{user.id}_{subscription_id}' if subscription_id is not None else str(user.id)
|
||||
|
||||
# Check rate limit
|
||||
is_limited = await RateLimitCache.is_rate_limited(
|
||||
cache_suffix,
|
||||
'traffic_refresh',
|
||||
TRAFFIC_REFRESH_RATE_LIMIT,
|
||||
TRAFFIC_REFRESH_RATE_WINDOW,
|
||||
)
|
||||
|
||||
if is_limited:
|
||||
# Check if we have cached data
|
||||
traffic_cache_key = cache_key('traffic', cache_suffix)
|
||||
cached_data = await cache.get(traffic_cache_key)
|
||||
|
||||
if cached_data:
|
||||
return {
|
||||
'success': True,
|
||||
'cached': True,
|
||||
'rate_limited': True,
|
||||
'retry_after_seconds': TRAFFIC_REFRESH_RATE_WINDOW,
|
||||
**cached_data,
|
||||
}
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail=f'Rate limited. Try again in {TRAFFIC_REFRESH_RATE_WINDOW} seconds.',
|
||||
headers={'Retry-After': str(TRAFFIC_REFRESH_RATE_WINDOW)},
|
||||
)
|
||||
|
||||
# Fetch traffic from RemnaWave
|
||||
try:
|
||||
remnawave_service = RemnaWaveService()
|
||||
|
||||
# Resolve panel UUID for traffic lookup
|
||||
_traffic_uuid = (
|
||||
subscription.remnawave_uuid
|
||||
if settings.is_multi_tariff_enabled() and subscription.remnawave_uuid
|
||||
else user.remnawave_uuid
|
||||
)
|
||||
if user.telegram_id and not settings.is_multi_tariff_enabled():
|
||||
traffic_stats = await remnawave_service.get_user_traffic_stats(user.telegram_id)
|
||||
elif _traffic_uuid:
|
||||
traffic_stats = await remnawave_service.get_user_traffic_stats_by_uuid(_traffic_uuid)
|
||||
else:
|
||||
traffic_stats = None
|
||||
|
||||
if not traffic_stats:
|
||||
# Return current database values if RemnaWave unavailable
|
||||
traffic_data = {
|
||||
'traffic_used_bytes': int((subscription.traffic_used_gb or 0) * (1024**3)),
|
||||
'traffic_used_gb': round(subscription.traffic_used_gb or 0, 2),
|
||||
'traffic_limit_bytes': int((subscription.traffic_limit_gb or 0) * (1024**3)),
|
||||
'traffic_limit_gb': subscription.traffic_limit_gb or 0,
|
||||
'traffic_used_percent': round(
|
||||
((subscription.traffic_used_gb or 0) / (subscription.traffic_limit_gb or 1)) * 100
|
||||
if subscription.traffic_limit_gb
|
||||
else 0,
|
||||
1,
|
||||
),
|
||||
'is_unlimited': (subscription.traffic_limit_gb or 0) == 0,
|
||||
}
|
||||
return {
|
||||
'success': True,
|
||||
'cached': False,
|
||||
'source': 'database',
|
||||
**traffic_data,
|
||||
}
|
||||
|
||||
# Update subscription with fresh data
|
||||
used_gb = traffic_stats.get('used_traffic_gb', 0)
|
||||
if abs((subscription.traffic_used_gb or 0) - used_gb) > 0.01:
|
||||
subscription.traffic_used_gb = used_gb
|
||||
subscription.updated_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
|
||||
# Calculate percentage
|
||||
limit_gb = subscription.traffic_limit_gb or 0
|
||||
if limit_gb > 0:
|
||||
percent = min(100, (used_gb / limit_gb) * 100)
|
||||
else:
|
||||
percent = 0
|
||||
|
||||
traffic_data = {
|
||||
'traffic_used_bytes': traffic_stats.get('used_traffic_bytes', 0),
|
||||
'traffic_used_gb': round(used_gb, 2),
|
||||
'traffic_limit_bytes': traffic_stats.get('traffic_limit_bytes', 0),
|
||||
'traffic_limit_gb': limit_gb,
|
||||
'traffic_used_percent': round(percent, 1),
|
||||
'is_unlimited': limit_gb == 0,
|
||||
'lifetime_used_bytes': traffic_stats.get('lifetime_used_traffic_bytes', 0),
|
||||
'lifetime_used_gb': round(traffic_stats.get('lifetime_used_traffic_gb', 0), 2),
|
||||
}
|
||||
|
||||
# Cache the result
|
||||
traffic_cache_key = cache_key('traffic', cache_suffix)
|
||||
await cache.set(traffic_cache_key, traffic_data, TRAFFIC_CACHE_TTL)
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'cached': False,
|
||||
'source': 'remnawave',
|
||||
**traffic_data,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Error refreshing traffic for user', user_id=user.id, error=e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to refresh traffic data',
|
||||
)
|
||||
@@ -20,6 +20,7 @@ from ..schemas.tickets import (
|
||||
TicketCreateRequest,
|
||||
TicketDetailResponse,
|
||||
TicketListResponse,
|
||||
TicketMediaItem,
|
||||
TicketMessageCreateRequest,
|
||||
TicketMessageResponse,
|
||||
TicketResponse,
|
||||
@@ -33,14 +34,23 @@ router = APIRouter(prefix='/tickets', tags=['Cabinet Tickets'])
|
||||
|
||||
def _message_to_response(message: TicketMessage) -> TicketMessageResponse:
|
||||
"""Convert TicketMessage to response."""
|
||||
raw_items = getattr(message, 'media_items', None) or None
|
||||
items = None
|
||||
if raw_items:
|
||||
try:
|
||||
items = [TicketMediaItem(**it) for it in raw_items]
|
||||
except (TypeError, KeyError, ValueError) as exc:
|
||||
logger.warning('Failed to parse media_items', message_id=message.id, error=str(exc))
|
||||
items = None
|
||||
return TicketMessageResponse(
|
||||
id=message.id,
|
||||
message_text=message.message_text or '',
|
||||
is_from_admin=message.is_from_admin,
|
||||
has_media=bool(message.media_file_id),
|
||||
has_media=bool(message.media_file_id) or bool(items),
|
||||
media_type=message.media_type,
|
||||
media_file_id=message.media_file_id,
|
||||
media_caption=message.media_caption,
|
||||
media_items=items,
|
||||
created_at=message.created_at,
|
||||
)
|
||||
|
||||
@@ -143,15 +153,30 @@ async def create_ticket(
|
||||
db.add(ticket)
|
||||
await db.flush()
|
||||
|
||||
# Resolve media payload
|
||||
items_payload = None
|
||||
primary_type = request.media_type
|
||||
primary_file_id = request.media_file_id
|
||||
primary_caption = request.media_caption
|
||||
if getattr(request, 'media_items', None):
|
||||
items_payload = [it.model_dump() for it in request.media_items]
|
||||
first = request.media_items[0]
|
||||
primary_type = first.type
|
||||
primary_file_id = first.file_id
|
||||
primary_caption = primary_caption or first.caption
|
||||
|
||||
# Create initial message with optional media
|
||||
has_media = bool(primary_file_id)
|
||||
message = TicketMessage(
|
||||
ticket_id=ticket.id,
|
||||
user_id=user.id,
|
||||
message_text=request.message,
|
||||
is_from_admin=False,
|
||||
media_type=request.media_type,
|
||||
media_file_id=request.media_file_id,
|
||||
media_caption=request.media_caption,
|
||||
has_media=has_media,
|
||||
media_type=primary_type if has_media else None,
|
||||
media_file_id=primary_file_id if has_media else None,
|
||||
media_caption=primary_caption if has_media else None,
|
||||
media_items=items_payload,
|
||||
created_at=datetime.now(UTC),
|
||||
)
|
||||
db.add(message)
|
||||
@@ -259,15 +284,30 @@ async def add_ticket_message(
|
||||
detail='Replies to this ticket are blocked',
|
||||
)
|
||||
|
||||
# Resolve media payload
|
||||
items_payload = None
|
||||
primary_type = request.media_type
|
||||
primary_file_id = request.media_file_id
|
||||
primary_caption = request.media_caption
|
||||
if getattr(request, 'media_items', None):
|
||||
items_payload = [it.model_dump() for it in request.media_items]
|
||||
first = request.media_items[0]
|
||||
primary_type = first.type
|
||||
primary_file_id = first.file_id
|
||||
primary_caption = primary_caption or first.caption
|
||||
|
||||
# Create message with optional media
|
||||
has_media = bool(primary_file_id)
|
||||
message = TicketMessage(
|
||||
ticket_id=ticket.id,
|
||||
user_id=user.id,
|
||||
message_text=request.message,
|
||||
is_from_admin=False,
|
||||
media_type=request.media_type,
|
||||
media_file_id=request.media_file_id,
|
||||
media_caption=request.media_caption,
|
||||
has_media=has_media,
|
||||
media_type=primary_type if has_media else None,
|
||||
media_file_id=primary_file_id if has_media else None,
|
||||
media_caption=primary_caption if has_media else None,
|
||||
media_items=items_payload,
|
||||
created_at=datetime.now(UTC),
|
||||
)
|
||||
db.add(message)
|
||||
@@ -286,8 +326,8 @@ async def add_ticket_message(
|
||||
ticket,
|
||||
request.message,
|
||||
db,
|
||||
media_file_id=request.media_file_id,
|
||||
media_type=request.media_type,
|
||||
media_file_id=primary_file_id,
|
||||
media_type=primary_type,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error('Error notifying admins about ticket reply from cabinet', error=e)
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
"""User-side endpoints для системы заданий с наградами."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.cabinet.dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
from app.cabinet.schemas.tasks import (
|
||||
ClaimRewardRequest,
|
||||
ClaimRewardResponse,
|
||||
UserTaskProgressResponse,
|
||||
UserTasksAvailabilityResponse,
|
||||
UserTasksListResponse,
|
||||
)
|
||||
from app.database.models import User
|
||||
from app.services import tasks_service
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/tasks', tags=['Cabinet Tasks'])
|
||||
|
||||
|
||||
@router.get('/availability', response_model=UserTasksAvailabilityResponse)
|
||||
async def get_tasks_availability(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Краткая информация для условного показа вкладки «Задания» в меню."""
|
||||
visible = await tasks_service.get_available_tasks_for_user(db, user)
|
||||
has_available = len(visible) > 0
|
||||
unclaimed = await tasks_service.count_completed_unclaimed(db, user_id=user.id)
|
||||
return UserTasksAvailabilityResponse(
|
||||
has_available_tasks=has_available,
|
||||
unclaimed_count=unclaimed,
|
||||
)
|
||||
|
||||
|
||||
@router.get('', response_model=UserTasksListResponse)
|
||||
async def list_my_tasks(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Список доступных заданий пользователя с их прогрессом."""
|
||||
visible = await tasks_service.get_available_tasks_for_user(db, user)
|
||||
|
||||
items: list[UserTaskProgressResponse] = []
|
||||
unclaimed_count = 0
|
||||
|
||||
for task, progress in visible:
|
||||
current_value = progress.current_value if progress else 0
|
||||
is_completed = progress.completed_at is not None if progress else False
|
||||
is_claimed = progress.claimed_at is not None if progress else False
|
||||
if is_completed and not is_claimed:
|
||||
unclaimed_count += 1
|
||||
percent = (
|
||||
int(min(current_value, task.target_value) / max(task.target_value, 1) * 100)
|
||||
if task.target_value
|
||||
else 0
|
||||
)
|
||||
items.append(
|
||||
UserTaskProgressResponse(
|
||||
task_id=task.id,
|
||||
title=task.title or {},
|
||||
description=task.description or {},
|
||||
icon=task.icon,
|
||||
task_type=task.task_type,
|
||||
target_value=task.target_value,
|
||||
target_meta=task.target_meta or {},
|
||||
reward_type=task.reward_type,
|
||||
reward_value=task.reward_value,
|
||||
reward_meta=task.reward_meta or {},
|
||||
allow_user_choice=task.allow_user_choice,
|
||||
level=task.level,
|
||||
parent_task_id=task.parent_task_id,
|
||||
current_value=current_value,
|
||||
percent=percent,
|
||||
is_completed=is_completed,
|
||||
is_claimed=is_claimed,
|
||||
completed_at=progress.completed_at if progress else None,
|
||||
claimed_at=progress.claimed_at if progress else None,
|
||||
reward_granted_meta=progress.reward_granted_meta if progress else None,
|
||||
)
|
||||
)
|
||||
|
||||
return UserTasksListResponse(
|
||||
items=items,
|
||||
has_unclaimed=unclaimed_count > 0,
|
||||
unclaimed_count=unclaimed_count,
|
||||
)
|
||||
|
||||
|
||||
@router.post('/{task_id}/claim', response_model=ClaimRewardResponse)
|
||||
async def claim_task_reward(
|
||||
task_id: int,
|
||||
request: ClaimRewardRequest,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Получить награду за выполненное задание."""
|
||||
try:
|
||||
granted = await tasks_service.claim_reward(
|
||||
db,
|
||||
user_id=user.id,
|
||||
task_id=task_id,
|
||||
chosen_subscription_id=request.chosen_subscription_id,
|
||||
chosen_reward_type=request.chosen_reward_type,
|
||||
)
|
||||
except ValueError as exc:
|
||||
msg = str(exc)
|
||||
# Маппим внутренние коды на HTTP-статусы
|
||||
not_found = {'progress_not_found', 'task_not_found', 'user_not_found'}
|
||||
bad_request = {
|
||||
'not_completed',
|
||||
'already_claimed',
|
||||
'user_not_eligible',
|
||||
'user_choice_not_allowed',
|
||||
'no_paid_subscription',
|
||||
'no_subscription_with_target_tariff',
|
||||
'chosen_subscription_invalid',
|
||||
'need_choose_subscription',
|
||||
'invalid_reward_amount',
|
||||
'invalid_reward_days',
|
||||
}
|
||||
if msg in not_found:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=msg) from exc
|
||||
if msg in bad_request or msg.startswith('unknown_reward_type'):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=msg) from exc
|
||||
logger.exception('claim_reward unexpected error', error=msg)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail='internal_error'
|
||||
) from exc
|
||||
|
||||
return ClaimRewardResponse(success=True, reward=granted)
|
||||
@@ -317,7 +317,8 @@ async def notify_user_balance_change(
|
||||
|
||||
async def notify_user_subscription_activated(
|
||||
user_id: int,
|
||||
expires_at: str,
|
||||
subscription_id: int | None = None,
|
||||
expires_at: str = '',
|
||||
tariff_name: str = '',
|
||||
) -> None:
|
||||
"""Уведомить пользователя об активации подписки."""
|
||||
@@ -325,6 +326,7 @@ async def notify_user_subscription_activated(
|
||||
user_id,
|
||||
{
|
||||
'type': 'subscription.activated',
|
||||
'subscription_id': subscription_id,
|
||||
'expires_at': expires_at,
|
||||
'tariff_name': tariff_name,
|
||||
},
|
||||
@@ -359,7 +361,8 @@ async def notify_user_subscription_expired(user_id: int) -> None:
|
||||
|
||||
async def notify_user_subscription_renewed(
|
||||
user_id: int,
|
||||
new_expires_at: str,
|
||||
subscription_id: int | None = None,
|
||||
new_expires_at: str = '',
|
||||
amount_kopeks: int = 0,
|
||||
) -> None:
|
||||
"""Уведомить пользователя о продлении подписки."""
|
||||
@@ -367,6 +370,7 @@ async def notify_user_subscription_renewed(
|
||||
user_id,
|
||||
{
|
||||
'type': 'subscription.renewed',
|
||||
'subscription_id': subscription_id,
|
||||
'new_expires_at': new_expires_at,
|
||||
'amount_kopeks': amount_kopeks,
|
||||
'amount_rubles': amount_kopeks / 100,
|
||||
|
||||
+60
-41
@@ -5,7 +5,6 @@ API роуты колеса удачи для пользователей.
|
||||
import math
|
||||
import time
|
||||
|
||||
import httpx
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
@@ -50,10 +49,22 @@ async def get_wheel_config(
|
||||
# Проверяем доступность
|
||||
availability = await wheel_service.check_availability(db, user)
|
||||
|
||||
# Проверяем наличие подписки
|
||||
from app.database.crud.subscription import get_subscription_by_user_id
|
||||
# Проверяем наличие подписки (multi-tariff aware)
|
||||
if settings.is_multi_tariff_enabled():
|
||||
from app.database.crud.subscription import get_active_subscriptions_by_user_id
|
||||
|
||||
subscription = await get_subscription_by_user_id(db, user.id)
|
||||
active_subs = await get_active_subscriptions_by_user_id(db, user.id)
|
||||
# Check if user has any active subscription for wheel access
|
||||
if active_subs:
|
||||
_non_daily = [s for s in active_subs if not getattr(s, 'is_daily_tariff', False)]
|
||||
_pool = _non_daily or active_subs
|
||||
subscription = max(_pool, key=lambda s: s.days_left)
|
||||
else:
|
||||
subscription = None
|
||||
else:
|
||||
from app.database.crud.subscription import get_subscription_by_user_id
|
||||
|
||||
subscription = await get_subscription_by_user_id(db, user.id)
|
||||
has_subscription = subscription is not None and subscription.is_active
|
||||
|
||||
prizes_display = [
|
||||
@@ -67,6 +78,14 @@ async def get_wheel_config(
|
||||
for p in prizes
|
||||
]
|
||||
|
||||
# Build eligible subscriptions for frontend picker
|
||||
eligible_subs_display = None
|
||||
if availability.eligible_subscriptions:
|
||||
eligible_subs_display = [
|
||||
{'id': s.id, 'tariff_name': s.tariff_name, 'days_left': s.days_left}
|
||||
for s in availability.eligible_subscriptions
|
||||
]
|
||||
|
||||
return WheelConfigResponse(
|
||||
is_enabled=config.is_enabled,
|
||||
name=config.name,
|
||||
@@ -84,6 +103,7 @@ async def get_wheel_config(
|
||||
user_balance_kopeks=availability.user_balance_kopeks,
|
||||
required_balance_kopeks=availability.required_balance_kopeks,
|
||||
has_subscription=has_subscription,
|
||||
eligible_subscriptions=eligible_subs_display,
|
||||
)
|
||||
|
||||
|
||||
@@ -115,7 +135,7 @@ async def spin_wheel(
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Крутить колесо удачи."""
|
||||
result = await wheel_service.spin(db, user, request.payment_type.value)
|
||||
result = await wheel_service.spin(db, user, request.payment_type.value, subscription_id=request.subscription_id)
|
||||
|
||||
if not result.success:
|
||||
# Возвращаем ошибку в теле ответа, а не HTTP exception
|
||||
@@ -220,10 +240,22 @@ async def create_stars_invoice(
|
||||
detail='Оплата Stars не включена',
|
||||
)
|
||||
|
||||
# Проверяем наличие активной подписки
|
||||
from app.database.crud.subscription import get_subscription_by_user_id
|
||||
# Проверяем наличие активной подписки (multi-tariff aware)
|
||||
if settings.is_multi_tariff_enabled():
|
||||
from app.database.crud.subscription import get_active_subscriptions_by_user_id
|
||||
|
||||
subscription = await get_subscription_by_user_id(db, user.id)
|
||||
active_subs = await get_active_subscriptions_by_user_id(db, user.id)
|
||||
# Check if user has any active subscription for Stars invoice
|
||||
if active_subs:
|
||||
_non_daily = [s for s in active_subs if not getattr(s, 'is_daily_tariff', False)]
|
||||
_pool = _non_daily or active_subs
|
||||
subscription = max(_pool, key=lambda s: s.days_left)
|
||||
else:
|
||||
subscription = None
|
||||
else:
|
||||
from app.database.crud.subscription import get_subscription_by_user_id
|
||||
|
||||
subscription = await get_subscription_by_user_id(db, user.id)
|
||||
if not subscription or not subscription.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
@@ -251,44 +283,31 @@ async def create_stars_invoice(
|
||||
|
||||
# Создаем invoice через Telegram Bot API
|
||||
try:
|
||||
bot_token = settings.BOT_TOKEN
|
||||
api_url = f'https://api.telegram.org/bot{bot_token}/createInvoiceLink'
|
||||
from aiogram.exceptions import TelegramAPIError
|
||||
from aiogram.types import LabeledPrice
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
api_url,
|
||||
json={
|
||||
'title': 'Колесо удачи',
|
||||
'description': f'Спин колеса удачи ({stars_amount} ⭐)',
|
||||
'payload': payload,
|
||||
'provider_token': '', # Пустой для Stars
|
||||
'currency': 'XTR',
|
||||
'prices': [{'label': 'Спин колеса', 'amount': stars_amount}],
|
||||
},
|
||||
from app.bot_factory import create_bot
|
||||
|
||||
async with create_bot() as bot:
|
||||
invoice_url = await bot.create_invoice_link(
|
||||
title='Колесо удачи',
|
||||
description=f'Спин колеса удачи ({stars_amount} ⭐)',
|
||||
payload=payload,
|
||||
provider_token='',
|
||||
currency='XTR',
|
||||
prices=[LabeledPrice(label='Спин колеса', amount=stars_amount)],
|
||||
)
|
||||
|
||||
result = response.json()
|
||||
logger.info('Created Stars invoice for wheel spin: user=, stars', user_id=user.id, stars_amount=stars_amount)
|
||||
|
||||
if not result.get('ok'):
|
||||
logger.error('Telegram API error', result=result)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Ошибка создания инвойса',
|
||||
)
|
||||
return StarsInvoiceResponse(
|
||||
invoice_url=invoice_url,
|
||||
stars_amount=stars_amount,
|
||||
)
|
||||
|
||||
invoice_url = result['result']
|
||||
logger.info(
|
||||
'Created Stars invoice for wheel spin: user=, stars', user_id=user.id, stars_amount=stars_amount
|
||||
)
|
||||
|
||||
return StarsInvoiceResponse(
|
||||
invoice_url=invoice_url,
|
||||
stars_amount=stars_amount,
|
||||
)
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
logger.error('HTTP error creating invoice', error=e)
|
||||
except TelegramAPIError as e:
|
||||
logger.error('Error creating invoice', error=e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Ошибка соединения с Telegram',
|
||||
detail='Ошибка создания инвойса',
|
||||
)
|
||||
|
||||
@@ -70,12 +70,11 @@ async def create_withdrawal(
|
||||
|
||||
# Уведомляем админов о запросе на вывод
|
||||
try:
|
||||
from aiogram import Bot
|
||||
|
||||
from app.bot_factory import create_bot
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
if getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) and settings.BOT_TOKEN:
|
||||
bot = Bot(token=settings.BOT_TOKEN)
|
||||
bot = create_bot()
|
||||
try:
|
||||
notification_service = AdminNotificationService(bot)
|
||||
await notification_service.send_withdrawal_request_notification(
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Apple In-App Purchase schemas for cabinet."""
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
|
||||
class ApplePurchaseRequest(BaseModel):
|
||||
"""Request to verify and credit an Apple IAP transaction."""
|
||||
|
||||
product_id: str = Field(..., description='Apple product ID (e.g. com.bitnet.vpnclient.topup.100)')
|
||||
transaction_id: str = Field(..., min_length=1, max_length=64, description='Apple StoreKit transaction ID')
|
||||
|
||||
@field_validator('transaction_id')
|
||||
@classmethod
|
||||
def transaction_id_must_be_numeric(cls, v: str) -> str:
|
||||
if not v.isdigit():
|
||||
raise ValueError('transaction_id must contain only digits')
|
||||
return v
|
||||
|
||||
|
||||
class ApplePurchaseResponse(BaseModel):
|
||||
"""Response indicating whether the purchase was successfully credited."""
|
||||
|
||||
success: bool
|
||||
+67
-15
@@ -8,27 +8,43 @@ from pydantic import BaseModel, EmailStr, Field
|
||||
class TelegramAuthRequest(BaseModel):
|
||||
"""Request for Telegram WebApp initData authentication."""
|
||||
|
||||
init_data: str = Field(..., description='Telegram WebApp initData string')
|
||||
init_data: str = Field(..., max_length=4096, description='Telegram WebApp initData string')
|
||||
campaign_slug: str | None = Field(
|
||||
None, min_length=1, max_length=64, pattern=r'^[a-zA-Z0-9_-]+$', description='Campaign slug from web link'
|
||||
)
|
||||
referral_code: str | None = Field(None, max_length=32, description='Referral code of inviter')
|
||||
referral_code: str | None = Field(
|
||||
None, max_length=32, pattern=r'^[a-zA-Z0-9_-]+$', description='Referral code of inviter'
|
||||
)
|
||||
|
||||
|
||||
class TelegramWidgetAuthRequest(BaseModel):
|
||||
"""Request for Telegram Login Widget authentication."""
|
||||
|
||||
id: int = Field(..., description='Telegram user ID')
|
||||
first_name: str = Field(..., description="User's first name")
|
||||
last_name: str | None = Field(None, description="User's last name")
|
||||
username: str | None = Field(None, description="User's username")
|
||||
photo_url: str | None = Field(None, description="User's photo URL")
|
||||
first_name: str = Field(..., max_length=64, description="User's first name")
|
||||
last_name: str | None = Field(None, max_length=64, description="User's last name")
|
||||
username: str | None = Field(None, max_length=32, description="User's username")
|
||||
photo_url: str | None = Field(None, max_length=512, description="User's photo URL")
|
||||
auth_date: int = Field(..., description='Unix timestamp of authentication')
|
||||
hash: str = Field(..., description='Authentication hash')
|
||||
hash: str = Field(..., min_length=64, max_length=64, description='Authentication hash')
|
||||
campaign_slug: str | None = Field(
|
||||
None, min_length=1, max_length=64, pattern=r'^[a-zA-Z0-9_-]+$', description='Campaign slug from web link'
|
||||
)
|
||||
referral_code: str | None = Field(None, max_length=32, description='Referral code of inviter')
|
||||
referral_code: str | None = Field(
|
||||
None, max_length=32, pattern=r'^[a-zA-Z0-9_-]+$', description='Referral code of inviter'
|
||||
)
|
||||
|
||||
|
||||
class TelegramOIDCAuthRequest(BaseModel):
|
||||
"""Request for Telegram OIDC authentication (popup flow)."""
|
||||
|
||||
id_token: str = Field(..., max_length=4096, description='JWT id_token from Telegram OIDC popup')
|
||||
campaign_slug: str | None = Field(
|
||||
None, min_length=1, max_length=64, pattern=r'^[a-zA-Z0-9_-]+$', description='Campaign slug from web link'
|
||||
)
|
||||
referral_code: str | None = Field(
|
||||
None, max_length=32, pattern=r'^[a-zA-Z0-9_-]+$', description='Referral code of inviter'
|
||||
)
|
||||
|
||||
|
||||
class EmailRegisterRequest(BaseModel):
|
||||
@@ -41,7 +57,7 @@ class EmailRegisterRequest(BaseModel):
|
||||
class EmailVerifyRequest(BaseModel):
|
||||
"""Request to verify email with token."""
|
||||
|
||||
token: str = Field(..., description='Email verification token')
|
||||
token: str = Field(..., max_length=2048, description='Email verification token')
|
||||
campaign_slug: str | None = Field(
|
||||
None, min_length=1, max_length=64, pattern=r'^[a-zA-Z0-9_-]+$', description='Campaign slug from web link'
|
||||
)
|
||||
@@ -51,7 +67,7 @@ class EmailLoginRequest(BaseModel):
|
||||
"""Request to login with email and password."""
|
||||
|
||||
email: EmailStr = Field(..., description='Email address')
|
||||
password: str = Field(..., description='Password')
|
||||
password: str = Field(..., min_length=1, max_length=128, description='Password')
|
||||
campaign_slug: str | None = Field(
|
||||
None, min_length=1, max_length=64, pattern=r'^[a-zA-Z0-9_-]+$', description='Campaign slug from web link'
|
||||
)
|
||||
@@ -60,7 +76,7 @@ class EmailLoginRequest(BaseModel):
|
||||
class RefreshTokenRequest(BaseModel):
|
||||
"""Request to refresh access token."""
|
||||
|
||||
refresh_token: str = Field(..., description='Refresh token')
|
||||
refresh_token: str = Field(..., max_length=2048, description='Refresh token')
|
||||
|
||||
|
||||
class PasswordForgotRequest(BaseModel):
|
||||
@@ -72,10 +88,16 @@ class PasswordForgotRequest(BaseModel):
|
||||
class PasswordResetRequest(BaseModel):
|
||||
"""Request to reset password with token."""
|
||||
|
||||
token: str = Field(..., description='Password reset token')
|
||||
token: str = Field(..., max_length=2048, description='Password reset token')
|
||||
password: str = Field(..., min_length=8, max_length=128, description='New password (min 8 chars)')
|
||||
|
||||
|
||||
class AutoLoginRequest(BaseModel):
|
||||
"""Request for auto-login from guest purchase success page."""
|
||||
|
||||
token: str = Field(..., max_length=2048, description='Auto-login JWT token')
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
"""Token pair response."""
|
||||
|
||||
@@ -112,8 +134,13 @@ class EmailRegisterStandaloneRequest(BaseModel):
|
||||
email: EmailStr = Field(..., description='Email address')
|
||||
password: str = Field(..., min_length=8, max_length=128, description='Password (min 8 chars)')
|
||||
first_name: str | None = Field(None, max_length=64, description='First name')
|
||||
language: str = Field('ru', description='Preferred language')
|
||||
referral_code: str | None = Field(None, max_length=32, description='Referral code of inviter')
|
||||
language: str = Field('ru', max_length=5, pattern=r'^[a-z]{2}$', description='Preferred language (ISO 639-1)')
|
||||
referral_code: str | None = Field(
|
||||
None, max_length=32, pattern=r'^[a-zA-Z0-9_-]+$', description='Referral code of inviter'
|
||||
)
|
||||
campaign_slug: str | None = Field(
|
||||
None, min_length=1, max_length=64, pattern=r'^[a-zA-Z0-9_-]+$', description='Campaign slug from web link'
|
||||
)
|
||||
|
||||
|
||||
class CampaignBonusInfo(BaseModel):
|
||||
@@ -154,7 +181,7 @@ class EmailChangeRequest(BaseModel):
|
||||
class EmailChangeVerifyRequest(BaseModel):
|
||||
"""Request to verify email change with code."""
|
||||
|
||||
code: str = Field(..., min_length=6, max_length=6, description='6-digit verification code')
|
||||
code: str = Field(..., min_length=6, max_length=6, pattern=r'^\d{6}$', description='6-digit verification code')
|
||||
|
||||
|
||||
class EmailChangeResponse(BaseModel):
|
||||
@@ -163,3 +190,28 @@ class EmailChangeResponse(BaseModel):
|
||||
message: str = Field(..., description='Success message')
|
||||
new_email: str = Field(..., description='New email address pending verification')
|
||||
expires_in_minutes: int = Field(..., description='Code expiration time in minutes')
|
||||
|
||||
|
||||
class DeepLinkTokenResponse(BaseModel):
|
||||
"""Response with deep link auth token."""
|
||||
|
||||
token: str = Field(..., description='One-time auth token')
|
||||
bot_username: str = Field(..., description='Bot username for deep link')
|
||||
expires_in: int = Field(..., description='Token TTL in seconds')
|
||||
|
||||
|
||||
class DeepLinkPollRequest(BaseModel):
|
||||
"""Request to poll deep link auth status.
|
||||
|
||||
Deep link auth is always for existing bot users — referral codes are not applicable here.
|
||||
Only campaign_slug is supported (campaign bonus can apply to existing users).
|
||||
"""
|
||||
|
||||
token: str = Field(..., min_length=16, max_length=128, description='Deep link auth token')
|
||||
campaign_slug: str | None = Field(
|
||||
None,
|
||||
min_length=1,
|
||||
max_length=64,
|
||||
pattern=r'^[a-zA-Z0-9_-]+$',
|
||||
description='Campaign slug captured from cabinet URL',
|
||||
)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class BalanceResponse(BaseModel):
|
||||
@@ -26,8 +26,7 @@ class TransactionResponse(BaseModel):
|
||||
created_at: datetime
|
||||
completed_at: datetime | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class TransactionListResponse(BaseModel):
|
||||
@@ -114,8 +113,7 @@ class PendingPaymentResponse(BaseModel):
|
||||
user_telegram_id: int | None = None
|
||||
user_username: str | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class PendingPaymentListResponse(BaseModel):
|
||||
@@ -137,3 +135,23 @@ class ManualCheckResponse(BaseModel):
|
||||
status_changed: bool = False
|
||||
old_status: str | None = None
|
||||
new_status: str | None = None
|
||||
|
||||
|
||||
class SavedCardResponse(BaseModel):
|
||||
"""Saved payment method (card) for recurrent payments."""
|
||||
|
||||
id: int
|
||||
method_type: str
|
||||
card_last4: str | None = None
|
||||
card_type: str | None = None
|
||||
title: str | None = None
|
||||
created_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class SavedCardsListResponse(BaseModel):
|
||||
"""List of saved payment methods."""
|
||||
|
||||
cards: list[SavedCardResponse]
|
||||
recurrent_enabled: bool = False
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
|
||||
# ============ Channel Types ============
|
||||
@@ -75,6 +75,27 @@ class BroadcastButtonsResponse(BaseModel):
|
||||
buttons: list[BroadcastButton]
|
||||
|
||||
|
||||
class CustomBroadcastButton(BaseModel):
|
||||
"""Custom button for broadcast message."""
|
||||
|
||||
label: str = Field(..., min_length=1, max_length=64)
|
||||
action_type: Literal['callback', 'url'] = 'callback'
|
||||
action_value: str = Field(..., min_length=1, max_length=256)
|
||||
|
||||
@field_validator('action_value')
|
||||
@classmethod
|
||||
def validate_action_value(cls, v: str, info) -> str:
|
||||
action_type = info.data.get('action_type', 'callback')
|
||||
if action_type == 'url':
|
||||
if not v.startswith(('https://', 'tg://')):
|
||||
raise ValueError('URL must start with https:// or tg://')
|
||||
elif action_type == 'callback':
|
||||
# Telegram API limits callback_data to 64 bytes
|
||||
if len(v.encode('utf-8')) > 64:
|
||||
raise ValueError('Callback data must be at most 64 bytes')
|
||||
return v
|
||||
|
||||
|
||||
# ============ Media ============
|
||||
|
||||
|
||||
@@ -95,7 +116,9 @@ class BroadcastCreateRequest(BaseModel):
|
||||
target: str
|
||||
message_text: str = Field(..., min_length=1, max_length=4000)
|
||||
selected_buttons: list[str] = Field(default_factory=lambda: ['home'])
|
||||
custom_buttons: list[CustomBroadcastButton] = Field(default_factory=list, max_length=10)
|
||||
media: BroadcastMediaRequest | None = None
|
||||
category: str = Field(default='system', pattern='^(system|news|promo)$')
|
||||
|
||||
|
||||
# ============ Response ============
|
||||
@@ -122,6 +145,9 @@ class BroadcastResponse(BaseModel):
|
||||
completed_at: datetime | None = None
|
||||
progress_percent: float = 0.0
|
||||
|
||||
# Category for user notification preference filtering
|
||||
category: str = 'system' # system|news|promo
|
||||
|
||||
# Email/channel fields
|
||||
channel: str = 'telegram' # telegram|email|both
|
||||
email_subject: str | None = None
|
||||
@@ -187,8 +213,12 @@ class CombinedBroadcastCreateRequest(BaseModel):
|
||||
# Telegram-specific fields
|
||||
message_text: str | None = Field(default=None, max_length=4000)
|
||||
selected_buttons: list[str] = Field(default_factory=lambda: ['home'])
|
||||
custom_buttons: list[CustomBroadcastButton] = Field(default_factory=list, max_length=10)
|
||||
media: BroadcastMediaRequest | None = None
|
||||
|
||||
# Broadcast category for user notification preference filtering
|
||||
category: str = Field(default='system', pattern='^(system|news|promo)$')
|
||||
|
||||
# Email-specific fields
|
||||
email_subject: str | None = Field(default=None, max_length=255)
|
||||
email_html_content: str | None = Field(default=None, max_length=100000)
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Schemas for admin bulk actions."""
|
||||
|
||||
from enum import StrEnum
|
||||
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
|
||||
class BulkActionType(StrEnum):
|
||||
EXTEND_SUBSCRIPTION = 'extend_subscription'
|
||||
CANCEL_SUBSCRIPTION = 'cancel_subscription'
|
||||
ACTIVATE_SUBSCRIPTION = 'activate_subscription'
|
||||
CHANGE_TARIFF = 'change_tariff'
|
||||
ADD_DAYS = 'add_days'
|
||||
ADD_TRAFFIC = 'add_traffic'
|
||||
ADD_BALANCE = 'add_balance'
|
||||
ASSIGN_PROMO_GROUP = 'assign_promo_group'
|
||||
GRANT_SUBSCRIPTION = 'grant_subscription'
|
||||
SET_DEVICES = 'set_devices'
|
||||
DELETE_SUBSCRIPTION = 'delete_subscription'
|
||||
DELETE_USER = 'delete_user'
|
||||
|
||||
|
||||
class BulkActionParams(BaseModel):
|
||||
days: int | None = Field(None, ge=1, le=3650)
|
||||
tariff_id: int | None = Field(None, gt=0)
|
||||
traffic_gb: int | None = Field(None, ge=1, le=10000)
|
||||
amount_kopeks: int | None = Field(None, ge=1, le=2_000_000_000)
|
||||
balance_description: str = Field(default='Массовое начисление баланса', max_length=500)
|
||||
promo_group_id: int | None = None
|
||||
device_limit: int | None = Field(None, ge=1, le=50)
|
||||
delete_from_panel: bool = Field(default=True)
|
||||
force_delete_active_paid: bool = Field(default=False)
|
||||
|
||||
|
||||
class BulkSubscriptionInfo(BaseModel):
|
||||
id: int
|
||||
tariff_id: int | None = None
|
||||
tariff_name: str | None = None
|
||||
status: str
|
||||
days_remaining: int
|
||||
traffic_used_gb: float = 0
|
||||
traffic_limit_gb: int = 0
|
||||
device_limit: int = 0
|
||||
|
||||
|
||||
class BulkExecuteRequest(BaseModel):
|
||||
action: BulkActionType
|
||||
user_ids: list[int] | None = Field(None, min_length=1, max_length=500)
|
||||
subscription_ids: list[int] | None = Field(None, min_length=1, max_length=2000)
|
||||
params: BulkActionParams = Field(default_factory=BulkActionParams)
|
||||
dry_run: bool = Field(default=False, description='Preview only, no mutations')
|
||||
|
||||
@model_validator(mode='after')
|
||||
def _exactly_one_target(self):
|
||||
has_users = self.user_ids is not None
|
||||
has_subs = self.subscription_ids is not None
|
||||
if has_users == has_subs:
|
||||
raise ValueError('Exactly one of user_ids or subscription_ids must be provided')
|
||||
return self
|
||||
|
||||
|
||||
class BulkUserResult(BaseModel):
|
||||
user_id: int
|
||||
subscription_id: int | None = None
|
||||
success: bool
|
||||
message: str
|
||||
username: str | None = None
|
||||
subscriptions: list[BulkSubscriptionInfo] | None = None
|
||||
|
||||
|
||||
class BulkExecuteResponse(BaseModel):
|
||||
action: str
|
||||
total: int
|
||||
success_count: int
|
||||
error_count: int
|
||||
skipped_count: int
|
||||
dry_run: bool
|
||||
results: list[BulkUserResult]
|
||||
@@ -0,0 +1,131 @@
|
||||
"""Schemas for cabinet gift subscription feature."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
|
||||
class GiftConfigSubOption(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
|
||||
|
||||
class GiftConfigTariffPeriod(BaseModel):
|
||||
days: int
|
||||
price_kopeks: int
|
||||
price_label: str
|
||||
original_price_kopeks: int | None = None
|
||||
discount_percent: int | None = None
|
||||
|
||||
|
||||
class GiftConfigTariff(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
description: str | None = None
|
||||
traffic_limit_gb: int
|
||||
device_limit: int
|
||||
periods: list[GiftConfigTariffPeriod]
|
||||
|
||||
|
||||
class GiftConfigPaymentMethod(BaseModel):
|
||||
method_id: str
|
||||
display_name: str
|
||||
description: str | None = None
|
||||
icon_url: str | None = None
|
||||
min_amount_kopeks: int | None = None
|
||||
max_amount_kopeks: int | None = None
|
||||
sub_options: list[GiftConfigSubOption] | None = None
|
||||
|
||||
|
||||
class GiftConfigResponse(BaseModel):
|
||||
is_enabled: bool
|
||||
tariffs: list[GiftConfigTariff] = []
|
||||
payment_methods: list[GiftConfigPaymentMethod] = []
|
||||
balance_kopeks: int = 0
|
||||
currency_symbol: str = '\u20bd'
|
||||
promo_group_name: str | None = None
|
||||
active_discount_percent: int | None = None
|
||||
active_discount_expires_at: datetime | None = None
|
||||
|
||||
|
||||
class GiftPurchaseRequest(BaseModel):
|
||||
tariff_id: int = Field(gt=0)
|
||||
period_days: int = Field(gt=0, le=3650)
|
||||
recipient_type: str | None = Field(default=None, pattern=r'^(email|telegram)$')
|
||||
recipient_value: str | None = Field(default=None, max_length=255)
|
||||
gift_message: str | None = Field(default=None, max_length=1000)
|
||||
payment_mode: str = Field(pattern=r'^(balance|gateway)$')
|
||||
payment_method: str | None = Field(default=None, max_length=50)
|
||||
|
||||
@model_validator(mode='after')
|
||||
def validate_payment(self) -> GiftPurchaseRequest:
|
||||
if self.payment_mode == 'gateway' and not self.payment_method:
|
||||
raise ValueError('payment_method is required for gateway mode')
|
||||
return self
|
||||
|
||||
|
||||
class GiftPurchaseResponse(BaseModel):
|
||||
status: str
|
||||
purchase_token: str
|
||||
payment_url: str | None = None
|
||||
warning: str | None = None
|
||||
|
||||
|
||||
class GiftPurchaseStatusResponse(BaseModel):
|
||||
status: str
|
||||
is_gift: bool = True
|
||||
is_code_only: bool = False
|
||||
purchase_token: str | None = None
|
||||
recipient_contact_value: str | None = None
|
||||
gift_message: str | None = None
|
||||
tariff_name: str | None = None
|
||||
period_days: int | None = None
|
||||
warning: str | None = None
|
||||
|
||||
|
||||
class PendingGiftResponse(BaseModel):
|
||||
token: str
|
||||
tariff_name: str | None = None
|
||||
period_days: int
|
||||
gift_message: str | None = None
|
||||
sender_display: str | None = None
|
||||
created_at: datetime | None = None
|
||||
|
||||
|
||||
class SentGiftResponse(BaseModel):
|
||||
"""A gift the current user has sent."""
|
||||
|
||||
token: str
|
||||
tariff_name: str | None = None
|
||||
period_days: int
|
||||
device_limit: int = 1
|
||||
status: str
|
||||
gift_recipient_value: str | None = None
|
||||
gift_message: str | None = None
|
||||
activated_by_username: str | None = None
|
||||
created_at: datetime | None = None
|
||||
|
||||
|
||||
class ReceivedGiftResponse(BaseModel):
|
||||
"""A gift the current user has received."""
|
||||
|
||||
token: str
|
||||
tariff_name: str | None = None
|
||||
period_days: int
|
||||
device_limit: int = 1
|
||||
status: str
|
||||
sender_display: str | None = None
|
||||
gift_message: str | None = None
|
||||
created_at: datetime | None = None
|
||||
|
||||
|
||||
class ActivateGiftRequest(BaseModel):
|
||||
code: str = Field(min_length=1, max_length=100)
|
||||
|
||||
|
||||
class ActivateGiftResponse(BaseModel):
|
||||
status: str
|
||||
tariff_name: str | None = None
|
||||
period_days: int | None = None
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Schemas for info pages in cabinet."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class InfoPageResponse(BaseModel):
|
||||
"""Full info page response."""
|
||||
|
||||
id: int
|
||||
slug: str
|
||||
title: dict[str, str]
|
||||
content: dict[str, str]
|
||||
page_type: str = 'page'
|
||||
is_active: bool
|
||||
sort_order: int
|
||||
icon: str | None = None
|
||||
replaces_tab: str | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime | None = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class InfoPageListItem(BaseModel):
|
||||
"""Compact info page for list views."""
|
||||
|
||||
id: int
|
||||
slug: str
|
||||
title: dict[str, str]
|
||||
page_type: str = 'page'
|
||||
is_active: bool
|
||||
sort_order: int
|
||||
icon: str | None = None
|
||||
replaces_tab: str | None = None
|
||||
updated_at: datetime | None = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class InfoPageCreateRequest(BaseModel):
|
||||
"""Request to create an info page."""
|
||||
|
||||
slug: str = Field(min_length=1, max_length=200, pattern=r'^[a-z0-9\-]+$')
|
||||
title: dict[str, str] = Field(default_factory=dict)
|
||||
content: dict[str, str] = Field(default_factory=dict)
|
||||
page_type: str = Field(default='page', pattern=r'^(page|faq)$')
|
||||
is_active: bool = True
|
||||
sort_order: int = 0
|
||||
icon: str | None = Field(None, max_length=50)
|
||||
replaces_tab: str | None = Field(None, pattern=r'^(faq|rules|privacy|offer)$')
|
||||
|
||||
|
||||
class InfoPageUpdateRequest(BaseModel):
|
||||
"""Request to update an info page."""
|
||||
|
||||
slug: str | None = Field(None, min_length=1, max_length=200, pattern=r'^[a-z0-9\-]+$')
|
||||
title: dict[str, str] | None = None
|
||||
content: dict[str, str] | None = None
|
||||
page_type: str | None = Field(None, pattern=r'^(page|faq)$')
|
||||
is_active: bool | None = None
|
||||
sort_order: int | None = None
|
||||
icon: str | None = Field(None, max_length=50)
|
||||
replaces_tab: str | None = Field(None, pattern=r'^(faq|rules|privacy|offer)$')
|
||||
|
||||
|
||||
class ReorderItem(BaseModel):
|
||||
"""Single item in a reorder request."""
|
||||
|
||||
id: int
|
||||
sort_order: int = Field(ge=0)
|
||||
|
||||
|
||||
class ReorderRequest(BaseModel):
|
||||
"""Request to bulk-reorder info pages."""
|
||||
|
||||
items: list[ReorderItem] = Field(..., min_length=1)
|
||||
@@ -0,0 +1,332 @@
|
||||
"""Schemas for news articles in cabinet.
|
||||
|
||||
Security notes:
|
||||
- featured_image_url is validated to only accept http/https schemes.
|
||||
- category_color is validated as a strict hex color (#RGB, #RRGGBB, etc.).
|
||||
- Slug is sanitized to only allow [a-zA-Z0-9_-].
|
||||
- Content is server-side sanitized to strip <script>, event handlers, and
|
||||
dangerous URI schemes as a defense-in-depth measure. The frontend also
|
||||
sanitizes via DOMPurify, but server-side sanitization protects against
|
||||
alternative consumers (mobile apps, RSS, email digests) and compromised
|
||||
frontends.
|
||||
"""
|
||||
|
||||
import re
|
||||
from datetime import datetime
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
|
||||
|
||||
# Pre-compiled regex for hex color validation (reused across validators)
|
||||
_HEX_COLOR_RE: re.Pattern[str] = re.compile(r'^#([0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$')
|
||||
|
||||
# Pre-compiled regex for collapsing repeated hyphens in slugs
|
||||
_MULTI_HYPHEN_RE: re.Pattern[str] = re.compile(r'-+')
|
||||
|
||||
# Maximum slug length (matches DB column constraint)
|
||||
_MAX_SLUG_LENGTH: int = 500
|
||||
|
||||
# Allowed URL schemes for user-supplied URLs (featured_image_url)
|
||||
_SAFE_URL_SCHEMES: frozenset[str] = frozenset({'http', 'https'})
|
||||
|
||||
# Cyrillic-to-Latin transliteration map for slug generation
|
||||
_TRANSLIT_MAP: dict[str, str] = {
|
||||
'а': 'a',
|
||||
'б': 'b',
|
||||
'в': 'v',
|
||||
'г': 'g',
|
||||
'д': 'd',
|
||||
'е': 'e',
|
||||
'ё': 'yo',
|
||||
'ж': 'zh',
|
||||
'з': 'z',
|
||||
'и': 'i',
|
||||
'й': 'y',
|
||||
'к': 'k',
|
||||
'л': 'l',
|
||||
'м': 'm',
|
||||
'н': 'n',
|
||||
'о': 'o',
|
||||
'п': 'p',
|
||||
'р': 'r',
|
||||
'с': 's',
|
||||
'т': 't',
|
||||
'у': 'u',
|
||||
'ф': 'f',
|
||||
'х': 'kh',
|
||||
'ц': 'ts',
|
||||
'ч': 'ch',
|
||||
'ш': 'sh',
|
||||
'щ': 'shch',
|
||||
'ъ': '',
|
||||
'ы': 'y',
|
||||
'ь': '',
|
||||
'э': 'e',
|
||||
'ю': 'yu',
|
||||
'я': 'ya',
|
||||
}
|
||||
|
||||
|
||||
def _slugify(title: str) -> str:
|
||||
"""Generate a URL-safe slug from a title, transliterating Cyrillic."""
|
||||
slug = title.lower()
|
||||
result: list[str] = []
|
||||
for ch in slug:
|
||||
if ch in _TRANSLIT_MAP:
|
||||
result.append(_TRANSLIT_MAP[ch])
|
||||
elif ch.isascii() and (ch.isalnum() or ch in '-_'):
|
||||
result.append(ch)
|
||||
elif ch == ' ':
|
||||
result.append('-')
|
||||
slug = ''.join(result)
|
||||
slug = _MULTI_HYPHEN_RE.sub('-', slug).strip('-')
|
||||
return slug[:_MAX_SLUG_LENGTH] or 'untitled'
|
||||
|
||||
|
||||
def _validate_hex_color(v: str) -> str:
|
||||
"""Validate a hex color string. Raises ValueError on invalid input."""
|
||||
if not _HEX_COLOR_RE.match(v):
|
||||
msg = 'category_color must be a valid hex color (e.g. #00e5a0)'
|
||||
raise ValueError(msg)
|
||||
return v
|
||||
|
||||
|
||||
def _validate_safe_url(v: str) -> str:
|
||||
"""Validate that a URL uses http or https scheme only.
|
||||
|
||||
Prevents javascript:, data:, vbscript:, and other dangerous URI schemes
|
||||
from being stored in the database and later rendered in <img> or <a> tags.
|
||||
"""
|
||||
try:
|
||||
parsed = urlparse(v)
|
||||
except Exception:
|
||||
msg = 'Invalid URL format'
|
||||
raise ValueError(msg)
|
||||
|
||||
if parsed.scheme not in _SAFE_URL_SCHEMES:
|
||||
msg = f'URL scheme must be http or https, got: {parsed.scheme!r}'
|
||||
raise ValueError(msg)
|
||||
|
||||
if not parsed.netloc:
|
||||
msg = 'URL must have a valid host'
|
||||
raise ValueError(msg)
|
||||
|
||||
return v
|
||||
|
||||
|
||||
# --- Server-side HTML content sanitization ---
|
||||
# Pre-compiled patterns for stripping the most dangerous HTML constructs.
|
||||
# This is a defense-in-depth measure: the frontend also sanitizes via DOMPurify.
|
||||
# Uses regex rather than a full HTML parser to avoid adding a new dependency.
|
||||
# Strips: <script>, <style>, <object>, <embed>, <applet>, <base>, <form>,
|
||||
# <link>, <meta> tags and all on* event handler attributes.
|
||||
_DANGEROUS_TAGS_RE: re.Pattern[str] = re.compile(
|
||||
r'<\s*/?\s*(script|style|object|embed|applet|base|form|link(?:\s)|meta)\b[^>]*>',
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
# Match on* event handler attributes, e.g. onclick="...", onerror='...'
|
||||
_EVENT_HANDLER_RE: re.Pattern[str] = re.compile(
|
||||
r'\s+on[a-z]+\s*=\s*(?:"[^"]*"|\'[^\']*\'|[^\s>]+)',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
# Match javascript:, vbscript:, data: in href/src attributes
|
||||
_DANGEROUS_URI_RE: re.Pattern[str] = re.compile(
|
||||
r'((?:href|src)\s*=\s*["\'])\s*(javascript|vbscript|data)\s*:',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _sanitize_html_content(html: str) -> str:
|
||||
"""Strip dangerous HTML constructs from article content.
|
||||
|
||||
This is NOT a replacement for DOMPurify on the frontend. It is a
|
||||
defense-in-depth layer that removes the most obvious XSS vectors
|
||||
at the storage boundary. A full HTML sanitizer (nh3, bleach) would
|
||||
be stronger, but this avoids adding a new dependency.
|
||||
"""
|
||||
if not html:
|
||||
return html
|
||||
|
||||
# 1. Remove dangerous tags and their content
|
||||
result = _DANGEROUS_TAGS_RE.sub('', html)
|
||||
|
||||
# Also strip <script>...</script> content (tag + body)
|
||||
result = re.sub(r'<script\b[^>]*>[\s\S]*?</script>', '', result, flags=re.IGNORECASE)
|
||||
result = re.sub(r'<style\b[^>]*>[\s\S]*?</style>', '', result, flags=re.IGNORECASE)
|
||||
|
||||
# 2. Remove event handler attributes
|
||||
result = _EVENT_HANDLER_RE.sub('', result)
|
||||
|
||||
# 3. Neutralize dangerous URI schemes in href/src
|
||||
result = _DANGEROUS_URI_RE.sub(r'\1about:', result)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
class NewsArticleResponse(BaseModel):
|
||||
"""Full news article response (detail view)."""
|
||||
|
||||
id: int
|
||||
title: str
|
||||
slug: str
|
||||
content: str
|
||||
excerpt: str | None
|
||||
category: str
|
||||
category_color: str
|
||||
tag: str | None
|
||||
category_id: int | None = None
|
||||
tag_id: int | None = None
|
||||
featured_image_url: str | None
|
||||
is_published: bool
|
||||
is_featured: bool
|
||||
published_at: datetime | None
|
||||
read_time_minutes: int
|
||||
views_count: int
|
||||
author_name: str | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime | None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class NewsArticleListItem(BaseModel):
|
||||
"""Compact news article for list views."""
|
||||
|
||||
id: int
|
||||
title: str
|
||||
slug: str
|
||||
excerpt: str | None
|
||||
category: str
|
||||
category_color: str
|
||||
tag: str | None
|
||||
category_id: int | None = None
|
||||
tag_id: int | None = None
|
||||
featured_image_url: str | None
|
||||
is_published: bool
|
||||
is_featured: bool
|
||||
published_at: datetime | None
|
||||
read_time_minutes: int
|
||||
views_count: int
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class NewsListResponse(BaseModel):
|
||||
"""Paginated list of news articles."""
|
||||
|
||||
items: list[NewsArticleListItem]
|
||||
total: int
|
||||
categories: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class NewsCreateRequest(BaseModel):
|
||||
"""Request to create a news article."""
|
||||
|
||||
title: str = Field(..., min_length=1, max_length=500)
|
||||
slug: str | None = Field(None, min_length=1, max_length=500)
|
||||
content: str = Field(default='', max_length=500_000)
|
||||
excerpt: str | None = Field(None, max_length=1000)
|
||||
category: str = Field(..., min_length=1, max_length=100)
|
||||
category_color: str = Field(default='#00e5a0', max_length=20)
|
||||
tag: str | None = Field(None, max_length=50)
|
||||
category_id: int | None = None
|
||||
tag_id: int | None = None
|
||||
featured_image_url: str | None = Field(None, max_length=2000)
|
||||
is_published: bool = False
|
||||
is_featured: bool = False
|
||||
read_time_minutes: int = Field(default=1, ge=1, le=60)
|
||||
|
||||
@field_validator('content')
|
||||
@classmethod
|
||||
def sanitize_content(cls, v: str) -> str:
|
||||
"""Strip dangerous HTML from article content (defense-in-depth)."""
|
||||
return _sanitize_html_content(v)
|
||||
|
||||
@field_validator('category_color')
|
||||
@classmethod
|
||||
def validate_hex_color(cls, v: str) -> str:
|
||||
return _validate_hex_color(v)
|
||||
|
||||
@field_validator('featured_image_url')
|
||||
@classmethod
|
||||
def validate_featured_image_url(cls, v: str | None) -> str | None:
|
||||
"""Reject javascript:, data:, and other dangerous URL schemes."""
|
||||
if v is not None:
|
||||
return _validate_safe_url(v)
|
||||
return v
|
||||
|
||||
@model_validator(mode='before')
|
||||
@classmethod
|
||||
def auto_generate_slug(cls, data: dict) -> dict: # type: ignore[type-arg]
|
||||
"""Generate slug from title when not explicitly provided."""
|
||||
if isinstance(data, dict) and not data.get('slug'):
|
||||
title = data.get('title', '')
|
||||
data['slug'] = _slugify(title) if isinstance(title, str) else 'untitled'
|
||||
return data
|
||||
|
||||
@field_validator('slug')
|
||||
@classmethod
|
||||
def sanitize_slug(cls, v: str | None) -> str | None:
|
||||
"""Ensure slug contains only URL-safe characters, transliterating Cyrillic."""
|
||||
if v is not None:
|
||||
return _slugify(v)
|
||||
return v
|
||||
|
||||
|
||||
class NewsUpdateRequest(BaseModel):
|
||||
"""Request to update a news article."""
|
||||
|
||||
title: str | None = Field(None, min_length=1, max_length=500)
|
||||
slug: str | None = Field(None, min_length=1, max_length=500)
|
||||
content: str | None = Field(None, max_length=500_000)
|
||||
excerpt: str | None = None
|
||||
category: str | None = Field(None, min_length=1, max_length=100)
|
||||
category_color: str | None = Field(None, max_length=20)
|
||||
tag: str | None = None
|
||||
category_id: int | None = None
|
||||
tag_id: int | None = None
|
||||
featured_image_url: str | None = Field(None, max_length=2000)
|
||||
is_published: bool | None = None
|
||||
is_featured: bool | None = None
|
||||
read_time_minutes: int | None = Field(None, ge=1, le=60)
|
||||
|
||||
@field_validator('content')
|
||||
@classmethod
|
||||
def sanitize_content(cls, v: str | None) -> str | None:
|
||||
"""Strip dangerous HTML from article content (defense-in-depth)."""
|
||||
if v is not None:
|
||||
return _sanitize_html_content(v)
|
||||
return v
|
||||
|
||||
@field_validator('category_color')
|
||||
@classmethod
|
||||
def validate_hex_color(cls, v: str | None) -> str | None:
|
||||
if v is not None:
|
||||
return _validate_hex_color(v)
|
||||
return v
|
||||
|
||||
@field_validator('featured_image_url')
|
||||
@classmethod
|
||||
def validate_featured_image_url(cls, v: str | None) -> str | None:
|
||||
"""Reject javascript:, data:, and other dangerous URL schemes."""
|
||||
if v is not None:
|
||||
return _validate_safe_url(v)
|
||||
return v
|
||||
|
||||
@field_validator('slug')
|
||||
@classmethod
|
||||
def sanitize_slug(cls, v: str | None) -> str | None:
|
||||
"""Ensure slug contains only URL-safe characters, transliterating Cyrillic."""
|
||||
if v is not None:
|
||||
return _slugify(v)
|
||||
return v
|
||||
|
||||
|
||||
class NewsToggleResponse(BaseModel):
|
||||
"""Response after toggling publish/featured status."""
|
||||
|
||||
id: int
|
||||
is_published: bool
|
||||
is_featured: bool
|
||||
published_at: datetime | None
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Schemas for news categories."""
|
||||
|
||||
import re
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
|
||||
_HEX_COLOR_RE: re.Pattern[str] = re.compile(r'^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$')
|
||||
|
||||
|
||||
class NewsCategoryCreate(BaseModel):
|
||||
"""Request to create a news category."""
|
||||
|
||||
name: str = Field(..., min_length=1, max_length=100)
|
||||
color: str = Field(default='#00e5a0', max_length=20)
|
||||
|
||||
@field_validator('color')
|
||||
@classmethod
|
||||
def validate_color(cls, v: str) -> str:
|
||||
if not _HEX_COLOR_RE.match(v):
|
||||
msg = 'Invalid hex color'
|
||||
raise ValueError(msg)
|
||||
return v
|
||||
|
||||
|
||||
class NewsCategoryUpdate(BaseModel):
|
||||
"""Request to update a news category."""
|
||||
|
||||
name: str | None = Field(None, min_length=1, max_length=100)
|
||||
color: str | None = Field(None, max_length=20)
|
||||
|
||||
@field_validator('color')
|
||||
@classmethod
|
||||
def validate_color(cls, v: str | None) -> str | None:
|
||||
if v is not None and not _HEX_COLOR_RE.match(v):
|
||||
msg = 'Invalid hex color'
|
||||
raise ValueError(msg)
|
||||
return v
|
||||
|
||||
|
||||
class NewsCategoryResponse(BaseModel):
|
||||
"""News category response."""
|
||||
|
||||
id: int
|
||||
name: str
|
||||
color: str
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
@@ -0,0 +1,17 @@
|
||||
"""Schemas for news media upload responses."""
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class NewsMediaUploadResponse(BaseModel):
|
||||
"""Response returned after a successful media upload."""
|
||||
|
||||
url: str
|
||||
thumbnail_url: str | None = None
|
||||
media_type: Literal['image', 'video']
|
||||
filename: str
|
||||
size_bytes: int
|
||||
width: int | None = None
|
||||
height: int | None = None
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Schemas for news tags."""
|
||||
|
||||
import re
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
|
||||
_HEX_COLOR_RE: re.Pattern[str] = re.compile(r'^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$')
|
||||
|
||||
|
||||
class NewsTagCreate(BaseModel):
|
||||
"""Request to create a news tag."""
|
||||
|
||||
name: str = Field(..., min_length=1, max_length=50)
|
||||
color: str = Field(default='#94a3b8', max_length=20)
|
||||
|
||||
@field_validator('color')
|
||||
@classmethod
|
||||
def validate_color(cls, v: str) -> str:
|
||||
if not _HEX_COLOR_RE.match(v):
|
||||
msg = 'Invalid hex color'
|
||||
raise ValueError(msg)
|
||||
return v
|
||||
|
||||
|
||||
class NewsTagUpdate(BaseModel):
|
||||
"""Request to update a news tag."""
|
||||
|
||||
name: str | None = Field(None, min_length=1, max_length=50)
|
||||
color: str | None = Field(None, max_length=20)
|
||||
|
||||
@field_validator('color')
|
||||
@classmethod
|
||||
def validate_color(cls, v: str | None) -> str | None:
|
||||
if v is not None and not _HEX_COLOR_RE.match(v):
|
||||
msg = 'Invalid hex color'
|
||||
raise ValueError(msg)
|
||||
return v
|
||||
|
||||
|
||||
class NewsTagResponse(BaseModel):
|
||||
"""News tag response."""
|
||||
|
||||
id: int
|
||||
name: str
|
||||
color: str
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
@@ -10,6 +10,7 @@ class ReferralInfoResponse(BaseModel):
|
||||
|
||||
referral_code: str
|
||||
referral_link: str
|
||||
bot_referral_link: str = ''
|
||||
total_referrals: int
|
||||
active_referrals: int
|
||||
total_earnings_kopeks: int
|
||||
@@ -80,4 +81,5 @@ class ReferralTermsResponse(BaseModel):
|
||||
first_topup_bonus_rubles: float
|
||||
inviter_bonus_kopeks: int
|
||||
inviter_bonus_rubles: float
|
||||
max_commission_payments: int = 0
|
||||
partner_section_visible: bool = True
|
||||
|
||||
@@ -47,11 +47,9 @@ class ServerInfo(BaseModel):
|
||||
"""Server hardware info."""
|
||||
|
||||
cpu_cores: int
|
||||
cpu_physical_cores: int
|
||||
memory_total: int
|
||||
memory_used: int
|
||||
memory_free: int
|
||||
memory_available: int
|
||||
uptime_seconds: int
|
||||
|
||||
|
||||
@@ -108,22 +106,22 @@ class NodeInfo(BaseModel):
|
||||
is_disabled: bool
|
||||
is_node_online: bool
|
||||
is_xray_running: bool
|
||||
users_online: int | None = None
|
||||
users_online: int = 0
|
||||
traffic_used_bytes: int | None = None
|
||||
traffic_limit_bytes: int | None = None
|
||||
last_status_change: datetime | None = None
|
||||
last_status_message: str | None = None
|
||||
xray_uptime: str | None = None
|
||||
xray_uptime: int = 0
|
||||
is_traffic_tracking_active: bool = False
|
||||
traffic_reset_day: int | None = None
|
||||
notify_percent: int | None = None
|
||||
consumption_multiplier: float = 1.0
|
||||
cpu_count: int | None = None
|
||||
cpu_model: str | None = None
|
||||
total_ram: str | None = None
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
provider_uuid: str | None = None
|
||||
versions: dict[str, str] | None = None
|
||||
system: dict[str, Any] | None = None
|
||||
active_plugin_uuid: str | None = None
|
||||
|
||||
|
||||
class NodesListResponse(BaseModel):
|
||||
|
||||
@@ -48,6 +48,7 @@ class SubscriptionData(BaseModel):
|
||||
hide_subscription_link: bool = False # Скрывать ли отображение ссылки (но кнопки работают)
|
||||
is_active: bool
|
||||
is_expired: bool
|
||||
is_limited: bool = False
|
||||
traffic_purchases: list[TrafficPurchaseInfo] = []
|
||||
# Daily tariff fields
|
||||
is_daily: bool = False
|
||||
@@ -87,6 +88,10 @@ class RenewalRequest(BaseModel):
|
||||
"""Request to renew subscription."""
|
||||
|
||||
period_days: int = Field(..., ge=1, le=3650, description='Renewal period in days')
|
||||
subscription_id: int | None = Field(
|
||||
default=None,
|
||||
description='ID of subscription to renew (required in multi-tariff mode)',
|
||||
)
|
||||
|
||||
|
||||
class TrafficPackageResponse(BaseModel):
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user