Compare commits
444 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 4c21e3a2a9 | |||
| c4ea17507f | |||
| 19d30dd292 | |||
| 1e930af7d4 | |||
| 833227a717 | |||
| 04562fd7e7 | |||
| 4984f20e8f | |||
| fe393d2ca6 | |||
| 34c82c3488 | |||
| 00a7db2690 | |||
| 7fb839aef6 | |||
| 6713b34978 | |||
| 7a7fb71bf5 | |||
| 1c89bd8b2a | |||
| 050be0fe0e | |||
| 297240f8ef | |||
| 2b16a00464 | |||
| b31a893b13 | |||
| b9e17be855 | |||
| 900be65617 | |||
| 53a67d7573 | |||
| 7d28f5516a | |||
| 849b3a7034 | |||
| 374907b607 | |||
| 9f35088788 | |||
| fd139b28a2 | |||
| de6f80694b | |||
| b87535ad48 | |||
| 6da61d7951 | |||
| 968d147046 | |||
| 93a55df4c0 | |||
| 82592784d0 | |||
| acfa4b3c2e | |||
| a7a18dd0d1 | |||
| 1cfede28b7 | |||
| c8ef808539 | |||
| b8857e789e | |||
| 5f2d855702 | |||
| 0466528925 | |||
| e4a6aad621 | |||
| 2cec8dc4a4 | |||
| 667291a2dc | |||
| eff74bed5b | |||
| 8f29e2eee2 | |||
| 9d7a557ef0 | |||
| 2664b4956d | |||
| f7caf0de70 | |||
| 0c1dc580c6 | |||
| f867989557 | |||
| 467dea1315 | |||
| da40d5662d | |||
| 7b4e9488f6 | |||
| d7a9d2bfba | |||
| 531d5cff30 | |||
| 8ee97ba1ba | |||
| 0e8c61a776 | |||
| 9582758d1c | |||
| f204b67880 | |||
| db61365e11 | |||
| bc1e6fb22c | |||
| 64ee0459e4 | |||
| d855e9e47f | |||
| dc7b8dc72a | |||
| 57b5216306 | |||
| e249bff5d6 | |||
| 85489cff3f | |||
| 57aaca82f5 | |||
| ff1c8722c9 | |||
| 018f18fa0c | |||
| eaeee7a765 | |||
| 618c936ac9 | |||
| 0ed6397fa9 | |||
| dce9eaa597 | |||
| 628a99e7aa | |||
| 4d74afd711 | |||
| e2c9aab7ba | |||
| e23d69fcec | |||
| 1afcd84e0e | |||
| e850419f10 | |||
| 360d579415 | |||
| c67f55fe0d | |||
| 310edae013 | |||
| 8fb97d9359 | |||
| d33c5d6c07 | |||
| 2449a5cbbe | |||
| e5f29eb041 | |||
| 31c7e2e9c1 | |||
| e25fcfc6ef | |||
| b2cf4aaa91 | |||
| 1256ddcd1a | |||
| 58faf9eaec | |||
| ded5c899f7 | |||
| fa7de589c1 | |||
| 69868418e5 | |||
| 062c4865db | |||
| 1dfa78013c | |||
| 60c97f778b | |||
| 83c6db4834 | |||
| ed3ae14d0c | |||
| 69a9899d40 | |||
| e32e2f779d | |||
| ccb61d6473 | |||
| 2fab50c340 | |||
| 69b5ca0670 | |||
| 06c3996da4 | |||
| faba3a8ed6 | |||
| 4c72058d4a | |||
| 9c004791f2 | |||
| cdcabee80d | |||
| 9ae5d7bb60 | |||
| efdf2a3189 | |||
| 2a90f871b9 | |||
| b47678cfb0 | |||
| d708365aca | |||
| 2cdbbc09ba | |||
| 4eaedd33bf | |||
| f605d8a39c | |||
| cc5be7059f | |||
| 739ba2986f | |||
| f52e6aedac | |||
| 256cbfcadf | |||
| dc3d22f52d | |||
| 7ea8fbd584 | |||
| b96e819da4 | |||
| 399ca86561 | |||
| 200f91ef17 | |||
| 59f0e42be7 | |||
| 2044cecc6e | |||
| ffffccb389 | |||
| 28d263fc8d | |||
| bfef7cc629 | |||
| a696896d2c | |||
| fd2e419e8e | |||
| aaf0263fda | |||
| d4d5031cc2 | |||
| b2d7abf5bd | |||
| 0f9f843236 | |||
| cab425cfac | |||
| 0da0c5547d | |||
| 988d0e5c2f | |||
| 1ce91749aa | |||
| 731eb24364 | |||
| a15403b8b6 |
@@ -16,6 +16,10 @@ __pycache__/
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
.venv/
|
||||
tests/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
|
||||
@@ -369,6 +369,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
|
||||
|
||||
@@ -492,6 +494,11 @@ 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
|
||||
# Отключить пополнение баланса через поддержку
|
||||
@@ -630,6 +637,13 @@ FREEKASSA_WEBHOOK_PORT=8088
|
||||
FREEKASSA_PAYMENT_SYSTEM_ID=
|
||||
# Использовать API для создания заказов (обязательно для NSPK СБП)
|
||||
FREEKASSA_USE_API=false
|
||||
# Раздельные методы оплаты (отображаются как отдельные кнопки)
|
||||
# СБП (QR код) — i=44
|
||||
FREEKASSA_SBP_ENABLED=false
|
||||
FREEKASSA_SBP_DISPLAY_NAME=СБП (QR код)
|
||||
# Карты РФ — i=36
|
||||
FREEKASSA_CARD_ENABLED=false
|
||||
FREEKASSA_CARD_DISPLAY_NAME=Карта РФ
|
||||
|
||||
# ===== KASSA AI (api.fk.life) =====
|
||||
# Отдельная платёжная система, работает параллельно с Freekassa
|
||||
@@ -648,6 +662,20 @@ 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=
|
||||
|
||||
# ===== WATA =====
|
||||
WATA_ENABLED=false
|
||||
WATA_BASE_URL=https://api.wata.pro
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "3.19.0"
|
||||
".": "3.32.3"
|
||||
}
|
||||
|
||||
+831
@@ -1,5 +1,836 @@
|
||||
# Changelog
|
||||
|
||||
## [3.32.3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.32.2...v3.32.3) (2026-03-14)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* campaign registration, revenue calculation, backup restore, autopay errors, referral links ([7648707](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7648707ca26d6cd2703b50b0fe8c4697e6155784))
|
||||
* implement case-insensitive email checks in authentication and user retrieval ([7e466ef](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7e466ef464ce918d885bd6297d1e605a633fd43e))
|
||||
* implement case-insensitive email checks in authentication and user retrieval ([ebee834](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ebee8348ca338b9be5f044537e5e2b4740dc6441))
|
||||
* **payment:** prioritize saved cart after topup over expired auto-extend ([28321df](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/28321df4d274269536efebcf3da870f2e7d07d90))
|
||||
* refresh CLASSIC_PERIOD_PRICES when admin changes PRICE_*_DAYS or SALES_MODE ([6adf70b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6adf70b2da6e2250cc8e909dbb497b355302e72f))
|
||||
|
||||
## [3.32.2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.32.1...v3.32.2) (2026-03-13)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add nested selectinload and referrer eager loading to prevent MissingGreenlet ([3306e02](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3306e029021c396e13774a205225beece4fbbcfb))
|
||||
* add selectinload to user lock queries to prevent MissingGreenlet ([5442f28](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5442f288d4c6c3973dd92ac141172a9f0e53a28f))
|
||||
* silence PARTICIPANT_ID_INVALID error in channel subscription check ([14dceaa](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/14dceaa39ff9faa1c9205483653014a1c5ac73fb))
|
||||
|
||||
## [3.32.1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.32.0...v3.32.1) (2026-03-13)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* invalid ISO date format in node usage stats API call ([69a38da](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/69a38dad259bd05f4658e1014ce0bd73fc2e2ac5))
|
||||
* platega webhook ID fallback for SBP and card payments ([aa3459b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/aa3459b8463ce0a54b7709aa3547b2337064fa26))
|
||||
* resolve MissingGreenlet in switch_tariff endpoint ([4d695be](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4d695be7d51adda40fa72c00c349fb0e1ec4acd2))
|
||||
|
||||
## [3.32.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.31.0...v3.32.0) (2026-03-13)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* add _calculate_servers_price (fixed fallback) and _calculate_traffic_price ([88369ee](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/88369eec5047e733d26d2450a74abd0d600b2e1b))
|
||||
* add CLASSIC_PERIOD_PRICES to config ([c3bb63f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c3bb63ffed6e0b684c322aa51d70ab7e71c8eb6b))
|
||||
* add LIMITED subscription status and preserve extra devices on tariff switch ([8f43452](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8f434525eb14618e3c3e26261d443b1632c111bb))
|
||||
* add RenewalPricing dataclass and PricingEngine discount methods ([83ca51c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/83ca51cd5b040e747c6db904dde0f3a5c59f480f))
|
||||
* implement calculate_renewal_price with tariff and classic modes ([02e5401](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/02e5401327786c9dfe5ae7d4c89624c9455aa53e))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add missing settings import in admin_users tariff switch ([b2ee6c7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b2ee6c766a1fb0c9a701684a6349b970d12f5e2e))
|
||||
* add per-category discounts and months multiplier to classic mode ([1660b24](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1660b24f9844374bbd156f9202a8e1550a6beb49))
|
||||
* add period_days whitelist validation and type annotations ([18e2e78](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/18e2e7841a6d614263e7c87db5964916ec869a9d))
|
||||
* address 6-agent review findings for PricingEngine ([c9f2dff](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c9f2dffabf6369df360c5f9ad7a12c0415026310))
|
||||
* address review findings from 5-agent audit ([08bea70](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/08bea704ded78102dce29deac8da95c4e4b9d815))
|
||||
* atomicity refactor, review fixes, and DELETED recovery logging ([ba54819](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ba54819f9cd7f60914dd472b68885683f435db4e))
|
||||
* change None assignment to [] + add "or []" guards at all 5 call sites. ([a5fbd74](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a5fbd7400f828824c5baa520bdaf06023b4caf70))
|
||||
* downgrade known-harmless RemnaWave 400s to warning level ([0419781](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/04197817fede058dc4688dce2f9877f0fc2a7f7f))
|
||||
* guard rollback on commit flag, add flush to promo_offer_log ([b7775b7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b7775b72dc7a1b3d18f179c2f247fe9f47023347))
|
||||
* handle legacy telegram_id in YooKassa webhook recovery metadata ([815a1d9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/815a1d9136f39b932d4b369aec9d67034d6785d9))
|
||||
* harden remnawave API error handling and YooKassa user cross-validation ([585baaf](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/585baaf63c9f535e5311a32085d0187d8c854001))
|
||||
* harden YooKassa webhook recovery user lookup ([d35ee58](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d35ee58aa6f3edc6a9e8ab43025569262acf64a2))
|
||||
* payment providers — lock_user_for_update + commit=False atomicity ([b4ef52c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b4ef52caa4b324eded8e6c6cb715a09ad59140c1))
|
||||
* prevent balance loss on auto-purchase for DISABLED subscriptions and fix WATA expiration ([266340a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/266340aad195995f208ed82fc11e0909d34898f4))
|
||||
* pricing audit — display/charge parity, race conditions, balance locks ([ae99358](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ae99358ae9f35a25370ab127d98a0b630a08e3f2))
|
||||
* resolve merge conflict with dev (accept calc_device_limit_on_tariff_switch) ([ba049ca](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ba049ca017e004c25f8738f01b2d5f329a35bb5e))
|
||||
* user deletion FK error + connected_squads None TypeError ([a5fbd74](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a5fbd7400f828824c5baa520bdaf06023b4caf70))
|
||||
|
||||
|
||||
### Refactoring
|
||||
|
||||
* add typed breakdowns + module-level singleton to PricingEngine ([b551def](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b551def3402e2bf762406fb0b374360958231bb3))
|
||||
* extract shared formatting helpers into app/utils/formatting.py ([5e9a462](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5e9a462261e46ee649de481266a821fd6793bf2e))
|
||||
* make finalize() accept both old and new pricing types ([3efa24b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3efa24bab3a2bd1d31103b134e502c10af8e41e1))
|
||||
* migrate admin user price calculation to PricingEngine ([49c0f3f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/49c0f3fc10d27092961601cf7f6a780fb56885fa))
|
||||
* migrate all callers to pricing_engine singleton + fix miniapp discount ([e24b911](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e24b911283bf4cbee7b18d3e49c935217e4a2863))
|
||||
* migrate bot renewal display to PricingEngine ([ce82c2c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ce82c2c00988542ac73dc9d2e811711ea9cefebe))
|
||||
* migrate bot renewal execute to PricingEngine ([acf27a1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/acf27a102308d38676b2ccaef78016b56a80935d))
|
||||
* migrate cabinet renewal display + execute to PricingEngine ([28fc36d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/28fc36dca41b626430baf823274561269023ac59))
|
||||
* migrate cart auto-purchase to PricingEngine (fresh calc) ([bd2e93a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bd2e93a6a5076341b104f7dba2b7fc5fdb587e66))
|
||||
* migrate menu.py renewal pricing to PricingEngine ([652b6da](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/652b6dabde014d19075f13198dd06e5fb8bef380))
|
||||
* migrate miniapp renewal display + execute to PricingEngine ([cb43aca](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/cb43acab3194bbf9b6e2c04ca0254ba5b2571b2d))
|
||||
* migrate recurrent and monitoring services to PricingEngine ([978f68e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/978f68e7be42b0faf92d9ac5bc0bbaa2022ac95b))
|
||||
* migrate remaining callers to PricingEngine + cleanup dead CRUD ([75dbd2b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/75dbd2b4fcc8ab14ac44d915bf55b10406544bb1))
|
||||
* migrate try_auto_extend_expired to PricingEngine ([e6ebc67](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e6ebc6722d826d291156e6bff3bf86000b32b783))
|
||||
* remove dead pricing code and fix miniapp classic mode ([c9a9816](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c9a9816daa15a4534a3990822543eeefe1a1631b))
|
||||
* unify first-purchase discount algorithm with PricingEngine ([fe4e6ac](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fe4e6acb5391d0797ea01281eeb2e2ea59a0070f))
|
||||
|
||||
## [3.31.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.30.0...v3.31.0) (2026-03-12)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* add show_in_gift toggle for tariffs in admin panel ([cb5126a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/cb5126aff8c15938a59ea9c4f8e605b250b05dbc))
|
||||
* add sync-squads endpoint for bulk updating subscription squads in Remnawave ([b1e2146](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b1e2146254255586b5be9bd894ac4d113a0a8cf5))
|
||||
* auto-sync squads to Remnawave when admin updates tariff ([076290e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/076290e0c1d81b610a7653d6b64ed218e0f124b4))
|
||||
* referral links now point to web cabinet instead of bot ([12ae871](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/12ae871653399bc4ccd23b6394878e814ce9cd75))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add post_update=True to User.referrals self-referential relationship ([9957259](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/995725988150f31d193631120a4692e88fa4dd57))
|
||||
* add Telegram Stars payment support for gift subscriptions ([5424d8c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5424d8c31484873b0adc0bc980abdc51ee81325b))
|
||||
* correct skipped_count in sync-squads circuit breaker and simplify ternary ([8a362db](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8a362db7833b5b7793b5b52345d227cb84cbc39e))
|
||||
* preserve purchased devices when admin changes user tariff ([bf72f24](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bf72f241d81e4432f50a61ec3bb829d18c92955d))
|
||||
* prevent account takeover via auto_login_token, ensure promo group on all purchase paths ([b3f3eba](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b3f3eba5756404df9ed0f12d8048244ca536f7d3))
|
||||
* reactivate subscription after traffic top-up when status is EXPIRED ([8b35428](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8b354280558a5f28d1b99eae55ccd21a4af6a07b))
|
||||
* update promo group via M2M table so admin changes persist ([68bc8eb](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/68bc8eb57c792059d2be8a8fff6bba3254d3773d))
|
||||
|
||||
|
||||
### Refactoring
|
||||
|
||||
* remove estimated price from balance, simplify server sync, fix HTML injection ([a798f11](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a798f1143eebf52e18254bddd610f7f14a0c4056))
|
||||
|
||||
## [3.30.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.29.0...v3.30.0) (2026-03-11)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* add gifts section to admin user detail API ([bca8bab](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bca8bab4336b2583da9be8c642985e6a0151e33d))
|
||||
* add promo group and promo offer discounts to gift subscriptions ([2fd0f6a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2fd0f6aa4eb62f704208c1e56a6542d3967e7867))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* record transactions for free tariff switches and admin tariff changes ([864a4ed](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/864a4ed7005195ff3be3a8bb2e7666bc5a7f3e4e))
|
||||
* reset subscription for paid users, trial-to-paid tariff conversion, gift purchase MissingGreenlet ([e67b8e4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e67b8e448e5396ee6daa8c6278bb5a0b313dda74))
|
||||
* use keyword args for Path.mkdir in asyncio.to_thread ([2879996](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/287999645506a49b6693a184757598e1cdceb4d8))
|
||||
|
||||
## [3.29.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.28.1...v3.29.0) (2026-03-10)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* gift subscription code-only purchase + activation via deep link ([5ffce17](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5ffce175dcb8aebf22cf536bfa032c66da284600))
|
||||
* prevent self-activation of gift codes ([b30c73c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b30c73c300019646ea4a0d7e1bf758464ee58f0f))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* 3 bugs — notification type, referral with channel sub, BOT_USERNAME ([3c96c2a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3c96c2affd5a803311e3c0c9a0f844d2217f387a))
|
||||
* 3 critical issues from second-round review ([a90d2d9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a90d2d936793daaadf116cf314b736a9ebfb7c3b))
|
||||
* add minimum 8-char length check for gift token in bot deep link ([8a8337f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8a8337f538c3fcaf84b84c34b7a1e38a4ce9d580))
|
||||
* address review findings from 6-agent audit ([5c34656](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5c3465647639d6f07432bc3d725bba9396af6c45))
|
||||
* code-only gifts skip fulfillment in gateway webhook + retry service ([05bcac5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/05bcac502efb1b4298a1c6be91bba5d7c057b9f0))
|
||||
* panel sync now updates end_date in both directions ([def594b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/def594bbb55ef45d3df81524bd8841de73a07340))
|
||||
* pass full token to svc_activate instead of truncated prefix ([38c6adf](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/38c6adfdb4d4fc786bf6ca34a5d54025126130c0))
|
||||
* refresh user subscription after gift activation in /start ([363ccce](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/363ccce56d3e61554ca49d725322a79b05bc65d3))
|
||||
* remove begin_nested that breaks activate_purchase transaction ([0005d59](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0005d59da1e58c38561c8346db89d8475a25d7df))
|
||||
* stars rate rounding + device/traffic purchase stats ([641ff86](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/641ff86bf6f1ac1f22146f4344beda05759869fc))
|
||||
* support prefix-based gift code lookup for activation ([4fb72ae](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4fb72ae6e3bc65d93ab84b594b4ff5b4856c5357))
|
||||
|
||||
|
||||
### Refactoring
|
||||
|
||||
* deduplicate gift activation in start.py ([769d3a0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/769d3a0b309fb6be1c3175cd66a0df7cb6e2fb67))
|
||||
* rename GIFTCODE_ start parameter prefix to GIFT_ ([42b6c80](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/42b6c80a48ad0100d5ddbbe99a096ebb7b292f08))
|
||||
|
||||
## [3.28.1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.28.0...v3.28.1) (2026-03-10)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* migrate pricing to days-based proration, fix promo revenue leaks, fix admin panel bugs ([fcdeff1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fcdeff1ee5155c88c634e12a703159c221d66af5))
|
||||
|
||||
## [3.28.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.27.0...v3.28.0) (2026-03-09)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* add cabinet gift subscription API routes and schemas ([6a61b09](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6a61b095755885ff8973eb9ac4422740d07e0306))
|
||||
* add cabinet menu layout editor with row arrangement, custom URL buttons, and drag-and-drop reordering ([dd8d7f6](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/dd8d7f69203490553d15dcdad6dda28fab02d593))
|
||||
* add CABINET_GIFT_ENABLED branding toggle ([759bfe1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/759bfe1bdb3a3d3f917334fd32d0ea2f5be5d1f0))
|
||||
* add open_in setting for custom buttons (external browser / webapp) ([497a8ee](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/497a8ee5b528cf80d7042a7eec62369b6a327339))
|
||||
* add source and buyer_user_id fields to GuestPurchase model ([0936d4a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0936d4a7f651a1fcef8c2f86818320af3764b423))
|
||||
* implement gateway payment for gifts, persist recipient warning ([cd04f3b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/cd04f3b622444f45e2edf4a92da581f3d1f79b67))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* enforce HTTPS for webapp mode, deduplicate keyboard builder, fix long line ([69dbd6a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/69dbd6a2df4cf5e0dd7156ca0f3beb53c4a061af))
|
||||
* harden gift subscription feature after multi-agent review ([6a4140e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6a4140e3e203beb20cc56aa9c65dfed70f0a12d7))
|
||||
* loyalty tiers current status based on spending, not assigned group ([b815abf](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b815abf2b11e32eb658f9a8a63ae902bc0db46f4))
|
||||
* negate GIFT_PAYMENT amounts and remove dead code ([f80b058](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f80b0583804f27c322a4eb27f0613163ca1f97e9))
|
||||
* normalize threshold 0→NULL in create_promo_group for consistency ([b9089e6](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b9089e693f823e3b8618d08329ccba559592dfa3))
|
||||
* payment gateway issues — YooKassa polling, PAL24 card 500 ([95a32e8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/95a32e8574320eeba9276e44551a2f1207ae1e8b))
|
||||
* support Telegram OIDC id_token in account linking endpoint ([680c22c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/680c22c0179253d24f7f89e115a283dac92f9a49))
|
||||
|
||||
## [3.27.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.26.0...v3.27.0) (2026-03-09)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* auto-resume disabled daily subscriptions on balance topup ([770b31d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/770b31d3d05c22411b64ddbea3c304e34d879f5b))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add method query param to return_url and latest-payment endpoint ([32d58b0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/32d58b04b9a37473f43ae07cc32d4e18b161e3b9))
|
||||
* add table existence guards to migrations for optional payment tables ([f4a7763](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f4a776319eaccbce108a1f22462da6cd592fe0f3))
|
||||
* admin tariff server selection - 64-byte overflow and callback routing conflicts ([536525c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/536525c9c0a7701321bc3b83d6cef125c6f343ba))
|
||||
* align tariff pricing with calculate_renewal_price reference ([6349b2f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6349b2f4426abd49e3bd63364f3d2b204a486282))
|
||||
* conditional log messages and sanitize panel_error in user deletion ([289cbe9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/289cbe966e42afe74c8d1b936139941ff84e008b))
|
||||
* encode payment status in provider return URLs and wire failed_url ([275f249](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/275f249bbdf28d065e1b856e4d8ec7e73af4e1aa))
|
||||
* enforce tariff device_price and max_device_limit across all purchase paths ([f9f07f3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f9f07f360c36ce1eade8a27fa0fa5bf22808db93))
|
||||
* keep DB session alive in Tribute payment notification handler ([4186159](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4186159a61a40003454afc9c0faf848582cfb037))
|
||||
* latest-payment endpoint returns all payments, not just pending ([7a9264b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7a9264b1731cf8935c9e3985f41a1df919dfbf83))
|
||||
* pass cabinet return_url to payment providers for top-up redirects ([7ca9619](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7ca96195a7240ce0c3bd613c20344d79e5219c74))
|
||||
* propagate tariff squad changes to existing subscriptions and fix user deletion from Remnawave ([7ccfb66](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7ccfb66690c93df0c9c694935b16a280ca8ae812))
|
||||
* renewal cost estimate double-counts servers and traffic in tariff mode ([bfbefeb](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bfbefeb1e20a191f604bbcbc79b14d8c6e4cd5bd))
|
||||
* resolve concurrent AsyncSession bug and sanitize error responses ([4a5cacd](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4a5cacda386e7fa60ad7b6393aa3372384bee128))
|
||||
* use parsed HTML length for Telegram caption limit checks ([2649e12](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2649e12f64b8f825a3db85b95da6a335b0f8eec6))
|
||||
|
||||
|
||||
### Refactoring
|
||||
|
||||
* move squad propagation to service layer with parallel Remnawave sync ([79161ea](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/79161eaae4d67c82c45b6ea3654c0b15c8b785a4))
|
||||
|
||||
## [3.26.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.25.0...v3.26.0) (2026-03-08)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* add telegram gift notification with inline activation button ([9ba61a0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9ba61a08796fbc06e0dea2ee9cb02edc4126b335))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* auto-purchase classic extend missing device_limit and traffic_limit_gb ([7dc5e4a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7dc5e4ab94a415dc739a49c74cde511aad0cbb29))
|
||||
* gift purchase notification and activation flow ([330d1cb](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/330d1cb6fe2eee81a3e8f841de75d41e8b4cde40))
|
||||
* multiple payment and notification bugs ([f4eeb9a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f4eeb9a503d6da8a152f8cb60b89f7ebbdf41c4a))
|
||||
* quick topup buttons include device/server/traffic costs, broadcast button crash on media messages ([5ebe107](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5ebe1072c9c8a1dcb6ee4cbbea2dc55211b534c4))
|
||||
* remove is_active_paid_subscription guard from admin deactivation ([1f664a9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1f664a9083d81bc4462c30bf0626a44b5a30f03e))
|
||||
* respect send_before_menu flag for pinned messages during new user registration ([20727b1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/20727b1017457769feccccf83e99523c19026a7e))
|
||||
|
||||
## [3.25.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.24.0...v3.25.0) (2026-03-07)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* add configurable animated background for landing pages ([11d3e63](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/11d3e637c106590a73ed804fc762bf303b37dd62))
|
||||
* add landing page statistics endpoint with charts data ([25478ce](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/25478ced209fdbf12f2c398ad1c8d48ac26c923e))
|
||||
* add paginated purchases list endpoint for landing pages ([0ba1127](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0ba112746913bb9927338a7554370ac8c4e12039))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add or [] guard to remaining connected_squads call site in fulfill_purchase ([d9f9f3d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d9f9f3dca126963967782160766bd5b26bde7a49))
|
||||
* align context_vars and SAMPLE_CONTEXTS with actual runtime context keys ([ab5313a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ab5313a381f8175346b470d6d1df54d8d7d11ff8))
|
||||
* align subscription_renewed/activated context_vars with runtime keys ([c507634](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c507634398d3e934246b2c66183ebad1b9949769))
|
||||
* correct device_limit and connected_squads in guest purchase fulfillment ([44d46fe](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/44d46feb0adec9255fbf167e9937ea70b711e289))
|
||||
* drop legacy prize_days column from contest_templates ([5214f55](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5214f55f46391c7870a38ea51c2efc2f4e518f58))
|
||||
* handle expired subscription in guest purchase fulfillment ([9e78509](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9e785092843cf7f6b3ebb5bfa1710030c74ceafb))
|
||||
* remaining context_vars/SAMPLE_CONTEXTS mismatches found by agents ([d72ea6b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d72ea6b7f999c320038f0327a0c541d1cd276244))
|
||||
* resolve alembic migration failures on fresh database install ([bbd353f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bbd353ff38af57aa9a8f15c60bded3259a3e3e26))
|
||||
* resolve NameError in YooKassa successful payment processing ([9d5329d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9d5329d9d1051eeaf77cfea4932557cdfbf21cc6))
|
||||
* strip newlines from subject substitution, fix subscription notification context ([c9ea2b1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c9ea2b15e9d670d6e1888c0396a145713ec749c0))
|
||||
* substitute context variables in email template overrides ([d52c87b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d52c87b2b752d3f432096318ac2ec4f9ad792929))
|
||||
* substitute sample context in admin test email for template overrides ([351d714](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/351d714f2d4c200e8ab4263ff006ae33ec062d95))
|
||||
* support {total_amount} placeholder in cart notification templates ([f4ab174](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f4ab174d32be8b48470320c27c92e75fdabd6d58))
|
||||
* use --frozen instead of --locked in Dockerfile to avoid version mismatch ([923b36a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/923b36a8b9caed5db1c147a5ef4c001f66f8170a))
|
||||
* use information_schema for constraint existence checks in migrations ([fc65e2d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fc65e2de4c9c08e7df1886c458b53f7a05894934))
|
||||
* use pg_class lookup for constraint existence checks in migrations ([ba335fe](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ba335fe78430e26b9e2449dbbd6db209557698e0))
|
||||
|
||||
## [3.24.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.23.2...v3.24.0) (2026-03-07)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* account linking and merge system for cabinet ([dc7b8dc](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/dc7b8dc72a3a398d6270a0a2b8ce9e2b54cb9af7))
|
||||
* account merge system — atomic user merge with full FK coverage ([2664b49](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2664b4956d8436a2720d7cd5992b8cdbb72cdbd9))
|
||||
* add 'default' (no color) option for button styles ([10538e7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/10538e735149bf3f3f2029ff44b94d11d48c478e))
|
||||
* add admin campaign chart data endpoint with deposits/spending split ([fa7de58](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fa7de589c1bd0ae37ebaaa07bae0ed3d68e01720))
|
||||
* add admin notifications for partner applications and withdrawals ([cf7cc5a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/cf7cc5a84e295608009f255fcd0dcedb5a2a04a3))
|
||||
* add admin partner settings API (withdrawal toggle, requisites text, partner visibility) ([6881d97](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6881d97bbb1f6cd8ca3609c2d9286a6e4fb24fc3))
|
||||
* add admin sales statistics API with 6 analytics endpoints ([58faf9e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/58faf9eaeca63c458093d2a5e74a860f57712ab0))
|
||||
* add admin topic notifications for landing page purchases ([dbb9757](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/dbb9757a3c7938ab7505358942f675b82401245a))
|
||||
* add all remaining RemnaWave webhook events (node, service, crm, device) ([1e37fd9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1e37fd9dd271814e644af591343cada6ab12d612))
|
||||
* add button style and emoji support for cabinet mode (Bot API 9.4) ([bf2b2f1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bf2b2f1c5650e527fcac0fb3e72b4e6e19bef406))
|
||||
* add cabinet admin API for pinned messages management ([1a476c4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1a476c49c19d1ec2ab2cda1c2ffb5fd242288bb6))
|
||||
* add campaign_id to ReferralEarning for campaign attribution ([0c07812](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0c07812ecc9502f54a7745a77b086fc52bdc0e34))
|
||||
* add ChatTypeFilterMiddleware to ignore group/forum messages ([25f014f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/25f014fd8988b5513fba8fec4483981384687e96))
|
||||
* add close button to all webhook notifications ([d9de15a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d9de15a5a06aec3901415bdfd25b55d2ca01d28c))
|
||||
* add daily deposits by payment method breakdown ([d33c5d6](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d33c5d6c07ce4a9efaf3c5aceb448e968e1b8ed7))
|
||||
* add daily device purchases chart to addons stats ([2449a5c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2449a5cbbe5179a762197414a5752896383a6ee4))
|
||||
* add dedicated sales_stats RBAC permission section ([8f29e2e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8f29e2eee2e0c78f7f7e87a322eaf4bd4221069c))
|
||||
* add desired commission percent to partner application ([7ea8fbd](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7ea8fbd584aff2127595001094ef69acb52f847f))
|
||||
* add discount system for landing pages ([aa7d986](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/aa7d98630dd9be2cfb81dac3ef2c1c6730487e61))
|
||||
* add external squad support for tariffs ([c10d678](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c10d6780ba89ac641769dcb0c4ab2d89f124f0b7))
|
||||
* add GET /admin/rbac/users endpoint for listing all RBAC users ([8b77cda](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8b77cdae2ccc489bfead89523f31cd15bfdc675b))
|
||||
* add granular user permissions (balance, subscription, promo_group, referral, send_offer) ([60c4fe2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/60c4fe2e239d8fef7726cac769711c8fcce789eb))
|
||||
* add landings to permission registry ([c93dbec](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c93dbec7a0e24a6cc41449ed3c6e5fb669b127a9))
|
||||
* add lite mode functionality with endpoints for retrieval and update ([7b0403a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7b0403a307702c24efefc5c14af8cb2fb7525671))
|
||||
* add LOG_COLORS env setting to toggle console ANSI colors ([27309f5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/27309f53d9fa0ba9a2ca07a65feed96bf38f470c))
|
||||
* add MULENPAY_WEBSITE_URL setting for post-payment redirect ([fe5f5de](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fe5f5ded965e36300e1c73f25f16de22f84651ad))
|
||||
* add multi-channel mandatory subscription system ([8375d7e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8375d7ecc5e54ea935a00175dd26f667eab95346))
|
||||
* add partner system and withdrawal management to cabinet ([58bfaea](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/58bfaeaddbcbb98cb67dbd507847a0e5c8d07809))
|
||||
* add per-button enable/disable toggle and custom labels per locale ([68773b7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/68773b7e77aa344d18b0f304fa561c91d7631c05))
|
||||
* add per-channel disable settings and fix CHANNEL_REQUIRED_FOR_ALL bug ([3642462](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3642462670c876052aa668c1515af8c04234cb34))
|
||||
* add per-section button style and emoji customization via admin API ([a968791](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a9687912dfe756e7d772d96cc253f78f2e97185c))
|
||||
* add Persian (fa) locale with complete translations ([29a3b39](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/29a3b395b6e67e4ce2437b75120b78c76b69ff4f))
|
||||
* add POST /auth/telegram/oidc endpoint for OIDC popup flow ([3a400d9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3a400d9f8b3b4dd2c0bb12fc68f1af6e7c880761))
|
||||
* add quick purchase email templates to admin panel ([6970340](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6970340e62c67a41f3219759fb0a752617690ea0))
|
||||
* add RBAC + ABAC permission system for admin cabinet ([3fee54f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3fee54f657dc6e0db1ec36697850ada2235e6968))
|
||||
* add referral code tracking to all cabinet auth methods + email_templates migration ([18c2477](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/18c24771737994f3ae1f832435ed2247ca625aab))
|
||||
* add RemnaWave incoming webhooks for real-time subscription events ([6d67cad](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6d67cad3e7aa07b8490d88b73c38c4aca6b9e315))
|
||||
* add required channels button to admin settings submenu in bot ([3af07ff](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3af07ff627fc354da4f8c41b0bd0575dddd9afa5))
|
||||
* add RESET_TRAFFIC_ON_TARIFF_SWITCH admin setting ([4eaedd3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4eaedd33bf697469fe9ed6a1bfe8b59ca43b46fb))
|
||||
* add resource_type and request body to audit log entries ([388fc7e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/388fc7ee67f5fc0edf6b7b64b977e12a2d8f0566))
|
||||
* add separate Freekassa SBP and card payment methods ([0da0c55](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0da0c5547d0648a70f848fe77c13d583f4868a52))
|
||||
* add server-complete OAuth linking endpoint for Mini App flow ([f867989](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f867989557d20378cfe815c9c88e1a842c4f6654))
|
||||
* add startup warnings for missing HAPP_CRYPTOLINK_REDIRECT_TEMPLATE and MINIAPP_CUSTOM_URL ([476b89f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/476b89fe8e613c505acfc58a9554d31ccf92718a))
|
||||
* add sub_options support for landing page payment methods ([220196f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/220196fb7abc88b60a37c1fb60786dd3a6ada3ad))
|
||||
* add Telegram account linking endpoint with security hardening ([da40d56](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/da40d5662d6d064090769823d616d6f9748ab5b9))
|
||||
* add Telegram OIDC id_token validation and code exchange ([2f0a9dc](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2f0a9dc4f3489f7d4311101191129ee95d7edbcc))
|
||||
* add TELEGRAM_OIDC_* settings for new Telegram Login ([833df51](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/833df518d010d1bfd773eb0c85aaa7e653c7e153))
|
||||
* add validation to animation config API ([a15403b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a15403b8b6e1ec1bb5c37fdde646e7790373e860))
|
||||
* add web admin button for admins in cabinet mode ([9ac6da4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9ac6da490dffa03ce823009c6b4e5014b7d2bdfb))
|
||||
* add web campaign links with bonus processing in auth flow ([d955279](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d9552799c17a76e2cc2118699528c5b591bd97fb))
|
||||
* allow editing system roles ([f6b6e22](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f6b6e22a9528dc05b7fbfa80b63051a75c8e73cd))
|
||||
* allow tariff deletion with active subscriptions ([ebd6bee](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ebd6bee05ed7d9187de9394c64dfd745bb06b65a))
|
||||
* attribute campaign registrations to partner for referral earnings ([767e965](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/767e9650285adc72b067b2c0b8a4d1ac5c5bba57))
|
||||
* blocked user detection during broadcasts, filter blocked from all notifications ([10e231e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/10e231e52e0dbabd9195a2df373b3c95129a5e4f))
|
||||
* capture query params in audit log details for all requests ([bea9da9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bea9da96d44965fcee5e2eba448960443152d4ea))
|
||||
* colored channel subscription buttons via Bot API 9.4 style ([0b3b2e5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0b3b2e5dc54d8b6b3ede883d5c0f5b91791b7b9b))
|
||||
* colored console logs via structlog + rich + FORCE_COLOR ([bf64611](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bf646112df02aa7aa7918d0513cb6968ceb7f378))
|
||||
* configurable Telegram Login Widget with admin settings ([084a3cd](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/084a3cd16f8825c389514813ba679748ba235d0a))
|
||||
* enforce 1-to-1 partner-campaign binding with partner info in campaigns ([366df18](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/366df18c547047a7c69192c768970ebc6ee426fc))
|
||||
* enhance sales stats with device purchases, per-tariff daily breakdown, and registration tracking ([31c7e2e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/31c7e2e9c14cb88762a62a72e4f65051e0c6c1fd))
|
||||
* expose oidc_enabled and oidc_client_id in telegram-widget config ([000b0c0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/000b0c0592773d0a5f6f572fd8a721ce0f474b2c))
|
||||
* expose payment sub-options with labels in public landing API ([c53e9af](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c53e9af744114e5d6fe014b09b4fac8da1e59c6e))
|
||||
* expose traffic_reset_mode in subscription response ([59383bd](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/59383bdbd8c72428d151cb24d132452414b14fa3))
|
||||
* expose traffic_reset_mode in tariff API response ([5d4a94b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5d4a94b8cea8f16f0b4c31e24a4695bee4c67af7))
|
||||
* guest purchase → cabinet account integration ([f8edfd7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f8edfd77463aad64d9e616569467b4883be4dccf))
|
||||
* guest purchase delivery & activation system ([776fc3a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/776fc3aadc14e1cc415286cf008fa4eb85f21164))
|
||||
* handle errors.bandwidth_usage_threshold_reached_max_notifications webhook ([8e85e24](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8e85e244cb786fb4c06162f2b98d01202e893315))
|
||||
* handle service.subpage_config_changed webhook event ([43a326a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/43a326a98ccc3351de04d9b2d660d3e7e0cb0efc))
|
||||
* include partner campaigns in /partner/status response ([ea5d932](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ea5d932476553ad1750da3bebbd4b8f055478040))
|
||||
* link campaign registrations to partner for referral earnings ([c4dc43e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c4dc43e054e9faec2f9614fe51a64635f80c1796))
|
||||
* **localization:** add Persian (fa) locale support and wire it across app flows ([cc54a7a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/cc54a7ad2fb98fe6e662e1923027f4989ae72868))
|
||||
* notify users on partner/withdrawal approve/reject ([327d4f4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/327d4f4d1559e37dc591adbfd0c839d986d1068d))
|
||||
* register TELEGRAM_OIDC category, hints in admin settings ([3a36162](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3a361628aa543cb629d6967d84d7f474b89c3841))
|
||||
* rename MAIN_MENU_MODE=text to cabinet with deep-linking to frontend sections ([ad87c5f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ad87c5fb5e1a4dd0ef7691f12764d3df1530f643))
|
||||
* replace pip with uv in Dockerfile ([e23d69f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e23d69fcec7ab65a14b054fd46f6ecf87ae6fd13))
|
||||
* rework guide mode with Remnawave API integration ([5a269b2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5a269b249e8e6cad266822095676937481613f5f))
|
||||
* show all active webhook endpoints in startup log ([9d71005](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9d710050ad40ba76a14aa6ace8e8a47f25cdde94))
|
||||
* unified notification delivery for webhook events (email + WS support) ([26637f0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/26637f0ae5c7264c0430487d942744fd034e78e8))
|
||||
* webhook protection — prevent sync/monitoring from overwriting webhook data ([184c52d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/184c52d4ea3ce02d40cf8a5ab42be855c7c7ae23))
|
||||
* мультиязычные лендинги + гостевые платежи для всех провайдеров ([6deab7d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6deab7dd8c5c5df812bd69608369258a10a67ca4))
|
||||
* публичные лендинг-страницы для быстрой покупки VPN-подписок ([5e404cc](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5e404cc082859d875f988911fcc4eedaa35b886b))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* 3 user deletion bugs — type cast, inner savepoint, lazy load ([af31c55](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/af31c551d2f23ef01425bdb2db8f255dbc3047e2))
|
||||
* abs() for transaction amounts in admin notifications and subscription events ([fd139b2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fd139b28a2c45cc3fbd2e01707fb83fbabf57c71))
|
||||
* add /start burst rate-limit to prevent spam abuse ([61a9722](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/61a97220d30031816ab23e33a46717e4895c0758))
|
||||
* add abs() to expenses query, display flip, contest stats, and recent payments ([de6f806](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/de6f80694ba8aa240764e2769ec04c16fe7f3672))
|
||||
* add action buttons to webhook notifications and fix empty device names ([7091eb9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7091eb9c148aaf913c4699fc86fef5b548002668))
|
||||
* add activate hint to gift pending activation email link ([fa21549](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fa21549cac9098f49e2e32868acce461acd1b40d))
|
||||
* add blocked_count column migration to universal_migration.py ([b4b10c9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b4b10c998cadbb879540e56dbd0e362b5497ee57))
|
||||
* add diagnostic logging for device_limit sync to RemnaWave ([97b3f89](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/97b3f899d12c4bf32b6229a3b595f1b9ad611096))
|
||||
* add exc_info traceback to sync user error log ([efdf2a3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/efdf2a3189a2f790e570f9a6e19d91469be4ea4f))
|
||||
* add int32 overflow guards and strengthen auth validation ([50a931e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/50a931ec363d1842126b90098f93c6cae47a9fac))
|
||||
* add IntegrityError handling on link commit and format fixes ([0c1dc58](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0c1dc580c67254d11ffb096c22d8c8d78ac18e2b))
|
||||
* add local traffic_used_gb reset in all tariff switch handlers ([2cdbbc0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2cdbbc09ba9a19dcb720049ffde08ba780ac5751))
|
||||
* add Message-ID and Date headers to outgoing emails ([de541ea](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/de541ea1c3fa20c606c0ea1b69a0223569afb9e2))
|
||||
* add Message-ID and Date headers to outgoing emails ([e9b4d8e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e9b4d8e444be9ab666caf642c849dcf63b1884ab))
|
||||
* add migration for partner system tables and columns ([4645be5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4645be53cbb3799aa6b2b6a623af30460357a554))
|
||||
* add migration for partner system tables and columns ([79ea398](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/79ea398d1db436a7812a799bf01b2c1c3b1b73be))
|
||||
* add min_length to state field, use exc_info for referral warning ([062c486](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/062c4865db194f9d2242772044402fa2711a69bd))
|
||||
* add missing broadcast_history columns and harden subscription logic ([d4c4a8a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d4c4a8a211eaf836024f8d9dcb725f25f514f05e))
|
||||
* add missing CHANNEL_CHECK_NOT_SUBSCRIBED localization key ([a47ef67](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a47ef67090c4e48f466286f7c676eeee0c61a4fb))
|
||||
* add missing mark_as_paid_subscription, fix operation order, remove dead code ([5f2d855](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5f2d855702dea838b38887a5f44b9ad759acd5cf))
|
||||
* add missing payment providers to payment_utils and fix {total_amount} formatting ([bdb6161](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bdb61613de378efab4de6de98fde2de3b554c548))
|
||||
* add missing placeholders to Arabic SUBSCRIPTION_INFO template ([fe54640](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fe546408857128649930de9473c7cde1f7cc450a))
|
||||
* add missing subscription columns migration ([b96e819](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b96e819da4cc37710e9fc17467045b33bcffac4d))
|
||||
* add naive datetime guards to fromisoformat() in Redis cache readers ([1b3e6f2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1b3e6f2f11c20aa240da1beb11dd7dfb20dbe6e8))
|
||||
* add naive datetime guards to fromisoformat() in Redis cache readers ([6fa4948](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6fa49485d9f1cd678cb5f9fa7d0375fd47643239))
|
||||
* add naive datetime guards to parsers and fix test datetime literals ([0946090](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/094609005af7358bf5d34d252fc66685bd25751c))
|
||||
* add passive_deletes to Subscription relationships to prevent NOT NULL violation on cascade delete ([bfd66c4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bfd66c42c1fba3763f41d641cea1bd101ec8c10c))
|
||||
* add pending_activation to purchase stats and show total count ([8510597](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8510597ddb501c479d5b70118a94944556ab984f))
|
||||
* add promo code anti-abuse protections ([97ec39a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/97ec39aa803f0e3f03fdcd482df0cbcb86fd1efd))
|
||||
* add referral_code pattern validation, email login rate limiting, and Retry-After headers ([5499ad6](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5499ad62dc98346bef9cb83bf6d8bca319291371))
|
||||
* add selectinload for campaign registrations in list query ([4d74afd](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4d74afd7118524623371f904a93ae1fcbba8d64e))
|
||||
* add selectinload for subscription in campaign user list ([eb9dba3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/eb9dba3f4728b478f2206ff992700a9677f879c7))
|
||||
* add startup warning for missing HAPP_CRYPTOLINK_REDIRECT_TEMPLATE in guide mode ([1d43ae5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1d43ae5e25ffcf0e4fe6fec13319d393717e1e50))
|
||||
* add X-CSRF-Token and X-Telegram-Init-Data to CORS allow_headers ([77456ef](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/77456efb7504e12c9b9879a352118ce1687132b1))
|
||||
* address code review findings for Telegram OIDC ([da1cc4f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/da1cc4fe5ab6436210185a12dc2a82cb153fc24a))
|
||||
* address code review issues in guide mode rework ([fae6f71](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fae6f71def421e319733e4edcf1ca80a2831b2ec))
|
||||
* address RBAC review findings (CRITICAL + HIGH) ([1646f04](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1646f04bde47a08f3fd782b7831d40760bd1ba60))
|
||||
* address remaining abs() issues from review ([ff21b27](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ff21b27b98bb5a7517e06057eb319c9f3ebb74c7))
|
||||
* address review findings for guest purchase admin notifications ([770f19e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/770f19e84688e55ed44f7c9de26b0e9ae9636c4b))
|
||||
* address review findings from agent verification ([cc5be70](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/cc5be7059fdf4cefb01e97196c825b217f8b54b3))
|
||||
* address review issues in backup, updates, and webhook handlers ([2094886](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/20948869902dc570681b05709ac8d51996330a6e))
|
||||
* address security review findings ([6feec1e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6feec1eaa847644ba3402763a2ffefd8f770cc01))
|
||||
* align RBAC route prefixes with frontend API paths ([5a7dd3f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5a7dd3f16408f3497a9765e79a540ccdabc50e69))
|
||||
* allow email change for unverified emails ([93bb8e0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/93bb8e0eb492ca59e29da86594e84e9c486fea65))
|
||||
* allow non-HTTP deep links in crypto link webhook updates ([f779225](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f77922522a85b3017be44b5fc71da9c95ec16379))
|
||||
* allow purchase when recalculated price is lower than cached ([19dabf3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/19dabf38512ae0c2121108d0b92fc8f384292484))
|
||||
* allow tariff switch when less than 1 day remains ([67f3547](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/67f3547ae2f40153229d71c1abe7e1213466e5c3))
|
||||
* always include details in successful audit log entries ([3dc0b93](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3dc0b93bdfc85fb97f371dc34e024272766afc65))
|
||||
* AttributeError in withdrawal admin notification (send_to_admins → send_admin_notification) ([c75ec0b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c75ec0b22a3f674d3e1a24b9d546eca1998701b3))
|
||||
* auth middleware catches all commit errors, not just connection errors ([6409b0c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6409b0c023cd7957c43d5c1c3d83e671ccaf959c))
|
||||
* auto-convert naive datetimes to UTC-aware on model load ([f7d33a7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f7d33a7d2b31145a839ee54676816aa657ac90da))
|
||||
* auto-update permissions for system roles on bootstrap ([eff74be](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/eff74bed5bcc47a6cfa05c20cad14a40c1572d1f))
|
||||
* backup restore fails on FK constraints and transaction poisoning ([ff1c872](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ff1c8722c9188fdbaf765d6b7e9192686df64850))
|
||||
* build composite device name from platform + hwid short suffix ([17ce640](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/17ce64037f198837c8f2aa7bf863871f60bdf547))
|
||||
* callback routing safety and cache invalidation order ([6a50013](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6a50013c21de199df0ba0dab3600b693548b6c1e))
|
||||
* campaign web link uses ?campaign= param, not ?start= ([28f524b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/28f524b7622ed975d2fece66edc94d9713354738))
|
||||
* cap expected_monthly_referrals to prevent int32 overflow ([2ef6185](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2ef618571570edb6011a365af8aa9cd7e3348c2e))
|
||||
* centralize balance deduction and fix unchecked return values ([0466528](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0466528925a24087b8522a10cbb11c947c2b7d91))
|
||||
* centralize has_had_paid_subscription into subtract_user_balance ([e4a6aad](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e4a6aad621be7ef4e7aedb21373927ede0c8d0a5))
|
||||
* change CryptoBot URL priority to bot_invoice_url for Telegram opening ([3193ffb](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3193ffbd1bee07cb79824d87cb0f77b473b22989))
|
||||
* classic mode prices overridden by active tariff prices ([628a99e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/628a99e7aa0812842dabc430857190c0cd5c2680))
|
||||
* clean email verification and password fields from secondary user during merge ([7b4e948](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7b4e9488f6fbd1271f063579e48ca9a3c96cb645))
|
||||
* clean stale squad UUIDs from tariffs during server sync ([fcaa9df](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fcaa9dfb27350ceda3765c6980ad67f671477caf))
|
||||
* clear subscription data when user deleted from Remnawave panel ([b0fd38d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b0fd38d60c22247a0086c570665b92c73a060f2f))
|
||||
* close remaining daily subscription expire paths ([618c936](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/618c936ac9ce4904cd784bf2278d3da188895f2d))
|
||||
* code style and formatting from review ([a539d69](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a539d698546a60aa0a06759f91c77476380a20b1))
|
||||
* complete datetime.utcnow() → datetime.now(UTC) migration ([eb18994](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/eb18994b7d34d777ca39d3278d509e41359e2a85))
|
||||
* complete FK migration — add 27 missing constraints, fix broadcast_history nullable ([fe393d2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fe393d2ca6ce302d8213cc751842ea92ef277e76))
|
||||
* comprehensive security and quality fixes from 7-agent review ([5c55662](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5c55662e2c7068456aeee435b543a851225ff39e))
|
||||
* comprehensive security hardening from 7-agent review ([e96fe1e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e96fe1ecd8d90878a3fbad9ed76c1a2e7f3a1415))
|
||||
* connected_squads stores UUIDs, not int IDs — use get_server_ids_by_uuids ([d7039d7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d7039d75a47fbf67436a9d39f2cd9f65f2646544))
|
||||
* consume promo offer in miniapp tariff-mode renewal path ([b8857e7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b8857e789ef60cf0c8766abbeadd094f62070a61))
|
||||
* consume promo offer in tariff_purchase.py, fix negative transaction amount ([c8ef808](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c8ef80853915af3e3eb254edd07d8d78b66a9282))
|
||||
* correct broadcast button deep-links for cabinet mode ([e5fa45f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e5fa45f74f969b84f9f1388f8d4888d22c46d7e8))
|
||||
* correct cart notification after balance top-up ([2fab50c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2fab50c340c885fc92a4bf797a4b03da6e44af31))
|
||||
* correct referral withdrawal balance formula and commission transaction type ([83c6db4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/83c6db48349440447305604e944fa440bdceb3fb))
|
||||
* correct subscription_service import in broadcast cleanup ([6c4e035](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6c4e035146934dffb576477cc75f7365b2f27b99))
|
||||
* count sales from completed payment transactions instead of subscription created_at ([06c3996](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/06c3996da4fa14eafb294651158068c7cda51e52))
|
||||
* critical OIDC fixes from 7-agent review ([b78c01c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b78c01cae9746275057aaf61c0876ccfd72e1f62))
|
||||
* critical security and data integrity fixes for partner system ([8899749](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/88997492c3534ea2f6e194c0382c77302557c2f3))
|
||||
* cross-validate Telegram identity on every authenticated request ([973b3d3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/973b3d3d3ff80376c0fd19c531d7aac3ae751df8))
|
||||
* CryptoBot guest payment — remove is_paid [@property](https://github.com/property) write, use correct status ([6f871ed](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6f871edc9d01ca20d1b194a157d3d6ae46512d05))
|
||||
* daily tariff subscriptions stuck in expired/disabled with no resume path ([80914c1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/80914c1af739aa0ee1ea75b0e5871bf391b9020d))
|
||||
* deadlock on user deletion + robust migration 0002 ([b7b83ab](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b7b83abb723913b3167e7462ff592a374c3f421b))
|
||||
* delete cross-referral earnings before bulk reassignment, clear secondary.referred_by_id ([f204b67](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f204b678803297ce60faad628d16f46344b11ed0))
|
||||
* delete subscription_servers before subscription to prevent FK violation ([7d9ced8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7d9ced8f4f71b43ed4ac798e6ff904a086e1ac4a))
|
||||
* device_limit fallback 1→0 для корректного отображения безлимита ([3e26832](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3e26832e745368a0dab2617e4e8ae2c410c6bca2))
|
||||
* don't delete Heleket invoice message on status check ([9943253](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/994325360ca7665800177bfad8f831154f4d733f))
|
||||
* downgrade Telegram timeout errors to warning in monitoring service ([e43a8d6](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e43a8d6ce4c40a7212bf90644f82da109717bdcb))
|
||||
* downgrade transient API errors (502/503/504) to warning level ([ec8eaf5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ec8eaf52bfdc2bde612e4fc0324575ba7dc6b2e1))
|
||||
* eliminate deadlock by matching lock order with webhook ([d651a6c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d651a6c02f501b7a0ded570f2db6addcc16173a9))
|
||||
* eliminate double panel API call on tariff change, harden cart notification ([b2cf4aa](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b2cf4aaa91f3fb63dca7e70645cadb75aa158cfe))
|
||||
* eliminate referral system inconsistencies ([60c97f7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/60c97f778bc4cc18aaf4d8a31826bc831c3b3f8f))
|
||||
* email verification bypass, ban-notifications size limit, referral balance API ([256cbfc](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/256cbfcadfd2fc88d8de69557c78618639af157d))
|
||||
* empty JSONB values exported as None in backup ([57aaca8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/57aaca82f5bf9d7bdd9d4b924aa3412d85eccbb5))
|
||||
* enforce user restrictions in cabinet API and fix poll history crash ([faba3a8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/faba3a8ed6d428305f9ca7d7fd9bdcc1fd72ba52))
|
||||
* expand backup coverage to all 68 models and harden restore ([02e40bd](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/02e40bd6f7ef8e653cae53ccd127f2f79009e0d4))
|
||||
* extend naive datetime guard to all model properties ([bd11801](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bd11801467e917d76005d1a782c71f5ae4ffee6e))
|
||||
* extract device name from nested hwidUserDevice object ([79793c4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/79793c47bbbdae8b0f285448d5f70e90c9d4f4b0))
|
||||
* extract real client IP from X-Forwarded-For/X-Real-IP headers ([af6686c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/af6686ccfae12876e867cdabe729d0c893bd85a1))
|
||||
* filter out traffic packages with zero price from purchase options ([64a684c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/64a684cd2ff51e663a1f70e61c07ca6b4f6bfc91))
|
||||
* flood control handling in pinned messages and XSS hardening in HTML sanitizer ([454b831](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/454b83138e4db8dc4f07171ee6fe262d2cd6d311))
|
||||
* force basicConfig to replace pre-existing handlers ([7eb8d4e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7eb8d4e153bab640a5829f75bfa6f70df5763284))
|
||||
* freekassa OP-SP-7 error and missing telegram notification ([200f91e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/200f91ef1748bb6213d1ef3a8e83ae976290a8a7))
|
||||
* from redis.exceptions import NoScriptError ([667291a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/667291a2dcaeae21e27eeb6376085e69caa4e45a))
|
||||
* generate missing crypto link on the fly and skip unresolved templates ([4c72058](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4c72058d4ad8b0594991b17323928d9004803bfa))
|
||||
* grant legacy config-based admins full RBAC access ([8893fc1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8893fc128e3d8927054f1df1647e896e780c69e7))
|
||||
* handle duplicate remnawave_uuid on email sync ([eaeee7a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/eaeee7a765c03ff33e2928cdb41be91948eca95c))
|
||||
* handle expired callback queries and harden middleware error handling ([f52e6ae](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f52e6aedac3de1c9bb2ad1a5a16b06d38b79ab63))
|
||||
* handle expired ORM attributes in sync UUID mutation ([9ae5d7b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9ae5d7bb60c57e2c29d6f3c5098c23450d5feb61))
|
||||
* handle naive datetime in raw SQL row comparison (payment/common) ([38f3a9a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/38f3a9a16a24e85adf473f2150aad31574a87060))
|
||||
* handle naive datetimes in Subscription properties ([e512e5f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e512e5fe6e9009992b5bc8b9be7f53e0612f234a))
|
||||
* handle NULL used_promocodes for migrated users ([cdcabee](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/cdcabee80d1d7f0b367a97cdec20bb49e8592115))
|
||||
* handle nullable traffic_limit_gb and end_date in subscription model ([e94b93d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e94b93d0c10b4e61d7750ca47e1b2f888f5873ed))
|
||||
* handle photo message in ticket creation flow ([e182280](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e1822800aba3ea5eee721846b1e0d8df0a9398d1))
|
||||
* handle RemnaWave API errors in traffic aggregation ([ed4624c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ed4624c6649bdbc04bc850ef63e5c86e26a37ce4))
|
||||
* handle StaleDataError in webhook user.deleted server counter decrement ([c30c2fe](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c30c2feee1db03f0a359b291117da88002dd0fe0))
|
||||
* handle StaleDataError in webhook when user already deleted ([d58a80f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d58a80f3eaa64a6fc899e10b3b14584fb7fc18a9))
|
||||
* handle tariff_extend callback without period (back button crash) ([ba0a5e9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ba0a5e9abd9bd582968d69a5c6e57f336094c782))
|
||||
* handle TelegramBadRequest in ticket edit_message_text calls ([8e61fe4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8e61fe47746da2ac09c3ea8c4dbfc6be198e49e3))
|
||||
* handle time/date types in backup JSON serialization ([27365b3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/27365b3c7518c09229afcd928f505d0f3f66213f))
|
||||
* handle unique constraint conflicts during backup restore without clear_existing ([5893874](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/589387477624691e0026086800428e7e52e06128))
|
||||
* handle YooKassa NotFoundError gracefully in get_payment_info ([df5b1a0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/df5b1a072d99ff8aee0c94304b2a0214f0fcffe7))
|
||||
* harden account merge security and correctness ([d855e9e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d855e9e47fab1a038e581437a9921bdfeb11e927))
|
||||
* harden backup create/restore against serialization and constraint errors ([fc42916](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fc42916b10bb698895eb75c0e2568747647555d3))
|
||||
* hide traffic topup button when tariff doesn't support it ([399ca86](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/399ca86561f4271e9c542bac87c0dd2931a223e0))
|
||||
* HTML parse fallback, email change race condition, username length limit ([d05ff67](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d05ff678abfacaa7e55ad3e55f226d706d32a7b7))
|
||||
* HTML-escape all externally-sourced text in guide messages ([711ec34](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/711ec344c646844401f355695a7e8c0d4fb401ee))
|
||||
* ignore 'message is not modified' on privacy policy decline ([be1da97](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/be1da976e14a35e6cca01a7fca7529c55c1a208b))
|
||||
* improve campaign notifications and ticket media in admin topics ([a594a0f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a594a0f79f48227f75d6102b4586179102c4d344))
|
||||
* improve campaign routes, schemas, and add database indexes ([ded5c89](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ded5c899f7425707b17fef4d0d5ceafac777ef08))
|
||||
* improve deduplication log message wording in monitoring service ([2aead9a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2aead9a68b6bf274c8d1497c85f2ed4d4fc9c70b))
|
||||
* include desired_commission_percent in admin notification ([dc3d22f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/dc3d22f52db40150d595bccf524d38790e5725d9))
|
||||
* initialize logger in bot_configuration.py ([988d0e5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/988d0e5c2f27538135d757187a0b6770f078b1d9))
|
||||
* invalidate app config cache on local file saves ([978726a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/978726a7856cf56257c49491afe569fa8c395eac))
|
||||
* limit Rich traceback output to prevent console flood ([11ef714](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/11ef714e0dde25a08711c0daeee943b6e71e20b7))
|
||||
* make migration 0002 robust with table existence checks ([f076269](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f076269c323726c683a38db092d907591a26e647))
|
||||
* make migrations 0010/0011 idempotent, escape HTML in crash notification ([a696896](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a696896d2c4a3d0d6026398fcdc76ded9575375d))
|
||||
* make users.promo_group_id nullable — sync DB with model ([e0f2243](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e0f2243f49ca8cc741a5c07b63ef3eb2abdef52c))
|
||||
* medium-priority fixes for partner system ([7c20fde](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7c20fde4e887749d72280a8804467645e5bab416))
|
||||
* **merge:** validate before consuming token, add flush, defensive balance ([bc1e6fb](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bc1e6fb22c6e23c7a34364796f51a55c60224aff))
|
||||
* migrate all remaining naive timestamp columns to timestamptz ([708bb9e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/708bb9eec7ea4360b26709fb2a3f82dd139ed600))
|
||||
* migrate VK OAuth to VK ID OAuth 2.1 with PKCE ([1dfa780](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1dfa78013c4fb926a2b32bf4d63baa28215e7340))
|
||||
* MissingGreenlet on campaign registrations access ([018f18f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/018f18fa0c9bba1a1dbca8b2398b9611d0c94c36))
|
||||
* move PartnerStatus enum before User class to fix NameError ([acc1323](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/acc1323a542b8e92433cabf1334d2d98bfa21e21))
|
||||
* NameError in set_user_devices_button — undefined action_text ([1b8ef69](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1b8ef69a1bbb7d8d86827cf7aaa4f05cbf480d75))
|
||||
* negative balance transfer, linking state validation, referrer migration ([531d5cf](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/531d5cff3019e72dde6ee64977cb801e8f8c8d0b))
|
||||
* normalize transaction amount signs across all aggregations ([4247981](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4247981c98111af388c98628c1e61f0517c57417))
|
||||
* nullify payment FK references before deleting transactions in user restoration ([0b86f37](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0b86f379b4e55e499ca3d189137e2aed865774b5))
|
||||
* partner system — CRUD nullable fields, per-campaign stats, atomic unassign, diagnostic logging ([ed3ae14](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ed3ae14d0c378fa0dc2d442c3aa5a70172f3132c))
|
||||
* pass return_url to all payment providers for guest purchases ([b85646a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b85646af85c4b2036f1c07c89e3e282f74d43c1e))
|
||||
* payment race conditions, balance atomicity, renewal rollback safety ([c5124b9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c5124b97b63eda59b52d2cbf9e2dcdaa6141ed6e))
|
||||
* photo handling in QR messages ([1afcd84](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1afcd84e0ed2c39abd674170b8b17e6c7ee8754d))
|
||||
* pre-existing bugs found during review ([1bb939f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1bb939f63a360a687fafba26bc363024df0f6be0))
|
||||
* pre-validate CABINET_BUTTON_STYLE to prevent invalid values from suppressing per-section defaults ([46c1a69](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/46c1a69456036cb1be784b8d952f27110e9124eb))
|
||||
* preserve connected_squads during subscription replacement cleanup ([d86c29a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d86c29a5d384db1d11ef3666153fa288d0c822d8))
|
||||
* preserve payment initiation time in transaction created_at ([90d9df8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/90d9df8f0e949913f09c4ebed8fe5280453ab3ab))
|
||||
* preserve purchased traffic when extending same tariff ([b167ed3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b167ed3dd1c6e6239db2bdbb8424bcb1fb7715d9))
|
||||
* prevent 'caption is too long' error in logo mode ([6e28a1a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6e28a1a22b02055b357051dfecbee7fefbebc774))
|
||||
* prevent cascading greenlet errors after sync rollback ([a1ffd5b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a1ffd5bda6b63145104ce750835d8e6492d781dc))
|
||||
* prevent concurrent device purchases exceeding max device limit ([1cfede2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1cfede28b7570bcaf77cb53d6b2a9f3b0e4e9408))
|
||||
* prevent daily subscriptions from being expired by middleware/CRUD/webhook ([0ed6397](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0ed6397fa9e5810fcffc9152ab2241fcf37cf85a))
|
||||
* prevent fileConfig from destroying structlog handlers ([e78b104](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e78b1040a50ac14759bceab396d0c3e34dd79cdd))
|
||||
* prevent infinite reuse of first_purchase_only promo code discounts ([2cec8dc](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2cec8dc4a487017f4b1c5ca80710f2d70045b825))
|
||||
* prevent negative amounts in spent display and balance history ([c30972f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c30972f6a7911a89a6c3f2080019ff465d11b597))
|
||||
* prevent partner self-referral via own campaign link ([115c0c8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/115c0c84c0698591da75d7d3b8fbd8e0fc8541ea))
|
||||
* prevent race condition expiring active daily subscriptions ([bfef7cc](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bfef7cc6296e296f17068e519469c3deaddc1b3b))
|
||||
* prevent self-referral loops, invalidate all sessions on merge ([db61365](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/db61365e11ccec4dd45671b33da00f4b05484589))
|
||||
* prevent squad drop on admin subscription type change, require subscription for wheel spins ([59f0e42](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/59f0e42be7e3c679d15cf2fc6820ab7097cd2201))
|
||||
* prevent sync from overwriting end_date for non-ACTIVE panel users ([49871f8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/49871f82f37d84979ea9ec91055e3f046d5854be))
|
||||
* prevent sync from overwriting subscription URLs with empty strings ([9c00479](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9c004791f28fbcf314b93c1b2a38593069605239))
|
||||
* promo code max_uses=0 conversion and trial UX after promo activation ([1cae713](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1cae7130bc87493ab8c7691b3c22ead8189dab55))
|
||||
* protect active paid subscriptions from being disabled in RemnaWave ([1b6bbc7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1b6bbc7131341b4afd739e4195f02aa956ead616))
|
||||
* protect server counter callers and fix tariff change detection ([bee4aa4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bee4aa42842b8b6611c7c268bcfced408a227bc0))
|
||||
* RBAC API response format fixes and audit log user info ([4598c27](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4598c2785a42773ee8be04ada1c00d14824e07e0))
|
||||
* RBAC audit log action filter and legacy admin level ([c1da8a4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c1da8a4dba5d0c993d3e15b2866bdcfa09de1752))
|
||||
* read discount overrides from landing model instead of response DTO ([6d65e15](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6d65e152669a7e92f93f592993c1d5507b890046))
|
||||
* read OIDC enabled setting from DB in auth endpoint ([2405dc5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2405dc5c1b6d6266da373e0e4dac6444b0e70a03))
|
||||
* reassign orphaned records on merge, eliminate TOCTOU race ([d7a9d2b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d7a9d2bfba5b796882d3e04be6038b766cd0a4c8))
|
||||
* redis cache uses sync client due to import shadowing ([667291a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/667291a2dcaeae21e27eeb6376085e69caa4e45a))
|
||||
* reject promo codes for days when user has no subscription or trial ([e32e2f7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e32e2f779d014d587b58d63b513fd913ae1b7a41))
|
||||
* remove [@username](https://github.com/username) channel ID input, auto-prefix -100 for bare digits ([a7db469](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a7db469fd7603e7d8dac3076f5d633da654a3a57))
|
||||
* remove decorative cloudpayments sub-options ([694aecc](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/694aeccc3121116bf193b5766572de7472eb4016))
|
||||
* remove DisplayNameRestrictionMiddleware ([640da34](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/640da3473662cfdcceaa4346729467600ac3b14f))
|
||||
* remove executable bit from email_service.py ([372d628](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/372d628908294d905c37828219cac6aef7941151))
|
||||
* remove gemini-effect and noise from allowed background types ([731eb24](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/731eb2436428d0e12f1e5ccdebc72cd74fd7c65e))
|
||||
* remove local UTC re-imports shadowing module-level import in purchase.py ([e68760c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e68760cc668016209f4f19a2e08af8680343d6ed))
|
||||
* remove premature tariff_id assignment in _apply_extension_updates ([b47678c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b47678cfb0ba5897b37dfe1f94e3d1336af5698e))
|
||||
* remove redundant trial inactivity monitoring checks ([d712ab8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d712ab830166cab61ce38dd32498a8a9e3e602b0))
|
||||
* remove subscription connection links from guest purchase emails ([9217352](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9217352685189118620f1246bc7d7a4459883ed6))
|
||||
* remove unused PaymentService from MonitoringService init ([491a7e1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/491a7e1c425a355e55b3020e2bcc7b96047bdf5e))
|
||||
* renewals stats empty on all-time filter ([e25fcfc](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e25fcfc6ef941465b83f368f152304ea5a6747d9))
|
||||
* reorder button_click_logs migration to nullify before ALTER TYPE ([df5415f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/df5415f30b2aae4412ff5fbd3cac8076128b818c))
|
||||
* repair missing DB columns and make backup resilient to schema mismatches ([c20355b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c20355b06df13328f85cc5a6045b3e490419a30a))
|
||||
* replace deprecated Query(regex=) with pattern= ([871ceb8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/871ceb866ccf1f3a770c7ef33406e1a43d0a7ff7))
|
||||
* reset QR photo when returning to referral ([3ee108f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3ee108fce85962dde5bc6c80b3464278369da9f5))
|
||||
* reset traffic purchases on expired subscription renewal + pricing fixes ([dce9eaa](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/dce9eaa5971cb1dc0945747e02397a250e8e411b))
|
||||
* resolve deadlock on server_squads counter updates and add webhook notification toggles ([57dc1ff](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/57dc1ff47f2f6183351db7594544a07ca6f27250))
|
||||
* resolve exc_info for admin notifications, clean log formatting ([11f8af0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/11f8af003fc60384abafa2b670b89d6ad3ac57a4))
|
||||
* resolve GROUP BY mismatch for daily_by_tariff query ([e5f29eb](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e5f29eb041e88bc6315f0b4da3b78898d9dd7fff))
|
||||
* resolve HIGH-priority performance and security issues in partner system ([fcf3a2c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fcf3a2c8062752b2b1dc06b5993ac2d8ae80ee85))
|
||||
* resolve MissingGreenlet error when accessing subscription.tariff ([a93a32f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a93a32f3a7d1b259a2e24954ae5d2b7c966c5639))
|
||||
* resolve ruff lint errors (import sorting, unused variable) ([b2d7abf](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b2d7abf5bd10a98fd7ad1da50b5072afc65a5b48))
|
||||
* resolve sync 404 errors, user deletion FK constraint, and device limit not sent to RemnaWave ([1ce9174](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1ce91749aa12ffcefcf66bea714cea218739f3fe))
|
||||
* restore merge token on DB failure, fix partner_status priority ([9582758](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9582758d1c85735c8ead8cbfeb56bbdae45288af))
|
||||
* restore panel user discovery on admin tariff change, localize cart reminder ([1256ddc](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1256ddcd1a772f90e7bdf9437043a47ea9d84d53))
|
||||
* restore RemnaWave config management endpoints ([6f473de](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6f473defef32a6d81cee55ef2cd397d536a784a7))
|
||||
* restore subscription_url and crypto_link after panel sync ([26efb15](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/26efb157e476a18b036d09167628a295d7e4c10b))
|
||||
* return zeroed stats dict when withdrawal is disabled ([7883efc](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7883efc3d6e6d8bedf8e4b7d72634cbab6e2f3d7))
|
||||
* review findings — exception chaining, redundant unquote, validator tightening ([467dea1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/467dea1315fbaf8d09ccbba292cd0bcc60d9f3ab))
|
||||
* safe HTML preview truncation and lazy-load subscription fallback ([40d8a6d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/40d8a6dc8baf3f0f7c30b0883898b4655a907eb5))
|
||||
* second round review fixes for account merge ([64ee045](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/64ee0459e4e3d3fe87ad65387fcbcb147147ac1b))
|
||||
* security and architecture fixes for webhook handlers ([dc1e96b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/dc1e96bbe9b4496e91e9dea591c7fc0ef4cc245b))
|
||||
* separate base and purchased traffic in renewal pricing ([739ba29](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/739ba2986f41b04058eb14e8b87b0699fe96f922))
|
||||
* show negative amounts for withdrawals in admin transaction list ([5ee45f9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5ee45f97d179ce2d32b3f19eeb6fd01989a30ca7))
|
||||
* skip blocked users in trial notifications and broadcasts without DB status change ([493f315](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/493f315a65610826a04e04c3d2065e0b395426ed))
|
||||
* skip users with active subscriptions in admin inactive cleanup ([e79f598](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e79f598d17ffa76372e6f88d2a498accf8175c76))
|
||||
* specify foreign_keys on User.admin_roles_rel to resolve ambiguous join ([bc7d061](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bc7d0612f1476f2fdb498cd76a9374b41fd9440a))
|
||||
* stack promo group + promo offer discounts in bot (matching cabinet) ([628997f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/628997fb48413cc4fae9ac491d1c7f6185877200))
|
||||
* stop CryptoBot webhook retry loop and save cabinet payments to DB ([2cb6d73](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2cb6d731e96cbfc305b098d8424b84bfd6826fb4))
|
||||
* suppress 'message is not modified' error in updates panel ([3a680b4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3a680b41b0124848572809d187cab720e1db8506))
|
||||
* suppress bot-blocked-by-user error in AuthMiddleware ([fda9f3b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fda9f3beecbfcca4d7abc16cf661d5ad5e3b5141))
|
||||
* suppress expired callback query error in AuthMiddleware ([2de4384](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2de438426a647e2bcae9b4d99eef4093ff8b5429))
|
||||
* suppress startup log noise (~350 lines → ~30) ([8a6650e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8a6650e57cd8ea396d9b057a7753469947f38d29))
|
||||
* suppress web page preview when logo mode is disabled ([1f4430f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1f4430f3af8f3efcc58ef7b562904adcb1640a44))
|
||||
* sync subscription status from panel in user.modified webhook ([5156d63](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5156d635f0b5bc0493e8f18ce9710cca6ff4ffc8))
|
||||
* sync support mode from cabinet admin to SupportSettingsService ([516be6e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/516be6e600a08ad700d83b793dc64b2ca07bdf44))
|
||||
* sync SUPPORT_SYSTEM_MODE between SystemSettings and SupportSettings ([0807a9f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0807a9ff19d1eb4f1204f7cbeb1da1c1cfefe83a))
|
||||
* sync traffic reset across all tariff switch code paths ([d708365](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d708365aca9dfd5c3afda1a1de4303e0bd1d263e))
|
||||
* sync uv.lock version with pyproject.toml 3.23.1 ([8eb6a8c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8eb6a8c4606a0ea48e383c031ad83219fc8e062b))
|
||||
* sync uv.lock version with pyproject.toml 3.23.1 ([bc52fd2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bc52fd27113f95a4154b1990142d46ae606fd2e0))
|
||||
* ticket creation crash and webhook PendingRollbackError ([760c833](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/760c833b7402541d3c7cf2ed7fc0418119e75042))
|
||||
* traceback in Telegram notifications + reduce log padding ([909a403](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/909a4039c43b910761bd05c36e79c8e6773199db))
|
||||
* transaction boundary and CORS in webapi ([6495384](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6495384bcfd76c377971438f6c132f1404ea1f7d))
|
||||
* translate required channels handler to Russian, add localization keys ([1bc9074](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1bc9074c1bcdaba7215065c77aac9dd51db4d7c8))
|
||||
* treat empty icon_url as None in payment method validation ([ab981dc](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ab981dce0d84bba3df5fc4366e39ba3ed0adeccd))
|
||||
* unassign all campaigns when revoking partner status ([d39063b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d39063b22ffb6442e275db39704361cdb9251793))
|
||||
* UnboundLocalError for get_logo_media in required_sub_channel_check ([d3c14ac](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d3c14ac30363839d1340129f279a7a7b4b021ed1))
|
||||
* UniqueViolation при мерже аккаунтов с общим OAuth/telegram/email ID ([1c89bd8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1c89bd8b2acfe49de2c97dd75446a037a54fded7))
|
||||
* uploaded backup restore button not triggering handler ([ebe5083](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ebe508302b906f8b56cb230b934fb8566990c684))
|
||||
* use .is_(True) and add or 0 guards per code review ([69b5ca0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/69b5ca06701e7381c39448e2bf6b927f0558058c))
|
||||
* use actual DB columns for subscription fallback query ([f0e7f8e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f0e7f8e3bec27d97a3f22445948b8dde37a92438))
|
||||
* use aiogram 3.x bot.download() instead of document.download() ([205c8d9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/205c8d987d93151a17aa0793cb51bd99917aea97))
|
||||
* use AwareDateTime TypeDecorator for all datetime columns ([a7f3d65](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a7f3d652c51ecd653900a530b7d38feaf603ecf1))
|
||||
* use callback fallback when MINIAPP_CUSTOM_URL is not set ([eaf3a07](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/eaf3a07579729031030308d77f61a5227b796c02))
|
||||
* use direct is_trial access, add missing error codes to promo APIs ([69a9899](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/69a9899d40dda83e83cbdba1aa43d9d1f756704b))
|
||||
* use event field directly as event_name (already includes scope prefix) ([9aa22af](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9aa22af3390a249d1b500d75a7d7189daaed265e))
|
||||
* use float instead of int | float (PYI041) ([310edae](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/310edae013973d8533051088f3720cc5da3651b5))
|
||||
* use flush instead of commit in server counter functions ([6cec024](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6cec024e46ef9177cb59aa81590953c9a75d81bb))
|
||||
* use get_rendered_override for proper variable substitution in guest email overrides ([c165cca](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c165cca3239c9a1249aae9e5e712f7e34fb01107))
|
||||
* use SAVEPOINT instead of full rollback in sync user creation ([2a90f87](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2a90f871b97b2b7ee8289e62294c65f8becb2539))
|
||||
* use selection.period.days instead of selection.period_days ([4541016](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/45410168afe683675003a1c41c17074a54ce04f1))
|
||||
* use short TTL fallback in restore_merge_token on parse error ([0e8c61a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0e8c61a7762ae796284144056c0cbdbcb53b6c7c))
|
||||
* use sync context manager for structlog bound_contextvars ([25e8c9f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/25e8c9f8fc4d2c66d5a1407d3de5c7402dc596da))
|
||||
* use traffic topup config and add WATA 429 retry ([b5998ea](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b5998ea9d22644ed2914b0e829b3a76a32a69ddf))
|
||||
* validate payment sub-option suffix and harden payment method handling ([5f01783](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5f01783dcb63f2f8bc20fef935d74d7588273aea))
|
||||
* webhook notification 'My Subscription' button uses unregistered callback_data ([1e2a7e3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1e2a7e3096af11540184d60885b8c08d73506c4a))
|
||||
* webhook:close button not working due to channel check timeout ([019fbc1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/019fbc12b6cf61d374bbed4bce3823afc60445c9))
|
||||
* wrap user deletion steps in savepoints to prevent transaction cascade abort ([a38dfcb](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a38dfcb75a47a185d979a8202f637d8b79812e67))
|
||||
* безопасность и качество кода лендингов — 16 исправлений ([ef45095](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ef450955e6b37d437dabac55da037f53ca1f75dc))
|
||||
* гарантировать положительный доход от подписок и исправить общий доход ([93a55df](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/93a55df4c0ac099946d440ec79fefb24327ab0e1))
|
||||
* дедупликация promocode_uses при мерже аккаунтов ([00a7db2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/00a7db26905d53a9a978aaf6b97800ca3042b957))
|
||||
* добавить create_transaction для 6 потоков оплаты с баланса ([374907b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/374907b6078c483531061465983e23f281e841a2))
|
||||
* добавить create_transaction и admin-уведомления для автопродлений ([9f35088](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9f35088788c971cb757936dba7214abe54477af0))
|
||||
* добавить ON DELETE CASCADE/SET NULL на все FK к users.id ([34c82c3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/34c82c348829cf528154bd1e2f5d77006d7ed5da))
|
||||
* добавить пробелы в формат тарифов (1000 ГБ / 2 📱) ([900be65](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/900be65617dd5bbc6ffdcc82bb5504e1a93ead95))
|
||||
* дубликаты системных ролей при переименовании и сброс permissions ([7a7fb71](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7a7fb71bf535e2a501f0677747ba63ca0b27ede5))
|
||||
* изолировать stored_amount от downstream consumers в create_transaction ([b87535a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b87535ad4842cbf1f99f6fc1e28b5932fa5e3baa))
|
||||
* исправления системы реферальных конкурсов ([6713b34](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6713b3497854e73dddc212280d7bf12db818f38a))
|
||||
* кнопка «Назад» в тарифах ведёт в админ панель, а не в настройки ([04562fd](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/04562fd7e74de26776517549730819389b24a0d0))
|
||||
* миграция 0016 падает если FK constraint отсутствует в БД ([15fe45d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/15fe45d11341001714599f8db963d182dc371aa3))
|
||||
* миграция 0021 — drop server_default перед сменой типа на JSON ([3d3bb3b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3d3bb3badb55511960ed9b2a29ea67e0f0c3f26c))
|
||||
* передать явный диапазон дат для all_time_stats в дашборде ([968d147](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/968d14704610eed528bca28cbf295c1ba1644a5a))
|
||||
* показывать кнопку покупки тарифа вместо ошибки для триальных подписок ([acfa4b3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/acfa4b3c2ea96e74d93470085265df76ec50e1e6))
|
||||
* показывать только активные провайдеры на странице /profile/accounts ([9d7a557](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9d7a557ef0e294ce9920e9953bb1358656ff9b81))
|
||||
* промокоды — конвертация триалов, race condition, savepoints ([7fb839a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7fb839aef6234294b95064f9575c19d5a0c3f892))
|
||||
* реактивация DISABLED подписок при покупке трафика для LIMITED пользователей ([7d28f55](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7d28f5516a52606280219cbea846fba431da80d2))
|
||||
* реактивация DISABLED подписок при покупке устройств и в REST API ([b9e17be](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b9e17be8554a65eaf765a0b5b36fee062205c66f))
|
||||
* синхронизация версии pyproject.toml с main и обновление uv в Dockerfile ([b31a893](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b31a893b13b2db911e51298ceb0107419f9a4cb3))
|
||||
* убрать WITHDRAWAL из автонегации, добавить abs() в агрегации, исправить all_time_stats ([6da61d7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6da61d79510f7e05310f3cc020515b4dd0b3eb34))
|
||||
* убрать избыточный минус в amount_kopeks для create_transaction ([849b3a7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/849b3a7034f2291db40e049c12e1b7c71b58bab1))
|
||||
* устранение race condition при покупке устройств через re-lock после коммита ([a7a18dd](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a7a18dd0d1d59c64f7e4dd3ddc1b8cec47198077))
|
||||
* устранение race conditions и атомарность платёжной системы ([4984f20](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4984f20e8fb030ee338723d797d51aee21f67ca8))
|
||||
* устранение каскадного PendingRollbackError при восстановлении бэкапа ([8259278](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/82592784d0da8b8718f3b3aa34076af59ad2a878))
|
||||
|
||||
|
||||
### Performance
|
||||
|
||||
* cache logo file_id to avoid re-uploading on every message ([142ff14](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/142ff14a502e629446be7d67fab880d12bee149d))
|
||||
|
||||
|
||||
### Refactoring
|
||||
|
||||
* complete structlog migration with contextvars, kwargs, and logging hardening ([1f0fef1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1f0fef114bd979b2b0d2bd38dde6ce05e7bba07b))
|
||||
* extract shared OAuth linking logic, add Literal types for providers ([f7caf0d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f7caf0de709ca6a46283f0b1928e34f8908f2c93))
|
||||
* improve log formatting — logger name prefix and table alignment ([f637204](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f63720467a935bdaaa58bb34d588d65e46698f26))
|
||||
* remove "both" mode from BOT_RUN_MODE, keep only polling and webhook ([efa3a5d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/efa3a5d4579f24dabeeba01a4f2e981144dd6022))
|
||||
* remove Flask, use FastAPI exclusively for all webhooks ([119f463](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/119f463c36a95685c3bc6cdf704e746b0ba20d56))
|
||||
* remove legacy app-config.json system ([295d2e8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/295d2e877e43f48e9319ba0b01be959904637000))
|
||||
* remove modem functionality from classic subscriptions ([ee2e79d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ee2e79db3114fe7a9852d2cd33c4b4fbbde311ea))
|
||||
* remove smart auto-activation & activation prompt, fix production bugs ([a3903a2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a3903a252efdd0db4b42ca3fd6771f1627050a7f))
|
||||
* replace universal_migration.py with Alembic ([b6c7f91](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b6c7f91a7c79d108820c9f89c9070fde4843316c))
|
||||
* replace universal_migration.py with Alembic ([784616b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/784616b349ef12b35ee021dd7a7b2a2ef9fc57f6))
|
||||
|
||||
## [3.23.2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.23.1...v3.23.2) (2026-03-06)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* device_limit fallback 1→0 для корректного отображения безлимита ([3e26832](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3e26832e745368a0dab2617e4e8ae2c410c6bca2))
|
||||
* sync uv.lock version with pyproject.toml 3.23.1 ([8eb6a8c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8eb6a8c4606a0ea48e383c031ad83219fc8e062b))
|
||||
* sync uv.lock version with pyproject.toml 3.23.1 ([bc52fd2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bc52fd27113f95a4154b1990142d46ae606fd2e0))
|
||||
* миграция 0016 падает если FK constraint отсутствует в БД ([15fe45d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/15fe45d11341001714599f8db963d182dc371aa3))
|
||||
|
||||
## [3.23.1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.23.0...v3.23.1) (2026-03-06)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* complete FK migration — add 27 missing constraints, fix broadcast_history nullable ([fe393d2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fe393d2ca6ce302d8213cc751842ea92ef277e76))
|
||||
* UniqueViolation при мерже аккаунтов с общим OAuth/telegram/email ID ([1c89bd8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1c89bd8b2acfe49de2c97dd75446a037a54fded7))
|
||||
* дедупликация promocode_uses при мерже аккаунтов ([00a7db2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/00a7db26905d53a9a978aaf6b97800ca3042b957))
|
||||
* добавить ON DELETE CASCADE/SET NULL на все FK к users.id ([34c82c3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/34c82c348829cf528154bd1e2f5d77006d7ed5da))
|
||||
* дубликаты системных ролей при переименовании и сброс permissions ([7a7fb71](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7a7fb71bf535e2a501f0677747ba63ca0b27ede5))
|
||||
* исправления системы реферальных конкурсов ([6713b34](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6713b3497854e73dddc212280d7bf12db818f38a))
|
||||
* кнопка «Назад» в тарифах ведёт в админ панель, а не в настройки ([04562fd](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/04562fd7e74de26776517549730819389b24a0d0))
|
||||
* промокоды — конвертация триалов, race condition, savepoints ([7fb839a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7fb839aef6234294b95064f9575c19d5a0c3f892))
|
||||
* устранение race conditions и атомарность платёжной системы ([4984f20](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4984f20e8fb030ee338723d797d51aee21f67ca8))
|
||||
|
||||
## [3.23.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.22.0...v3.23.0) (2026-03-05)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* account linking and merge system for cabinet ([dc7b8dc](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/dc7b8dc72a3a398d6270a0a2b8ce9e2b54cb9af7))
|
||||
* account merge system — atomic user merge with full FK coverage ([2664b49](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2664b4956d8436a2720d7cd5992b8cdbb72cdbd9))
|
||||
* add dedicated sales_stats RBAC permission section ([8f29e2e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8f29e2eee2e0c78f7f7e87a322eaf4bd4221069c))
|
||||
* add server-complete OAuth linking endpoint for Mini App flow ([f867989](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f867989557d20378cfe815c9c88e1a842c4f6654))
|
||||
* add Telegram account linking endpoint with security hardening ([da40d56](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/da40d5662d6d064090769823d616d6f9748ab5b9))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* abs() for transaction amounts in admin notifications and subscription events ([fd139b2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fd139b28a2c45cc3fbd2e01707fb83fbabf57c71))
|
||||
* add abs() to expenses query, display flip, contest stats, and recent payments ([de6f806](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/de6f80694ba8aa240764e2769ec04c16fe7f3672))
|
||||
* add IntegrityError handling on link commit and format fixes ([0c1dc58](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0c1dc580c67254d11ffb096c22d8c8d78ac18e2b))
|
||||
* add missing mark_as_paid_subscription, fix operation order, remove dead code ([5f2d855](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5f2d855702dea838b38887a5f44b9ad759acd5cf))
|
||||
* auto-update permissions for system roles on bootstrap ([eff74be](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/eff74bed5bcc47a6cfa05c20cad14a40c1572d1f))
|
||||
* centralize balance deduction and fix unchecked return values ([0466528](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0466528925a24087b8522a10cbb11c947c2b7d91))
|
||||
* centralize has_had_paid_subscription into subtract_user_balance ([e4a6aad](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e4a6aad621be7ef4e7aedb21373927ede0c8d0a5))
|
||||
* clean email verification and password fields from secondary user during merge ([7b4e948](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7b4e9488f6fbd1271f063579e48ca9a3c96cb645))
|
||||
* consume promo offer in miniapp tariff-mode renewal path ([b8857e7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b8857e789ef60cf0c8766abbeadd094f62070a61))
|
||||
* consume promo offer in tariff_purchase.py, fix negative transaction amount ([c8ef808](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c8ef80853915af3e3eb254edd07d8d78b66a9282))
|
||||
* delete cross-referral earnings before bulk reassignment, clear secondary.referred_by_id ([f204b67](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f204b678803297ce60faad628d16f46344b11ed0))
|
||||
* from redis.exceptions import NoScriptError ([667291a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/667291a2dcaeae21e27eeb6376085e69caa4e45a))
|
||||
* harden account merge security and correctness ([d855e9e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d855e9e47fab1a038e581437a9921bdfeb11e927))
|
||||
* **merge:** validate before consuming token, add flush, defensive balance ([bc1e6fb](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bc1e6fb22c6e23c7a34364796f51a55c60224aff))
|
||||
* negative balance transfer, linking state validation, referrer migration ([531d5cf](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/531d5cff3019e72dde6ee64977cb801e8f8c8d0b))
|
||||
* prevent concurrent device purchases exceeding max device limit ([1cfede2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1cfede28b7570bcaf77cb53d6b2a9f3b0e4e9408))
|
||||
* prevent infinite reuse of first_purchase_only promo code discounts ([2cec8dc](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2cec8dc4a487017f4b1c5ca80710f2d70045b825))
|
||||
* prevent self-referral loops, invalidate all sessions on merge ([db61365](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/db61365e11ccec4dd45671b33da00f4b05484589))
|
||||
* reassign orphaned records on merge, eliminate TOCTOU race ([d7a9d2b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d7a9d2bfba5b796882d3e04be6038b766cd0a4c8))
|
||||
* redis cache uses sync client due to import shadowing ([667291a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/667291a2dcaeae21e27eeb6376085e69caa4e45a))
|
||||
* restore merge token on DB failure, fix partner_status priority ([9582758](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9582758d1c85735c8ead8cbfeb56bbdae45288af))
|
||||
* review findings — exception chaining, redundant unquote, validator tightening ([467dea1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/467dea1315fbaf8d09ccbba292cd0bcc60d9f3ab))
|
||||
* second round review fixes for account merge ([64ee045](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/64ee0459e4e3d3fe87ad65387fcbcb147147ac1b))
|
||||
* use short TTL fallback in restore_merge_token on parse error ([0e8c61a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0e8c61a7762ae796284144056c0cbdbcb53b6c7c))
|
||||
* гарантировать положительный доход от подписок и исправить общий доход ([93a55df](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/93a55df4c0ac099946d440ec79fefb24327ab0e1))
|
||||
* добавить create_transaction для 6 потоков оплаты с баланса ([374907b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/374907b6078c483531061465983e23f281e841a2))
|
||||
* добавить create_transaction и admin-уведомления для автопродлений ([9f35088](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9f35088788c971cb757936dba7214abe54477af0))
|
||||
* добавить пробелы в формат тарифов (1000 ГБ / 2 📱) ([900be65](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/900be65617dd5bbc6ffdcc82bb5504e1a93ead95))
|
||||
* изолировать stored_amount от downstream consumers в create_transaction ([b87535a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b87535ad4842cbf1f99f6fc1e28b5932fa5e3baa))
|
||||
* передать явный диапазон дат для all_time_stats в дашборде ([968d147](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/968d14704610eed528bca28cbf295c1ba1644a5a))
|
||||
* показывать кнопку покупки тарифа вместо ошибки для триальных подписок ([acfa4b3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/acfa4b3c2ea96e74d93470085265df76ec50e1e6))
|
||||
* показывать только активные провайдеры на странице /profile/accounts ([9d7a557](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9d7a557ef0e294ce9920e9953bb1358656ff9b81))
|
||||
* реактивация DISABLED подписок при покупке трафика для LIMITED пользователей ([7d28f55](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7d28f5516a52606280219cbea846fba431da80d2))
|
||||
* реактивация DISABLED подписок при покупке устройств и в REST API ([b9e17be](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b9e17be8554a65eaf765a0b5b36fee062205c66f))
|
||||
* синхронизация версии pyproject.toml с main и обновление uv в Dockerfile ([b31a893](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b31a893b13b2db911e51298ceb0107419f9a4cb3))
|
||||
* убрать WITHDRAWAL из автонегации, добавить abs() в агрегации, исправить all_time_stats ([6da61d7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6da61d79510f7e05310f3cc020515b4dd0b3eb34))
|
||||
* убрать избыточный минус в amount_kopeks для create_transaction ([849b3a7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/849b3a7034f2291db40e049c12e1b7c71b58bab1))
|
||||
* устранение race condition при покупке устройств через re-lock после коммита ([a7a18dd](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a7a18dd0d1d59c64f7e4dd3ddc1b8cec47198077))
|
||||
* устранение каскадного PendingRollbackError при восстановлении бэкапа ([8259278](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/82592784d0da8b8718f3b3aa34076af59ad2a878))
|
||||
|
||||
|
||||
### Refactoring
|
||||
|
||||
* extract shared OAuth linking logic, add Literal types for providers ([f7caf0d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f7caf0de709ca6a46283f0b1928e34f8908f2c93))
|
||||
|
||||
## [3.22.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.21.0...v3.22.0) (2026-03-04)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* replace pip with uv in Dockerfile ([e23d69f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e23d69fcec7ab65a14b054fd46f6ecf87ae6fd13))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add selectinload for campaign registrations in list query ([4d74afd](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4d74afd7118524623371f904a93ae1fcbba8d64e))
|
||||
* backup restore fails on FK constraints and transaction poisoning ([ff1c872](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ff1c8722c9188fdbaf765d6b7e9192686df64850))
|
||||
* classic mode prices overridden by active tariff prices ([628a99e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/628a99e7aa0812842dabc430857190c0cd5c2680))
|
||||
* close remaining daily subscription expire paths ([618c936](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/618c936ac9ce4904cd784bf2278d3da188895f2d))
|
||||
* empty JSONB values exported as None in backup ([57aaca8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/57aaca82f5bf9d7bdd9d4b924aa3412d85eccbb5))
|
||||
* handle duplicate remnawave_uuid on email sync ([eaeee7a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/eaeee7a765c03ff33e2928cdb41be91948eca95c))
|
||||
* MissingGreenlet on campaign registrations access ([018f18f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/018f18fa0c9bba1a1dbca8b2398b9611d0c94c36))
|
||||
* prevent daily subscriptions from being expired by middleware/CRUD/webhook ([0ed6397](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0ed6397fa9e5810fcffc9152ab2241fcf37cf85a))
|
||||
* reset traffic purchases on expired subscription renewal + pricing fixes ([dce9eaa](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/dce9eaa5971cb1dc0945747e02397a250e8e411b))
|
||||
|
||||
## [3.21.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.20.1...v3.21.0) (2026-03-02)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* add admin campaign chart data endpoint with deposits/spending split ([fa7de58](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fa7de589c1bd0ae37ebaaa07bae0ed3d68e01720))
|
||||
* add admin sales statistics API with 6 analytics endpoints ([58faf9e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/58faf9eaeca63c458093d2a5e74a860f57712ab0))
|
||||
* add daily deposits by payment method breakdown ([d33c5d6](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d33c5d6c07ce4a9efaf3c5aceb448e968e1b8ed7))
|
||||
* add daily device purchases chart to addons stats ([2449a5c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2449a5cbbe5179a762197414a5752896383a6ee4))
|
||||
* add desired commission percent to partner application ([7ea8fbd](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7ea8fbd584aff2127595001094ef69acb52f847f))
|
||||
* add RESET_TRAFFIC_ON_TARIFF_SWITCH admin setting ([4eaedd3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4eaedd33bf697469fe9ed6a1bfe8b59ca43b46fb))
|
||||
* enhance sales stats with device purchases, per-tariff daily breakdown, and registration tracking ([31c7e2e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/31c7e2e9c14cb88762a62a72e4f65051e0c6c1fd))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add exc_info traceback to sync user error log ([efdf2a3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/efdf2a3189a2f790e570f9a6e19d91469be4ea4f))
|
||||
* add local traffic_used_gb reset in all tariff switch handlers ([2cdbbc0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2cdbbc09ba9a19dcb720049ffde08ba780ac5751))
|
||||
* add min_length to state field, use exc_info for referral warning ([062c486](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/062c4865db194f9d2242772044402fa2711a69bd))
|
||||
* add missing subscription columns migration ([b96e819](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b96e819da4cc37710e9fc17467045b33bcffac4d))
|
||||
* address review findings from agent verification ([cc5be70](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/cc5be7059fdf4cefb01e97196c825b217f8b54b3))
|
||||
* correct cart notification after balance top-up ([2fab50c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2fab50c340c885fc92a4bf797a4b03da6e44af31))
|
||||
* correct referral withdrawal balance formula and commission transaction type ([83c6db4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/83c6db48349440447305604e944fa440bdceb3fb))
|
||||
* count sales from completed payment transactions instead of subscription created_at ([06c3996](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/06c3996da4fa14eafb294651158068c7cda51e52))
|
||||
* eliminate double panel API call on tariff change, harden cart notification ([b2cf4aa](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b2cf4aaa91f3fb63dca7e70645cadb75aa158cfe))
|
||||
* eliminate referral system inconsistencies ([60c97f7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/60c97f778bc4cc18aaf4d8a31826bc831c3b3f8f))
|
||||
* email verification bypass, ban-notifications size limit, referral balance API ([256cbfc](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/256cbfcadfd2fc88d8de69557c78618639af157d))
|
||||
* enforce user restrictions in cabinet API and fix poll history crash ([faba3a8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/faba3a8ed6d428305f9ca7d7fd9bdcc1fd72ba52))
|
||||
* freekassa OP-SP-7 error and missing telegram notification ([200f91e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/200f91ef1748bb6213d1ef3a8e83ae976290a8a7))
|
||||
* generate missing crypto link on the fly and skip unresolved templates ([4c72058](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4c72058d4ad8b0594991b17323928d9004803bfa))
|
||||
* handle expired callback queries and harden middleware error handling ([f52e6ae](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f52e6aedac3de1c9bb2ad1a5a16b06d38b79ab63))
|
||||
* handle expired ORM attributes in sync UUID mutation ([9ae5d7b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9ae5d7bb60c57e2c29d6f3c5098c23450d5feb61))
|
||||
* handle NULL used_promocodes for migrated users ([cdcabee](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/cdcabee80d1d7f0b367a97cdec20bb49e8592115))
|
||||
* hide traffic topup button when tariff doesn't support it ([399ca86](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/399ca86561f4271e9c542bac87c0dd2931a223e0))
|
||||
* improve campaign routes, schemas, and add database indexes ([ded5c89](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ded5c899f7425707b17fef4d0d5ceafac777ef08))
|
||||
* include desired_commission_percent in admin notification ([dc3d22f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/dc3d22f52db40150d595bccf524d38790e5725d9))
|
||||
* migrate VK OAuth to VK ID OAuth 2.1 with PKCE ([1dfa780](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1dfa78013c4fb926a2b32bf4d63baa28215e7340))
|
||||
* partner system — CRUD nullable fields, per-campaign stats, atomic unassign, diagnostic logging ([ed3ae14](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ed3ae14d0c378fa0dc2d442c3aa5a70172f3132c))
|
||||
* prevent squad drop on admin subscription type change, require subscription for wheel spins ([59f0e42](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/59f0e42be7e3c679d15cf2fc6820ab7097cd2201))
|
||||
* prevent sync from overwriting subscription URLs with empty strings ([9c00479](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9c004791f28fbcf314b93c1b2a38593069605239))
|
||||
* reject promo codes for days when user has no subscription or trial ([e32e2f7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e32e2f779d014d587b58d63b513fd913ae1b7a41))
|
||||
* remove premature tariff_id assignment in _apply_extension_updates ([b47678c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b47678cfb0ba5897b37dfe1f94e3d1336af5698e))
|
||||
* renewals stats empty on all-time filter ([e25fcfc](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e25fcfc6ef941465b83f368f152304ea5a6747d9))
|
||||
* resolve GROUP BY mismatch for daily_by_tariff query ([e5f29eb](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e5f29eb041e88bc6315f0b4da3b78898d9dd7fff))
|
||||
* restore panel user discovery on admin tariff change, localize cart reminder ([1256ddc](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1256ddcd1a772f90e7bdf9437043a47ea9d84d53))
|
||||
* separate base and purchased traffic in renewal pricing ([739ba29](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/739ba2986f41b04058eb14e8b87b0699fe96f922))
|
||||
* sync traffic reset across all tariff switch code paths ([d708365](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d708365aca9dfd5c3afda1a1de4303e0bd1d263e))
|
||||
* use .is_(True) and add or 0 guards per code review ([69b5ca0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/69b5ca06701e7381c39448e2bf6b927f0558058c))
|
||||
* use direct is_trial access, add missing error codes to promo APIs ([69a9899](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/69a9899d40dda83e83cbdba1aa43d9d1f756704b))
|
||||
* use float instead of int | float (PYI041) ([310edae](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/310edae013973d8533051088f3720cc5da3651b5))
|
||||
* use SAVEPOINT instead of full rollback in sync user creation ([2a90f87](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2a90f871b97b2b7ee8289e62294c65f8becb2539))
|
||||
|
||||
## [3.20.1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.20.0...v3.20.1) (2026-02-25)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* make migrations 0010/0011 idempotent, escape HTML in crash notification ([a696896](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a696896d2c4a3d0d6026398fcdc76ded9575375d))
|
||||
* prevent race condition expiring active daily subscriptions ([bfef7cc](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bfef7cc6296e296f17068e519469c3deaddc1b3b))
|
||||
|
||||
## [3.20.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.19.0...v3.20.0) (2026-02-25)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* add separate Freekassa SBP and card payment methods ([0da0c55](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0da0c5547d0648a70f848fe77c13d583f4868a52))
|
||||
* add validation to animation config API ([a15403b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a15403b8b6e1ec1bb5c37fdde646e7790373e860))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* initialize logger in bot_configuration.py ([988d0e5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/988d0e5c2f27538135d757187a0b6770f078b1d9))
|
||||
* remove gemini-effect and noise from allowed background types ([731eb24](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/731eb2436428d0e12f1e5ccdebc72cd74fd7c65e))
|
||||
* resolve ruff lint errors (import sorting, unused variable) ([b2d7abf](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b2d7abf5bd10a98fd7ad1da50b5072afc65a5b48))
|
||||
* resolve sync 404 errors, user deletion FK constraint, and device limit not sent to RemnaWave ([1ce9174](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1ce91749aa12ffcefcf66bea714cea218739f3fe))
|
||||
|
||||
## [3.19.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.18.0...v3.19.0) (2026-02-25)
|
||||
|
||||
|
||||
|
||||
+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 тесты
|
||||
|
||||
+16
-17
@@ -4,27 +4,27 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gcc \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN python -m venv /opt/venv
|
||||
ENV PATH="/opt/venv/bin:$PATH"
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.10.8 /uv /uvx /bin/
|
||||
|
||||
COPY requirements.txt .
|
||||
ENV UV_COMPILE_BYTECODE=1 \
|
||||
UV_LINK_MODE=copy \
|
||||
UV_PYTHON_DOWNLOADS=never
|
||||
|
||||
RUN pip install --no-cache-dir --upgrade pip && \
|
||||
pip install --no-cache-dir -r requirements.txt
|
||||
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 --frozen --no-dev
|
||||
|
||||
FROM python:3.13-slim
|
||||
|
||||
ARG VERSION="v3.19.0" # x-release-please-version
|
||||
ARG VERSION="v3.32.3" # x-release-please-version
|
||||
ARG BUILD_DATE
|
||||
ARG VCS_REF
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
wget \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& apt-get clean
|
||||
|
||||
COPY --from=builder /opt/venv /opt/venv
|
||||
ENV PATH="/opt/venv/bin:$PATH"
|
||||
COPY --from=builder /app/.venv /app/.venv
|
||||
ENV PATH="/app/.venv/bin:$PATH"
|
||||
|
||||
RUN groupadd -g 1000 app && \
|
||||
useradd -u 1000 -g 1000 -m -s /bin/bash app
|
||||
@@ -33,8 +33,7 @@ WORKDIR /app
|
||||
|
||||
COPY --chown=app:app . .
|
||||
|
||||
RUN mkdir -p logs data && \
|
||||
chown -R app:app /app logs data
|
||||
RUN mkdir -p logs data && chown app:app logs data
|
||||
|
||||
USER app
|
||||
|
||||
@@ -56,7 +55,7 @@ LABEL org.opencontainers.image.title="Bedolaga RemnaWave Bot" \
|
||||
org.opencontainers.image.url="https://github.com/fr1ngg/remnawave-bedolaga-telegram-bot" \
|
||||
org.opencontainers.image.vendor="fr1ngg"
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
||||
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8080/health')" || exit 1
|
||||
|
||||
CMD ["python", "main.py"]
|
||||
|
||||
@@ -610,6 +610,16 @@ hooks.domain.com {
|
||||
}
|
||||
}
|
||||
|
||||
handle /riopay-webhook {
|
||||
reverse_proxy remnawave_bot:8080 {
|
||||
header_up Host {host}
|
||||
header_up X-Real-IP {remote_host}
|
||||
transport http {
|
||||
read_buffer 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handle /remnawave-webhook {
|
||||
reverse_proxy remnawave_bot:8080 {
|
||||
header_up Host {host}
|
||||
@@ -827,6 +837,18 @@ http {
|
||||
proxy_buffering off;
|
||||
proxy_request_buffering off;
|
||||
}
|
||||
|
||||
location = /riopay-webhook {
|
||||
proxy_pass http://remnawave_bot_unified;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 120s;
|
||||
proxy_send_timeout 120s;
|
||||
proxy_buffering off;
|
||||
proxy_request_buffering off;
|
||||
}
|
||||
|
||||
location = /remnawave-webhook {
|
||||
proxy_pass http://remnawave_bot_unified;
|
||||
@@ -1455,6 +1477,7 @@ CONTEST_BUTTON_VISIBLE=true
|
||||
- 💳 **WATA**
|
||||
- 💳 **Freekassa** (NSPK СБП + карты)
|
||||
- 💳 **CloudPayments** (карты + СБП)
|
||||
- 💳 **RioPay** (карты + СБП)
|
||||
- 🔥 Автогенерация счетов и webhook-уведомления
|
||||
- 💼 История операций
|
||||
- 🔄 Автоплатёж с настройкой дня списания
|
||||
|
||||
+13
-3
@@ -60,6 +60,7 @@ 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
|
||||
@@ -132,8 +133,9 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
dp.message.middleware(blacklist_middleware)
|
||||
dp.callback_query.middleware(blacklist_middleware)
|
||||
dp.pre_checkout_query.middleware(blacklist_middleware)
|
||||
dp.message.middleware(ThrottlingMiddleware())
|
||||
dp.callback_query.middleware(ThrottlingMiddleware())
|
||||
throttling_middleware = ThrottlingMiddleware()
|
||||
dp.message.middleware(throttling_middleware)
|
||||
dp.callback_query.middleware(throttling_middleware)
|
||||
|
||||
# Middleware для автоматического логирования кликов по кнопкам
|
||||
if settings.MENU_LAYOUT_ENABLED:
|
||||
@@ -198,6 +200,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)
|
||||
@@ -245,7 +248,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
|
||||
@@ -254,6 +257,13 @@ 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)
|
||||
|
||||
logger.info('Бот успешно настроен')
|
||||
|
||||
return bot, dp
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
"""Temporary merge token management for account linking.
|
||||
|
||||
Stores short-lived tokens in Redis so the user can confirm merging
|
||||
two cabinet accounts (primary absorbs secondary) via a separate
|
||||
confirmation endpoint.
|
||||
"""
|
||||
|
||||
import secrets
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
|
||||
from app.utils.cache import cache, cache_key
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
MERGE_TOKEN_TTL_SECONDS = 1800 # 30 minutes
|
||||
MERGE_TOKEN_PREFIX = 'account_merge'
|
||||
|
||||
|
||||
async def create_merge_token(
|
||||
primary_user_id: int,
|
||||
secondary_user_id: int,
|
||||
provider: str,
|
||||
provider_id: str,
|
||||
) -> str:
|
||||
"""Generate a merge token and store its payload in Redis.
|
||||
|
||||
The token is a one-time confirmation handle: whoever presents it
|
||||
within ``MERGE_TOKEN_TTL_SECONDS`` can execute the account merge.
|
||||
|
||||
Returns the raw token string (URL-safe base64, 32 bytes of entropy).
|
||||
Raises ``RuntimeError`` if Redis write fails.
|
||||
"""
|
||||
token = secrets.token_urlsafe(32)
|
||||
value: dict[str, Any] = {
|
||||
'primary_user_id': primary_user_id,
|
||||
'secondary_user_id': secondary_user_id,
|
||||
'provider': provider,
|
||||
'provider_id': provider_id,
|
||||
'created_at': datetime.now(UTC).isoformat(),
|
||||
}
|
||||
key = cache_key(MERGE_TOKEN_PREFIX, token)
|
||||
stored = await cache.set(key, value, expire=MERGE_TOKEN_TTL_SECONDS)
|
||||
if not stored:
|
||||
logger.error(
|
||||
'Failed to store merge token in Redis',
|
||||
primary_user_id=primary_user_id,
|
||||
secondary_user_id=secondary_user_id,
|
||||
provider=provider,
|
||||
)
|
||||
raise RuntimeError('Failed to store merge token')
|
||||
|
||||
logger.info(
|
||||
'Merge token created',
|
||||
primary_user_id=primary_user_id,
|
||||
secondary_user_id=secondary_user_id,
|
||||
provider=provider,
|
||||
provider_id=provider_id,
|
||||
)
|
||||
return token
|
||||
|
||||
|
||||
async def get_merge_token_data(token: str) -> dict[str, Any] | None:
|
||||
"""Read merge token payload *without* consuming it.
|
||||
|
||||
Intended for preview / confirmation screens where the user sees
|
||||
what will happen before they press "Confirm".
|
||||
|
||||
Returns ``None`` when the token is expired, missing, or malformed.
|
||||
"""
|
||||
key = cache_key(MERGE_TOKEN_PREFIX, token)
|
||||
data: Any = await cache.get(key)
|
||||
if data is None or not isinstance(data, dict):
|
||||
return None
|
||||
return data
|
||||
|
||||
|
||||
async def consume_merge_token(token: str) -> dict[str, Any] | None:
|
||||
"""Atomically read and delete a merge token (GETDEL).
|
||||
|
||||
This prevents double-merge race conditions: only the first caller
|
||||
that reaches Redis will get the payload; every subsequent attempt
|
||||
receives ``None``.
|
||||
|
||||
Returns the stored dict or ``None`` if already consumed / expired.
|
||||
"""
|
||||
key = cache_key(MERGE_TOKEN_PREFIX, token)
|
||||
data: Any = await cache.getdel(key)
|
||||
if data is None or not isinstance(data, dict):
|
||||
return None
|
||||
|
||||
logger.info(
|
||||
'Merge token consumed',
|
||||
primary_user_id=data.get('primary_user_id'),
|
||||
secondary_user_id=data.get('secondary_user_id'),
|
||||
provider=data.get('provider'),
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
_MAX_MERGE_RESTORE_ATTEMPTS = 3
|
||||
|
||||
|
||||
async def restore_merge_token(token: str, data: dict[str, Any]) -> bool:
|
||||
"""Re-store a consumed merge token so the user can retry after a DB failure.
|
||||
|
||||
Uses the remaining TTL based on the original ``created_at``.
|
||||
Uses SETNX to avoid overwriting a fresh token.
|
||||
Caps restore attempts to prevent infinite retry cycles.
|
||||
Returns ``True`` if restored, ``False`` if exhausted or Redis write failed.
|
||||
"""
|
||||
restore_count = data.get('_restore_count', 0) + 1
|
||||
if restore_count > _MAX_MERGE_RESTORE_ATTEMPTS:
|
||||
logger.warning(
|
||||
'Merge token exhausted restore attempts',
|
||||
primary_user_id=data.get('primary_user_id'),
|
||||
secondary_user_id=data.get('secondary_user_id'),
|
||||
restore_count=restore_count,
|
||||
)
|
||||
return False
|
||||
|
||||
# Shallow copy to avoid mutating the caller's dict
|
||||
data = {**data, '_restore_count': restore_count}
|
||||
|
||||
created_at_str: str = data.get('created_at', '')
|
||||
try:
|
||||
created_at = datetime.fromisoformat(created_at_str)
|
||||
if created_at.tzinfo is None:
|
||||
created_at = created_at.replace(tzinfo=UTC)
|
||||
elapsed = (datetime.now(UTC) - created_at).total_seconds()
|
||||
remaining_ttl = max(1, min(int(MERGE_TOKEN_TTL_SECONDS - elapsed), MERGE_TOKEN_TTL_SECONDS))
|
||||
except (ValueError, TypeError):
|
||||
remaining_ttl = 60 # brief retry window — fail closed
|
||||
|
||||
key = cache_key(MERGE_TOKEN_PREFIX, token)
|
||||
stored = await cache.setnx(key, data, expire=remaining_ttl)
|
||||
if stored:
|
||||
logger.info(
|
||||
'Merge token restored after failed merge',
|
||||
primary_user_id=data.get('primary_user_id'),
|
||||
secondary_user_id=data.get('secondary_user_id'),
|
||||
remaining_ttl=remaining_ttl,
|
||||
restore_count=restore_count,
|
||||
)
|
||||
else:
|
||||
logger.error(
|
||||
'Failed to restore merge token to Redis (key may already exist)',
|
||||
primary_user_id=data.get('primary_user_id'),
|
||||
secondary_user_id=data.get('secondary_user_id'),
|
||||
)
|
||||
return bool(stored)
|
||||
@@ -1,5 +1,7 @@
|
||||
"""OAuth 2.0 provider implementations for cabinet authentication."""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import secrets
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any, TypedDict
|
||||
@@ -33,7 +35,7 @@ class OAuthTokenResponse(TypedDict, total=False):
|
||||
expires_in: int
|
||||
refresh_token: str
|
||||
scope: str
|
||||
# VK-specific: email and user_id come in token response
|
||||
# Provider-specific extra fields (optional)
|
||||
email: str
|
||||
user_id: int
|
||||
|
||||
@@ -67,15 +69,19 @@ class DiscordUserInfoResponse(TypedDict, total=False):
|
||||
avatar: str
|
||||
|
||||
|
||||
class VKUserInfoItem(TypedDict, total=False):
|
||||
id: int
|
||||
class VKIDUserData(TypedDict, total=False):
|
||||
"""VK ID /oauth2/user_info response user object."""
|
||||
|
||||
user_id: str
|
||||
first_name: str
|
||||
last_name: str
|
||||
photo_200: str
|
||||
phone: str
|
||||
avatar: str
|
||||
email: str
|
||||
|
||||
|
||||
class VKUserInfoResponse(TypedDict, total=False):
|
||||
response: list[VKUserInfoItem]
|
||||
class VKIDUserInfoResponse(TypedDict, total=False):
|
||||
user: VKIDUserData
|
||||
|
||||
|
||||
# --- Models ---
|
||||
@@ -97,23 +103,45 @@ class OAuthUserInfo(BaseModel):
|
||||
# --- CSRF state management (Redis) ---
|
||||
|
||||
|
||||
async def generate_oauth_state(provider: str) -> str:
|
||||
"""Generate a CSRF state token for OAuth flow. Stored in Redis with TTL."""
|
||||
async def generate_oauth_state(provider: str, extra_data: dict[str, str] | None = None) -> str:
|
||||
"""Generate a CSRF state token for OAuth flow.
|
||||
|
||||
Stores provider name and optional extra data (e.g., PKCE code_verifier) in Redis with TTL.
|
||||
Keys prefixed with '_' are ephemeral and NOT stored in Redis (e.g., _code_challenge).
|
||||
CacheService handles JSON serialization internally.
|
||||
"""
|
||||
state = secrets.token_urlsafe(32)
|
||||
await cache.set(cache_key('oauth_state', state), provider, expire=STATE_TTL_SECONDS)
|
||||
value: dict[str, Any] = {'provider': provider}
|
||||
if extra_data:
|
||||
# Filter out ephemeral keys (prefixed with '_') — they're only needed for the URL
|
||||
value.update({k: v for k, v in extra_data.items() if not k.startswith('_')})
|
||||
stored = await cache.set(cache_key('oauth_state', state), value, expire=STATE_TTL_SECONDS)
|
||||
if not stored:
|
||||
logger.error('Failed to store OAuth state in Redis')
|
||||
raise RuntimeError('Failed to store OAuth state')
|
||||
return state
|
||||
|
||||
|
||||
async def validate_oauth_state(state: str, provider: str) -> bool:
|
||||
"""Validate and consume a CSRF state token from Redis."""
|
||||
async def validate_oauth_state(state: str, provider: str | None = None) -> dict[str, Any] | None:
|
||||
"""Validate and consume a CSRF state token from Redis.
|
||||
|
||||
Uses atomic GETDEL to prevent TOCTOU race conditions.
|
||||
Returns the stored data dict (with 'provider' key + any extra data) or None if invalid.
|
||||
|
||||
Args:
|
||||
state: The state token to validate.
|
||||
provider: If provided, verifies it matches the stored provider.
|
||||
If None, skips provider check (used for server-complete flow).
|
||||
"""
|
||||
key = cache_key('oauth_state', state)
|
||||
stored_provider: str | None = await cache.get(key)
|
||||
if stored_provider is None:
|
||||
return False
|
||||
await cache.delete(key)
|
||||
if stored_provider != provider:
|
||||
return False
|
||||
return True
|
||||
data: Any = await cache.getdel(key)
|
||||
if data is None:
|
||||
return None
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
if provider is not None and data.get('provider') != provider:
|
||||
return None
|
||||
return data
|
||||
|
||||
|
||||
# --- Provider implementations ---
|
||||
@@ -130,13 +158,28 @@ class OAuthProvider(ABC):
|
||||
self.client_secret = client_secret
|
||||
self.redirect_uri = redirect_uri
|
||||
|
||||
@abstractmethod
|
||||
def get_authorization_url(self, state: str) -> str:
|
||||
"""Build the authorization URL for the provider."""
|
||||
def prepare_auth_state(self) -> dict[str, str]:
|
||||
"""Return extra data to store with OAuth state (e.g., PKCE code_verifier).
|
||||
|
||||
Override in providers that need PKCE or other state-stored data.
|
||||
The returned dict is stored in Redis alongside the state token
|
||||
and passed back via validate_oauth_state().
|
||||
"""
|
||||
return {}
|
||||
|
||||
@abstractmethod
|
||||
async def exchange_code(self, code: str) -> OAuthTokenResponse:
|
||||
"""Exchange authorization code for tokens."""
|
||||
def get_authorization_url(self, state: str, **kwargs: Any) -> str:
|
||||
"""Build the authorization URL for the provider.
|
||||
|
||||
kwargs may contain extra data from prepare_auth_state() (e.g., code_challenge).
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
async def exchange_code(self, code: str, **kwargs: Any) -> OAuthTokenResponse:
|
||||
"""Exchange authorization code for tokens.
|
||||
|
||||
kwargs may contain provider-specific params (e.g., device_id, code_verifier for VK).
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
async def get_user_info(self, token_data: OAuthTokenResponse) -> OAuthUserInfo:
|
||||
@@ -151,7 +194,7 @@ class GoogleProvider(OAuthProvider):
|
||||
TOKEN_URL = 'https://oauth2.googleapis.com/token'
|
||||
USERINFO_URL = 'https://www.googleapis.com/oauth2/v3/userinfo'
|
||||
|
||||
def get_authorization_url(self, state: str) -> str:
|
||||
def get_authorization_url(self, state: str, **kwargs: Any) -> str:
|
||||
params: dict[str, str] = {
|
||||
'client_id': self.client_id,
|
||||
'redirect_uri': self.redirect_uri,
|
||||
@@ -164,7 +207,7 @@ class GoogleProvider(OAuthProvider):
|
||||
request = httpx.Request('GET', self.AUTHORIZE_URL, params=params)
|
||||
return str(request.url)
|
||||
|
||||
async def exchange_code(self, code: str) -> OAuthTokenResponse:
|
||||
async def exchange_code(self, code: str, **kwargs: Any) -> OAuthTokenResponse:
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
response = await client.post(
|
||||
self.TOKEN_URL,
|
||||
@@ -209,7 +252,7 @@ class YandexProvider(OAuthProvider):
|
||||
TOKEN_URL = 'https://oauth.yandex.com/token'
|
||||
USERINFO_URL = 'https://login.yandex.ru/info'
|
||||
|
||||
def get_authorization_url(self, state: str) -> str:
|
||||
def get_authorization_url(self, state: str, **kwargs: Any) -> str:
|
||||
params: dict[str, str] = {
|
||||
'client_id': self.client_id,
|
||||
'redirect_uri': self.redirect_uri,
|
||||
@@ -221,7 +264,7 @@ class YandexProvider(OAuthProvider):
|
||||
request = httpx.Request('GET', self.AUTHORIZE_URL, params=params)
|
||||
return str(request.url)
|
||||
|
||||
async def exchange_code(self, code: str) -> OAuthTokenResponse:
|
||||
async def exchange_code(self, code: str, **kwargs: Any) -> OAuthTokenResponse:
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
response = await client.post(
|
||||
self.TOKEN_URL,
|
||||
@@ -275,7 +318,7 @@ class DiscordProvider(OAuthProvider):
|
||||
TOKEN_URL = 'https://discord.com/api/oauth2/token'
|
||||
USERINFO_URL = 'https://discord.com/api/v10/users/@me'
|
||||
|
||||
def get_authorization_url(self, state: str) -> str:
|
||||
def get_authorization_url(self, state: str, **kwargs: Any) -> str:
|
||||
params: dict[str, str] = {
|
||||
'client_id': self.client_id,
|
||||
'redirect_uri': self.redirect_uri,
|
||||
@@ -287,7 +330,7 @@ class DiscordProvider(OAuthProvider):
|
||||
request = httpx.Request('GET', self.AUTHORIZE_URL, params=params)
|
||||
return str(request.url)
|
||||
|
||||
async def exchange_code(self, code: str) -> OAuthTokenResponse:
|
||||
async def exchange_code(self, code: str, **kwargs: Any) -> OAuthTokenResponse:
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
response = await client.post(
|
||||
self.TOKEN_URL,
|
||||
@@ -329,35 +372,72 @@ class DiscordProvider(OAuthProvider):
|
||||
|
||||
|
||||
class VKProvider(OAuthProvider):
|
||||
"""VK ID OAuth 2.1 provider (id.vk.ru).
|
||||
|
||||
Uses OAuth 2.1 with mandatory PKCE (S256).
|
||||
Old oauth.vk.com endpoints deprecated since September 30, 2025.
|
||||
"""
|
||||
|
||||
name = 'vk'
|
||||
display_name = 'VK'
|
||||
|
||||
AUTHORIZE_URL = 'https://oauth.vk.com/authorize'
|
||||
TOKEN_URL = 'https://oauth.vk.com/access_token'
|
||||
USERINFO_URL = 'https://api.vk.com/method/users.get'
|
||||
API_VERSION = '5.131'
|
||||
AUTHORIZE_URL = 'https://id.vk.ru/authorize'
|
||||
TOKEN_URL = 'https://id.vk.ru/oauth2/auth'
|
||||
USERINFO_URL = 'https://id.vk.ru/oauth2/user_info'
|
||||
|
||||
def get_authorization_url(self, state: str) -> str:
|
||||
@staticmethod
|
||||
def _generate_pkce() -> tuple[str, str]:
|
||||
"""Generate PKCE code_verifier and code_challenge (S256)."""
|
||||
code_verifier = secrets.token_urlsafe(64)
|
||||
digest = hashlib.sha256(code_verifier.encode('ascii')).digest()
|
||||
code_challenge = base64.urlsafe_b64encode(digest).rstrip(b'=').decode('ascii')
|
||||
return code_verifier, code_challenge
|
||||
|
||||
def prepare_auth_state(self) -> dict[str, str]:
|
||||
"""Generate PKCE pair. code_verifier stored in Redis, code_challenge only goes to URL."""
|
||||
code_verifier, code_challenge = self._generate_pkce()
|
||||
# code_challenge is ephemeral — only needed for the authorization URL,
|
||||
# not stored in Redis (code_verifier is the secret used during token exchange)
|
||||
return {
|
||||
'code_verifier': code_verifier,
|
||||
'_code_challenge': code_challenge,
|
||||
}
|
||||
|
||||
def get_authorization_url(self, state: str, **kwargs: Any) -> str:
|
||||
code_challenge: str = kwargs.get('_code_challenge', '')
|
||||
params: dict[str, str] = {
|
||||
'client_id': self.client_id,
|
||||
'redirect_uri': self.redirect_uri,
|
||||
'response_type': 'code',
|
||||
'scope': 'email',
|
||||
'scope': 'vkid.personal_info email',
|
||||
'state': state,
|
||||
'v': self.API_VERSION,
|
||||
'code_challenge': code_challenge,
|
||||
'code_challenge_method': 'S256',
|
||||
}
|
||||
request = httpx.Request('GET', self.AUTHORIZE_URL, params=params)
|
||||
return str(request.url)
|
||||
|
||||
async def exchange_code(self, code: str) -> OAuthTokenResponse:
|
||||
async def exchange_code(self, code: str, **kwargs: Any) -> OAuthTokenResponse:
|
||||
device_id: str = kwargs.get('device_id', '')
|
||||
code_verifier: str = kwargs.get('code_verifier', '')
|
||||
state: str = kwargs.get('state', '')
|
||||
|
||||
if not device_id:
|
||||
raise ValueError('device_id is required for VK ID token exchange')
|
||||
if not code_verifier:
|
||||
raise ValueError('code_verifier is required for VK ID token exchange')
|
||||
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
response = await client.get(
|
||||
response = await client.post(
|
||||
self.TOKEN_URL,
|
||||
params={
|
||||
'client_id': self.client_id,
|
||||
'client_secret': self.client_secret,
|
||||
data={
|
||||
'grant_type': 'authorization_code',
|
||||
'code': code,
|
||||
'redirect_uri': self.redirect_uri,
|
||||
'client_id': self.client_id,
|
||||
'device_id': device_id,
|
||||
'code_verifier': code_verifier,
|
||||
'state': state,
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
@@ -366,33 +446,37 @@ class VKProvider(OAuthProvider):
|
||||
|
||||
async def get_user_info(self, token_data: OAuthTokenResponse) -> OAuthUserInfo:
|
||||
access_token = token_data['access_token']
|
||||
user_id: int | None = token_data.get('user_id')
|
||||
# VK returns email in token response, not in userinfo
|
||||
email: str | None = token_data.get('email')
|
||||
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
response = await client.get(
|
||||
response = await client.post(
|
||||
self.USERINFO_URL,
|
||||
params={
|
||||
data={
|
||||
'access_token': access_token,
|
||||
'fields': 'photo_200',
|
||||
'v': self.API_VERSION,
|
||||
'client_id': self.client_id,
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data: VKUserInfoResponse = response.json()
|
||||
data: VKIDUserInfoResponse = response.json()
|
||||
|
||||
users: list[Any] = data.get('response', [])
|
||||
user_data: VKUserInfoItem = users[0] if users else {} # type: ignore[assignment]
|
||||
user_data = data.get('user')
|
||||
if not user_data:
|
||||
raise ValueError('VK ID response missing user data')
|
||||
|
||||
user_id = user_data.get('user_id')
|
||||
if not user_id:
|
||||
raise ValueError('VK ID response missing user_id')
|
||||
|
||||
# VK ID returns email only if 'email' scope was granted and user has a verified email
|
||||
email: str | None = user_data.get('email') or None
|
||||
|
||||
return OAuthUserInfo(
|
||||
provider='vk',
|
||||
provider_id=str(user_id or user_data.get('id', '')),
|
||||
provider_id=str(user_id),
|
||||
email=email,
|
||||
email_verified=bool(email),
|
||||
first_name=user_data.get('first_name'),
|
||||
last_name=user_data.get('last_name'),
|
||||
avatar_url=user_data.get('photo_200'),
|
||||
avatar_url=user_data.get('avatar'),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,15 +1,27 @@
|
||||
"""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, unquote
|
||||
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
|
||||
|
||||
|
||||
def validate_telegram_login_widget(data: dict[str, Any], max_age_seconds: int = 86400) -> bool:
|
||||
"""
|
||||
Validate Telegram Login Widget data.
|
||||
@@ -29,17 +41,17 @@ def validate_telegram_login_widget(data: dict[str, Any], max_age_seconds: int =
|
||||
if not check_hash:
|
||||
return False
|
||||
|
||||
# Check auth_date is not too old
|
||||
# Check auth_date is present and within valid range
|
||||
auth_date = auth_data.get('auth_date')
|
||||
if auth_date:
|
||||
try:
|
||||
# Use UTC timestamp to avoid timezone issues
|
||||
auth_time = datetime.fromtimestamp(int(auth_date), tz=UTC)
|
||||
age = (datetime.now(UTC) - auth_time).total_seconds()
|
||||
if age > max_age_seconds:
|
||||
return False
|
||||
except (ValueError, TypeError, OSError):
|
||||
if not auth_date:
|
||||
return False
|
||||
try:
|
||||
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:
|
||||
return False
|
||||
except (ValueError, TypeError, OSError):
|
||||
return False
|
||||
|
||||
# Build data-check-string (sorted key=value pairs, newline-separated)
|
||||
data_check_arr = [f'{k}={v}' for k, v in sorted(auth_data.items()) if v is not None]
|
||||
@@ -76,17 +88,17 @@ def validate_telegram_init_data(init_data: str, max_age_seconds: int = 86400) ->
|
||||
if not received_hash:
|
||||
return None
|
||||
|
||||
# Check auth_date is not too old
|
||||
# Check auth_date is present and within valid range
|
||||
auth_date = parsed.get('auth_date')
|
||||
if auth_date:
|
||||
try:
|
||||
# Use UTC timestamp to avoid timezone issues
|
||||
auth_time = datetime.fromtimestamp(int(auth_date), tz=UTC)
|
||||
age = (datetime.now(UTC) - auth_time).total_seconds()
|
||||
if age > max_age_seconds:
|
||||
return None
|
||||
except (ValueError, TypeError, OSError):
|
||||
if not auth_date:
|
||||
return None
|
||||
try:
|
||||
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:
|
||||
return None
|
||||
except (ValueError, TypeError, OSError):
|
||||
return None
|
||||
|
||||
# Build data-check-string
|
||||
data_check_arr = [f'{k}={v}' for k, v in sorted(parsed.items())]
|
||||
@@ -105,7 +117,7 @@ def validate_telegram_init_data(init_data: str, max_age_seconds: int = 86400) ->
|
||||
# Parse user data from the validated data
|
||||
user_data_str = parsed.get('user')
|
||||
if user_data_str:
|
||||
user_data = json.loads(unquote(user_data_str))
|
||||
user_data = json.loads(user_data_str)
|
||||
return user_data
|
||||
|
||||
return parsed
|
||||
@@ -125,3 +137,117 @@ 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
|
||||
|
||||
async with httpx.AsyncClient(timeout=10) 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
|
||||
|
||||
@@ -14,6 +14,7 @@ from app.services.maintenance_service import maintenance_service
|
||||
|
||||
from .auth.jwt_handler import get_token_payload
|
||||
from .auth.telegram_auth import validate_telegram_init_data
|
||||
from .ip_utils import get_client_ip
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
@@ -289,11 +290,11 @@ def require_permission(*permissions: str):
|
||||
) -> User:
|
||||
from app.services.permission_service import PermissionService
|
||||
|
||||
ip_address = (
|
||||
request.headers.get('X-Forwarded-For', '').split(',')[0].strip()
|
||||
or request.headers.get('X-Real-IP', '').strip()
|
||||
or (request.client.host if request.client else None)
|
||||
)
|
||||
try:
|
||||
client_ip = get_client_ip(request)
|
||||
except HTTPException:
|
||||
logger.warning('Unable to determine client IP in require_permission')
|
||||
client_ip = 'unknown'
|
||||
user_agent = request.headers.get('user-agent', '')
|
||||
|
||||
# Extract resource_type from the first permission (section before ':')
|
||||
@@ -308,7 +309,7 @@ def require_permission(*permissions: str):
|
||||
db,
|
||||
user,
|
||||
perm,
|
||||
ip_address=ip_address,
|
||||
ip_address=client_ip,
|
||||
)
|
||||
if not allowed:
|
||||
await PermissionService.log_action(
|
||||
@@ -317,7 +318,7 @@ def require_permission(*permissions: str):
|
||||
action=perm,
|
||||
resource_type=resource_type,
|
||||
status='denied',
|
||||
ip_address=ip_address,
|
||||
ip_address=client_ip,
|
||||
user_agent=user_agent,
|
||||
request_method=request.method,
|
||||
request_path=str(request.url.path),
|
||||
@@ -354,7 +355,7 @@ def require_permission(*permissions: str):
|
||||
action=','.join(permissions),
|
||||
resource_type=resource_type,
|
||||
status='success',
|
||||
ip_address=ip_address,
|
||||
ip_address=client_ip,
|
||||
user_agent=user_agent,
|
||||
request_method=request.method,
|
||||
request_path=str(request.url.path),
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Shared IP extraction utilities for cabinet module."""
|
||||
|
||||
from ipaddress import ip_address, ip_network
|
||||
|
||||
from fastapi import HTTPException, Request, status
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
def _is_trusted_proxy(peer_ip: str, trusted: set[str]) -> bool:
|
||||
"""Check if peer IP matches any trusted proxy entry (IP or CIDR)."""
|
||||
if not trusted:
|
||||
return False
|
||||
try:
|
||||
addr = ip_address(peer_ip)
|
||||
except ValueError:
|
||||
return False
|
||||
for entry in trusted:
|
||||
try:
|
||||
if '/' in entry:
|
||||
if addr in ip_network(entry, strict=False):
|
||||
return True
|
||||
elif addr == ip_address(entry):
|
||||
return True
|
||||
except ValueError:
|
||||
continue
|
||||
return False
|
||||
|
||||
|
||||
def get_client_ip(request: Request) -> str:
|
||||
"""Extract real client IP, trusting proxy headers only from known proxies.
|
||||
|
||||
Raises HTTPException 400 if the peer IP cannot be determined
|
||||
(request.client is None — e.g., test harness or broken transport).
|
||||
"""
|
||||
if not request.client:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Unable to determine client IP',
|
||||
)
|
||||
peer_ip = request.client.host
|
||||
trusted_proxies = settings.get_cabinet_trusted_proxies()
|
||||
|
||||
if trusted_proxies and _is_trusted_proxy(peer_ip, trusted_proxies):
|
||||
forwarded = request.headers.get('X-Forwarded-For', '').split(',')[0].strip()
|
||||
if forwarded:
|
||||
try:
|
||||
ip_address(forwarded)
|
||||
return forwarded
|
||||
except ValueError:
|
||||
pass # invalid IP in header — fall through to peer_ip
|
||||
real_ip = request.headers.get('X-Real-IP', '').strip()
|
||||
if real_ip:
|
||||
try:
|
||||
ip_address(real_ip)
|
||||
return real_ip
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
return peer_ip
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from .account_linking import merge_router as merge_router, router as account_linking_router
|
||||
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
|
||||
@@ -10,6 +11,8 @@ 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_landings import router as admin_landings_router
|
||||
from .admin_menu_layout import router as admin_menu_layout_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
|
||||
@@ -19,6 +22,7 @@ from .admin_promo_offers import router as admin_promo_offers_router
|
||||
from .admin_promocodes import promo_groups_router as admin_promo_groups_router, router as admin_promocodes_router
|
||||
from .admin_remnawave import router as admin_remnawave_router
|
||||
from .admin_roles import router as admin_roles_router
|
||||
from .admin_sales_stats import router as admin_sales_stats_router
|
||||
from .admin_servers import router as admin_servers_router
|
||||
from .admin_settings import router as admin_settings_router
|
||||
from .admin_stats import router as admin_stats_router
|
||||
@@ -33,7 +37,9 @@ 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 .landing import router as landing_router
|
||||
from .media import router as media_router
|
||||
from .notifications import router as notifications_router
|
||||
from .oauth import router as oauth_router
|
||||
@@ -59,6 +65,8 @@ router = APIRouter(prefix='/cabinet', tags=['Cabinet'])
|
||||
# Include all sub-routers
|
||||
router.include_router(auth_router)
|
||||
router.include_router(oauth_router)
|
||||
router.include_router(account_linking_router)
|
||||
router.include_router(merge_router)
|
||||
router.include_router(subscription_router)
|
||||
router.include_router(balance_router)
|
||||
router.include_router(referral_router)
|
||||
@@ -74,11 +82,15 @@ 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)
|
||||
|
||||
# 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)
|
||||
@@ -87,6 +99,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_sales_stats_router)
|
||||
router.include_router(admin_ban_system_router)
|
||||
router.include_router(admin_broadcasts_router)
|
||||
router.include_router(admin_promocodes_router)
|
||||
@@ -96,6 +109,7 @@ router.include_router(admin_partners_router)
|
||||
router.include_router(admin_withdrawals_router)
|
||||
router.include_router(admin_users_router)
|
||||
router.include_router(admin_payment_methods_router)
|
||||
router.include_router(admin_landings_router)
|
||||
router.include_router(admin_payments_router)
|
||||
router.include_router(admin_promo_offers_router)
|
||||
router.include_router(admin_remnawave_router)
|
||||
@@ -104,6 +118,7 @@ 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)
|
||||
|
||||
@@ -0,0 +1,891 @@
|
||||
"""Account linking and merge routes for cabinet.
|
||||
|
||||
Router 1 (`router`): JWT-protected endpoints for linking/unlinking OAuth providers.
|
||||
Exception: `link/server-complete` uses state-token auth instead of JWT (for Mini App external browser flow).
|
||||
Router 2 (`merge_router`): Public endpoints for merge preview and execution.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
from datetime import UTC, datetime
|
||||
from typing import Literal, NotRequired, TypedDict
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path, Request, status
|
||||
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,
|
||||
get_user_by_id,
|
||||
get_user_by_oauth_provider,
|
||||
get_user_by_telegram_id,
|
||||
set_user_oauth_provider_id,
|
||||
)
|
||||
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, TokenReplayCache
|
||||
|
||||
from ..auth.merge_service import (
|
||||
MERGE_TOKEN_TTL_SECONDS,
|
||||
consume_merge_token,
|
||||
create_merge_token,
|
||||
get_merge_token_data,
|
||||
restore_merge_token,
|
||||
)
|
||||
from ..auth.oauth_providers import (
|
||||
generate_oauth_state,
|
||||
get_provider,
|
||||
validate_oauth_state,
|
||||
)
|
||||
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
|
||||
from .auth import _create_auth_response, _store_refresh_token, _user_to_response
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
OAuthProviderName = Literal['google', 'yandex', 'discord', 'vk']
|
||||
|
||||
# Ensure OAuthProviderName Literal stays in sync with OAUTH_PROVIDER_COLUMNS
|
||||
_EXPECTED_PROVIDERS = {'google', 'yandex', 'discord', 'vk'}
|
||||
if set(OAUTH_PROVIDER_COLUMNS.keys()) != _EXPECTED_PROVIDERS:
|
||||
raise RuntimeError(
|
||||
f'OAuthProviderName Literal is out of sync with OAUTH_PROVIDER_COLUMNS: '
|
||||
f'{set(OAUTH_PROVIDER_COLUMNS.keys())} != {_EXPECTED_PROVIDERS}'
|
||||
)
|
||||
|
||||
|
||||
class OAuthStateData(TypedDict):
|
||||
"""Typed dict for Redis-stored OAuth state data."""
|
||||
|
||||
provider: str # Always present
|
||||
linking: NotRequired[str] # 'true' if account linking flow
|
||||
user_id: NotRequired[str] # ID of user who initiated linking
|
||||
code_verifier: NotRequired[str] # PKCE code verifier (VK)
|
||||
|
||||
|
||||
def _get_active_providers() -> list[str]:
|
||||
"""Вернуть список активных провайдеров аутентификации (только включённые)."""
|
||||
providers: list[str] = ['telegram']
|
||||
if settings.is_cabinet_email_auth_enabled():
|
||||
providers.append('email')
|
||||
providers.extend(settings.get_enabled_oauth_provider_names())
|
||||
return providers
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class LinkedProvider(BaseModel):
|
||||
provider: str
|
||||
linked: bool
|
||||
identifier: str | None = None
|
||||
|
||||
|
||||
class LinkedProvidersResponse(BaseModel):
|
||||
providers: list[LinkedProvider]
|
||||
|
||||
|
||||
class LinkInitResponse(BaseModel):
|
||||
authorize_url: str
|
||||
state: str
|
||||
|
||||
|
||||
class LinkCallbackRequest(BaseModel):
|
||||
code: str = Field(..., min_length=1, max_length=2048, description='Authorization code from provider')
|
||||
state: str = Field(..., min_length=1, max_length=128, description='CSRF state token')
|
||||
device_id: str | None = Field(None, max_length=256, description='Device ID from VK ID callback')
|
||||
|
||||
|
||||
class LinkCallbackResponse(BaseModel):
|
||||
success: bool
|
||||
message: str | None = None
|
||||
merge_required: bool = False
|
||||
merge_token: str | None = None
|
||||
|
||||
|
||||
class UnlinkResponse(BaseModel):
|
||||
success: bool
|
||||
|
||||
|
||||
class LinkTelegramRequest(BaseModel):
|
||||
"""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")
|
||||
last_name: str | None = Field(None, max_length=256, description="User's last name")
|
||||
username: str | None = Field(None, max_length=256, description="User's username")
|
||||
photo_url: str | None = Field(None, max_length=2048, description="User's photo URL")
|
||||
auth_date: int | None = Field(None, description='Unix timestamp of authentication')
|
||||
hash: str | None = Field(None, min_length=64, max_length=64, description='Authentication hash (SHA-256 hex)')
|
||||
|
||||
@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
|
||||
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
|
||||
|
||||
|
||||
class MergePreviewSubscription(BaseModel):
|
||||
status: str
|
||||
is_trial: bool
|
||||
end_date: datetime | None = None
|
||||
traffic_limit_gb: float
|
||||
traffic_used_gb: float
|
||||
device_limit: int
|
||||
tariff_name: str | None = None
|
||||
autopay_enabled: bool
|
||||
|
||||
|
||||
class MergePreviewUser(BaseModel):
|
||||
id: int
|
||||
username: str | None = None
|
||||
first_name: str | None = None
|
||||
email: str | None = None
|
||||
auth_methods: list[str]
|
||||
balance_kopeks: int = 0
|
||||
subscription: MergePreviewSubscription | None = None
|
||||
created_at: datetime | None = None
|
||||
|
||||
|
||||
class MergePreviewResponse(BaseModel):
|
||||
primary: MergePreviewUser
|
||||
secondary: MergePreviewUser
|
||||
expires_in_seconds: int
|
||||
|
||||
|
||||
class MergeRequest(BaseModel):
|
||||
keep_subscription_from: int = Field(..., description='User ID whose subscription to keep')
|
||||
|
||||
|
||||
class MergeResponse(BaseModel):
|
||||
success: bool
|
||||
access_token: str | None = None
|
||||
refresh_token: str | None = None
|
||||
user: UserResponse | None = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _get_provider_identifier(user: User, provider: str) -> str | None:
|
||||
"""Return the identifier (provider_id or email) for a given provider, or None."""
|
||||
match provider:
|
||||
case 'telegram':
|
||||
return str(user.telegram_id) if user.telegram_id else None
|
||||
case 'email':
|
||||
return user.email if user.email and user.password_hash else None
|
||||
case _:
|
||||
column = OAUTH_PROVIDER_COLUMNS.get(provider)
|
||||
if not column:
|
||||
return None
|
||||
value = getattr(user, column, None)
|
||||
return str(value) if value else None
|
||||
|
||||
|
||||
def _count_auth_methods(user: User) -> int:
|
||||
"""Count how many auth methods the user has linked."""
|
||||
return len(compute_auth_methods(user))
|
||||
|
||||
|
||||
async def _exchange_and_link_oauth(
|
||||
*,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
provider: str,
|
||||
code: str,
|
||||
state: str,
|
||||
state_data: OAuthStateData,
|
||||
device_id: str | None,
|
||||
log_context: str,
|
||||
) -> LinkCallbackResponse:
|
||||
"""Shared OAuth linking logic: exchange code, fetch user info, link or merge.
|
||||
|
||||
Used by both link_provider_callback (JWT-authed) and link_server_complete (state-authed).
|
||||
"""
|
||||
oauth_provider = get_provider(provider)
|
||||
if not oauth_provider:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Requested OAuth provider is not available',
|
||||
)
|
||||
|
||||
# Exchange code for tokens
|
||||
exchange_kwargs: dict[str, str] = {'state': state}
|
||||
code_verifier = state_data.get('code_verifier')
|
||||
if code_verifier:
|
||||
exchange_kwargs['code_verifier'] = code_verifier
|
||||
if device_id:
|
||||
exchange_kwargs['device_id'] = device_id
|
||||
|
||||
try:
|
||||
token_data = await oauth_provider.exchange_code(code, **exchange_kwargs)
|
||||
except Exception as exc:
|
||||
logger.error('OAuth code exchange failed', context=log_context, provider=provider, exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Failed to exchange authorization code',
|
||||
) from exc
|
||||
|
||||
# Fetch user info from provider
|
||||
try:
|
||||
user_info = await oauth_provider.get_user_info(token_data)
|
||||
except Exception as exc:
|
||||
logger.error('OAuth user info fetch failed', context=log_context, provider=provider, exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Failed to fetch user information from provider',
|
||||
) from exc
|
||||
|
||||
# Check if provider_id is already linked to THIS user
|
||||
column = OAUTH_PROVIDER_COLUMNS[provider]
|
||||
current_value = getattr(user, column, None)
|
||||
if current_value and str(current_value) == user_info.provider_id:
|
||||
return LinkCallbackResponse(success=True, message='already_linked')
|
||||
|
||||
# Check if provider_id is linked to ANOTHER user
|
||||
existing_user = await get_user_by_oauth_provider(db, provider, user_info.provider_id)
|
||||
if existing_user and existing_user.id != user.id:
|
||||
logger.info(
|
||||
'Account linking conflict: provider already linked to another user',
|
||||
context=log_context,
|
||||
provider=provider,
|
||||
provider_id=user_info.provider_id,
|
||||
current_user_id=user.id,
|
||||
existing_user_id=existing_user.id,
|
||||
)
|
||||
merge_token = await create_merge_token(
|
||||
primary_user_id=user.id,
|
||||
secondary_user_id=existing_user.id,
|
||||
provider=provider,
|
||||
provider_id=user_info.provider_id,
|
||||
)
|
||||
return LinkCallbackResponse(
|
||||
success=False,
|
||||
merge_required=True,
|
||||
merge_token=merge_token,
|
||||
)
|
||||
|
||||
# Link the provider to current user
|
||||
await set_user_oauth_provider_id(db, user, provider, user_info.provider_id)
|
||||
try:
|
||||
await db.commit()
|
||||
except IntegrityError as exc:
|
||||
await db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail='This provider account was just linked to another user',
|
||||
) from exc
|
||||
|
||||
logger.info(
|
||||
'OAuth provider linked to account',
|
||||
context=log_context,
|
||||
provider=provider,
|
||||
provider_id=user_info.provider_id,
|
||||
user_id=user.id,
|
||||
)
|
||||
return LinkCallbackResponse(success=True, message='linked')
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Router 1: Account linking (JWT required)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
router = APIRouter(prefix='/auth/account', tags=['Cabinet Account Linking'])
|
||||
|
||||
|
||||
@router.get('/linked-providers', response_model=LinkedProvidersResponse)
|
||||
async def get_linked_providers(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
) -> LinkedProvidersResponse:
|
||||
"""Return all auth methods with their link status for the current user."""
|
||||
providers: list[LinkedProvider] = []
|
||||
for provider in _get_active_providers():
|
||||
identifier = _get_provider_identifier(user, provider)
|
||||
providers.append(
|
||||
LinkedProvider(
|
||||
provider=provider,
|
||||
linked=identifier is not None,
|
||||
identifier=identifier,
|
||||
)
|
||||
)
|
||||
return LinkedProvidersResponse(providers=providers)
|
||||
|
||||
|
||||
@router.get('/link/{provider}/init', response_model=LinkInitResponse)
|
||||
async def link_provider_init(
|
||||
provider: OAuthProviderName,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
) -> LinkInitResponse:
|
||||
"""Start OAuth flow for linking a new provider to the current account."""
|
||||
|
||||
# Check if already linked
|
||||
column = OAUTH_PROVIDER_COLUMNS[provider]
|
||||
if getattr(user, column, None):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Provider is already linked to your account',
|
||||
)
|
||||
|
||||
oauth_provider = get_provider(provider)
|
||||
if not oauth_provider:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Requested OAuth provider is not available',
|
||||
)
|
||||
|
||||
# Generate PKCE data for VK (and potentially future providers)
|
||||
auth_extra = oauth_provider.prepare_auth_state()
|
||||
extra_data: dict[str, str] = {
|
||||
'linking': 'true',
|
||||
'user_id': str(user.id),
|
||||
}
|
||||
if auth_extra:
|
||||
extra_data.update(auth_extra)
|
||||
|
||||
state = await generate_oauth_state(provider, extra_data=extra_data)
|
||||
# Only pass URL-safe params (prefixed with _) to authorize URL; exclude secrets like code_verifier
|
||||
url_params = {k: v for k, v in auth_extra.items() if k.startswith('_')} if auth_extra else {}
|
||||
authorize_url = oauth_provider.get_authorization_url(state, **url_params)
|
||||
|
||||
return LinkInitResponse(authorize_url=authorize_url, state=state)
|
||||
|
||||
|
||||
@router.post('/link/{provider}/callback', response_model=LinkCallbackResponse)
|
||||
async def link_provider_callback(
|
||||
provider: OAuthProviderName,
|
||||
request: LinkCallbackRequest,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> LinkCallbackResponse:
|
||||
"""Handle OAuth callback for linking a provider to the current account."""
|
||||
# 1. Validate CSRF state
|
||||
state_data = await validate_oauth_state(request.state, provider)
|
||||
if not state_data:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Invalid or expired OAuth state',
|
||||
)
|
||||
|
||||
# 1b. Validate that this state was created for account linking (not login)
|
||||
if state_data.get('linking') != 'true' or not state_data.get('user_id'):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='OAuth state was not initiated for account linking',
|
||||
)
|
||||
|
||||
# 1c. Validate that the user who initiated the link flow is the same user completing it
|
||||
state_user_id = state_data['user_id']
|
||||
if str(user.id) != state_user_id:
|
||||
logger.warning(
|
||||
'OAuth state user_id mismatch in link callback',
|
||||
state_user_id=state_user_id,
|
||||
current_user_id=user.id,
|
||||
provider=provider,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='OAuth state was initiated by a different user',
|
||||
)
|
||||
|
||||
# 2-7. Exchange code, fetch user info, link or merge
|
||||
return await _exchange_and_link_oauth(
|
||||
db=db,
|
||||
user=user,
|
||||
provider=provider,
|
||||
code=request.code,
|
||||
state=request.state,
|
||||
state_data=state_data,
|
||||
device_id=request.device_id,
|
||||
log_context='link-callback',
|
||||
)
|
||||
|
||||
|
||||
@router.post('/unlink/{provider}', response_model=UnlinkResponse)
|
||||
async def unlink_provider(
|
||||
provider: OAuthProviderName,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> UnlinkResponse:
|
||||
"""Unlink an OAuth provider from the current account."""
|
||||
column = OAUTH_PROVIDER_COLUMNS[provider]
|
||||
if not getattr(user, column, None):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Provider is not linked to your account',
|
||||
)
|
||||
|
||||
# Ensure at least one auth method remains
|
||||
if _count_auth_methods(user) <= 1:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Cannot unlink last authentication method',
|
||||
)
|
||||
|
||||
await clear_user_oauth_provider_id(db, user, provider)
|
||||
await db.commit()
|
||||
return UnlinkResponse(success=True)
|
||||
|
||||
|
||||
@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, 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(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Telegram is already linked to your account',
|
||||
)
|
||||
|
||||
# 2. Validate and extract telegram_id
|
||||
telegram_id: int | None = None
|
||||
telegram_username: str | None = None
|
||||
telegram_first_name: str | None = None
|
||||
telegram_last_name: str | None = None
|
||||
|
||||
if request.init_data:
|
||||
# Mini App flow: validate initData
|
||||
user_data = validate_telegram_init_data(request.init_data)
|
||||
if not user_data or not user_data.get('id'):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Invalid or expired Telegram initData',
|
||||
)
|
||||
telegram_id = int(user_data['id'])
|
||||
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 = {
|
||||
'id': request.id,
|
||||
'auth_date': request.auth_date,
|
||||
'hash': request.hash,
|
||||
}
|
||||
if request.first_name is not None:
|
||||
widget_data['first_name'] = request.first_name
|
||||
if request.last_name is not None:
|
||||
widget_data['last_name'] = request.last_name
|
||||
if request.username is not None:
|
||||
widget_data['username'] = request.username
|
||||
if request.photo_url is not None:
|
||||
widget_data['photo_url'] = request.photo_url
|
||||
|
||||
if not validate_telegram_login_widget(widget_data):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Invalid or expired Telegram Login Widget data',
|
||||
)
|
||||
telegram_id = request.id
|
||||
telegram_username = request.username
|
||||
telegram_first_name = request.first_name
|
||||
telegram_last_name = request.last_name
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
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
|
||||
existing_user = await get_user_by_telegram_id(db, telegram_id)
|
||||
if existing_user and existing_user.id != user.id:
|
||||
logger.info(
|
||||
'Telegram linking conflict: telegram_id already linked to another user',
|
||||
telegram_id=telegram_id,
|
||||
current_user_id=user.id,
|
||||
existing_user_id=existing_user.id,
|
||||
)
|
||||
merge_token = await create_merge_token(
|
||||
primary_user_id=user.id,
|
||||
secondary_user_id=existing_user.id,
|
||||
provider='telegram',
|
||||
provider_id=str(telegram_id),
|
||||
)
|
||||
return LinkCallbackResponse(
|
||||
success=False,
|
||||
merge_required=True,
|
||||
merge_token=merge_token,
|
||||
)
|
||||
|
||||
# 4. Link Telegram to current user
|
||||
user.telegram_id = telegram_id
|
||||
if telegram_username and not user.username:
|
||||
user.username = telegram_username
|
||||
if telegram_first_name and not user.first_name:
|
||||
user.first_name = telegram_first_name
|
||||
if telegram_last_name and not user.last_name:
|
||||
user.last_name = telegram_last_name
|
||||
user.updated_at = datetime.now(UTC)
|
||||
|
||||
try:
|
||||
await db.commit()
|
||||
except IntegrityError as exc:
|
||||
await db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail='This Telegram account was just linked to another user',
|
||||
) from exc
|
||||
|
||||
logger.info(
|
||||
'Telegram linked to account',
|
||||
telegram_id=telegram_id,
|
||||
user_id=user.id,
|
||||
)
|
||||
return LinkCallbackResponse(success=True, message='linked')
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Server-side OAuth linking callback (NO JWT required — auth via state token)
|
||||
# Used by Telegram Mini App where OAuth must open in external browser.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ServerCompleteRequest(BaseModel):
|
||||
code: str = Field(..., min_length=1, max_length=2048, description='Authorization code from provider')
|
||||
state: str = Field(..., min_length=1, max_length=128, description='CSRF state token')
|
||||
provider: OAuthProviderName | None = Field(None, description='OAuth provider name (resolved from state if omitted)')
|
||||
device_id: str | None = Field(None, max_length=256, description='Device ID from VK ID callback')
|
||||
|
||||
|
||||
class ServerCompleteResponse(LinkCallbackResponse):
|
||||
provider: str
|
||||
|
||||
|
||||
@router.post('/link/server-complete', response_model=ServerCompleteResponse)
|
||||
async def link_server_complete(
|
||||
request: ServerCompleteRequest,
|
||||
raw_request: Request,
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> ServerCompleteResponse:
|
||||
"""Complete OAuth account linking without JWT.
|
||||
|
||||
Authenticates via the one-time state token stored in Redis during link_provider_init.
|
||||
Used when OAuth opens in an external browser (e.g., from Telegram Mini App).
|
||||
Provider is resolved from the state token if not explicitly provided.
|
||||
"""
|
||||
# Rate limit by IP (unauthenticated endpoint)
|
||||
client_ip = get_client_ip(raw_request)
|
||||
if await RateLimitCache.is_ip_rate_limited(client_ip, 'server_complete', limit=10, window=60, fail_closed=True):
|
||||
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail='Too many requests')
|
||||
|
||||
# 1. Validate and consume state from Redis (one-time use).
|
||||
# Provider may be None — validate_oauth_state will skip provider check,
|
||||
# and we'll resolve it from state_data['provider'].
|
||||
state_data = await validate_oauth_state(request.state, request.provider)
|
||||
if not state_data:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Invalid or expired OAuth state',
|
||||
)
|
||||
|
||||
# Resolve provider from state data (canonical source)
|
||||
state_provider: str = state_data.get('provider', '')
|
||||
if not state_provider or state_provider not in OAUTH_PROVIDER_COLUMNS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Could not determine OAuth provider',
|
||||
)
|
||||
|
||||
# If request explicitly provides a provider, ensure it matches the state
|
||||
if request.provider and request.provider != state_provider:
|
||||
logger.warning(
|
||||
'Provider mismatch in server-complete',
|
||||
request_provider=request.provider,
|
||||
state_provider=state_provider,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Provider does not match OAuth state',
|
||||
)
|
||||
|
||||
provider_name: str = state_provider
|
||||
|
||||
# 2. Must be a linking state (not login)
|
||||
if state_data.get('linking') != 'true' or not state_data.get('user_id'):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='OAuth state was not initiated for account linking',
|
||||
)
|
||||
|
||||
# 3. Parse and validate user_id from state
|
||||
try:
|
||||
user_id = int(state_data['user_id'])
|
||||
except (ValueError, TypeError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Invalid user_id in OAuth state',
|
||||
) from exc
|
||||
|
||||
# 4. Load user from DB
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='User not found',
|
||||
)
|
||||
|
||||
# 5-9. Exchange code, fetch user info, link or merge
|
||||
result = await _exchange_and_link_oauth(
|
||||
db=db,
|
||||
user=user,
|
||||
provider=provider_name,
|
||||
code=request.code,
|
||||
state=request.state,
|
||||
state_data=state_data,
|
||||
device_id=request.device_id,
|
||||
log_context='server-complete',
|
||||
)
|
||||
|
||||
return ServerCompleteResponse(
|
||||
success=result.success,
|
||||
message=result.message,
|
||||
merge_required=result.merge_required,
|
||||
merge_token=result.merge_token,
|
||||
provider=provider_name,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Router 2: Merge (NO JWT required)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
merge_router = APIRouter(prefix='/auth/merge', tags=['Cabinet Account Merge'])
|
||||
|
||||
|
||||
@merge_router.get('/{merge_token}', response_model=MergePreviewResponse)
|
||||
async def get_merge_preview_endpoint(
|
||||
raw_request: Request,
|
||||
merge_token: str = Path(..., min_length=32, max_length=64),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> MergePreviewResponse:
|
||||
"""Preview the result of merging two accounts before confirming."""
|
||||
# Rate limit by IP (unauthenticated endpoint)
|
||||
client_ip = get_client_ip(raw_request)
|
||||
if await RateLimitCache.is_ip_rate_limited(client_ip, 'merge_preview', limit=15, window=60, fail_closed=True):
|
||||
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail='Too many requests')
|
||||
|
||||
token_data = await get_merge_token_data(merge_token)
|
||||
if not token_data:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Merge token is invalid or expired',
|
||||
)
|
||||
|
||||
primary_user_id: int = token_data['primary_user_id']
|
||||
secondary_user_id: int = token_data['secondary_user_id']
|
||||
|
||||
try:
|
||||
preview = await get_merge_preview(db, primary_user_id, secondary_user_id)
|
||||
except ValueError as exc:
|
||||
logger.error('Merge preview failed', error=str(exc))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='One or both users not found',
|
||||
) from exc
|
||||
|
||||
# Calculate remaining TTL
|
||||
created_at_str: str = token_data.get('created_at', '')
|
||||
try:
|
||||
created_at = datetime.fromisoformat(created_at_str)
|
||||
if created_at.tzinfo is None:
|
||||
created_at = created_at.replace(tzinfo=UTC)
|
||||
elapsed = (datetime.now(UTC) - created_at).total_seconds()
|
||||
expires_in_seconds = max(0, int(MERGE_TOKEN_TTL_SECONDS - elapsed))
|
||||
except (ValueError, TypeError):
|
||||
expires_in_seconds = 0
|
||||
|
||||
return MergePreviewResponse(
|
||||
primary=MergePreviewUser(**preview['primary']),
|
||||
secondary=MergePreviewUser(**preview['secondary']),
|
||||
expires_in_seconds=expires_in_seconds,
|
||||
)
|
||||
|
||||
|
||||
@merge_router.post('/{merge_token}', response_model=MergeResponse)
|
||||
async def execute_merge_endpoint(
|
||||
request: MergeRequest,
|
||||
raw_request: Request,
|
||||
merge_token: str = Path(..., min_length=32, max_length=64),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> MergeResponse:
|
||||
"""Execute account merge. Consumes the merge token (one-time use)."""
|
||||
# Rate limit by IP (unauthenticated endpoint)
|
||||
client_ip = get_client_ip(raw_request)
|
||||
if await RateLimitCache.is_ip_rate_limited(client_ip, 'merge_execute', limit=5, window=60, fail_closed=True):
|
||||
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail='Too many requests')
|
||||
|
||||
# 1. Consume token atomically first (GETDEL — one-time use, no TOCTOU)
|
||||
consumed = await consume_merge_token(merge_token)
|
||||
if not consumed:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Merge token is invalid, expired, or already consumed',
|
||||
)
|
||||
|
||||
primary_user_id: int = consumed['primary_user_id']
|
||||
secondary_user_id: int = consumed['secondary_user_id']
|
||||
provider: str = consumed.get('provider', '')
|
||||
provider_id: str = consumed.get('provider_id', '')
|
||||
|
||||
# 2. Validate keep_subscription_from — restore token if invalid
|
||||
if request.keep_subscription_from not in (primary_user_id, secondary_user_id):
|
||||
await restore_merge_token(merge_token, consumed)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='keep_subscription_from must be one of the two user IDs being merged',
|
||||
)
|
||||
|
||||
# Convert user_id to 'primary'/'secondary' string for execute_merge()
|
||||
keep_from: Literal['primary', 'secondary'] = (
|
||||
'primary' if request.keep_subscription_from == primary_user_id else 'secondary'
|
||||
)
|
||||
|
||||
# 3. Execute merge
|
||||
try:
|
||||
merged_user = await execute_merge(
|
||||
db=db,
|
||||
primary_user_id=primary_user_id,
|
||||
secondary_user_id=secondary_user_id,
|
||||
keep_subscription_from=keep_from,
|
||||
provider=provider,
|
||||
provider_id=provider_id,
|
||||
)
|
||||
await db.commit()
|
||||
except ValueError as exc:
|
||||
await db.rollback()
|
||||
await restore_merge_token(merge_token, consumed)
|
||||
logger.error('Merge execution failed (ValueError)', error=str(exc))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Account merge cannot be completed. The accounts may have already been merged or deleted.',
|
||||
) from exc
|
||||
except Exception as exc:
|
||||
await db.rollback()
|
||||
await restore_merge_token(merge_token, consumed)
|
||||
logger.exception('Merge execution failed')
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Account merge failed due to an internal error',
|
||||
) from exc
|
||||
|
||||
# 4. Re-fetch merged user with full relationships for auth response
|
||||
merged_user = await get_user_by_id(db, primary_user_id)
|
||||
if not merged_user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to load merged user',
|
||||
)
|
||||
|
||||
# 5. Create auth tokens for the merged user
|
||||
try:
|
||||
auth_response = await _create_auth_response(merged_user, db)
|
||||
await _store_refresh_token(db, merged_user.id, auth_response.refresh_token, device_info='merge')
|
||||
except Exception as exc:
|
||||
logger.exception('Failed to create auth tokens after merge')
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Merge succeeded but failed to create new session',
|
||||
) from exc
|
||||
|
||||
logger.info(
|
||||
'Account merge completed successfully',
|
||||
primary_user_id=primary_user_id,
|
||||
secondary_user_id=secondary_user_id,
|
||||
provider=provider,
|
||||
)
|
||||
|
||||
return MergeResponse(
|
||||
success=True,
|
||||
access_token=auth_response.access_token,
|
||||
refresh_token=auth_response.refresh_token,
|
||||
user=_user_to_response(merged_user),
|
||||
)
|
||||
@@ -7,7 +7,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.cabinet.utils.links import get_campaign_deep_link, get_campaign_web_link
|
||||
from app.database.crud.campaign import (
|
||||
create_campaign,
|
||||
delete_campaign,
|
||||
@@ -29,9 +29,11 @@ from app.database.models import (
|
||||
Tariff,
|
||||
User,
|
||||
)
|
||||
from app.services.partner_stats_service import PartnerStatsService
|
||||
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
from ..schemas.campaigns import (
|
||||
AdminCampaignChartDataResponse,
|
||||
AvailablePartnerItem,
|
||||
CampaignCreateRequest,
|
||||
CampaignDetailResponse,
|
||||
@@ -54,20 +56,9 @@ logger = structlog.get_logger(__name__)
|
||||
router = APIRouter(prefix='/admin/campaigns', tags=['Cabinet Admin Campaigns'])
|
||||
|
||||
|
||||
def _get_deep_link(start_parameter: str) -> str:
|
||||
"""Generate deep link for campaign."""
|
||||
bot_username = settings.get_bot_username()
|
||||
if bot_username:
|
||||
return f'https://t.me/{bot_username}?start={start_parameter}'
|
||||
return f'?start={start_parameter}'
|
||||
|
||||
|
||||
def _get_web_link(start_parameter: str) -> str | None:
|
||||
"""Generate web link for campaign."""
|
||||
base_url = (settings.MINIAPP_CUSTOM_URL or '').rstrip('/')
|
||||
if base_url:
|
||||
return f'{base_url}/?campaign={start_parameter}'
|
||||
return None
|
||||
def _safe_div(value: float | None, divisor: int = 100) -> float:
|
||||
"""Safely divide kopeks to rubles, handling None values."""
|
||||
return (value or 0) / divisor
|
||||
|
||||
|
||||
def _get_partner_name(campaign: AdvertisingCampaign) -> str | None:
|
||||
@@ -84,26 +75,35 @@ async def get_overview(
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get campaigns overview statistics."""
|
||||
overview = await get_campaigns_overview(db)
|
||||
try:
|
||||
overview = await get_campaigns_overview(db)
|
||||
|
||||
# Count tariff bonuses
|
||||
tariff_result = await db.execute(
|
||||
select(func.count(AdvertisingCampaignRegistration.id)).where(
|
||||
AdvertisingCampaignRegistration.bonus_type == 'tariff'
|
||||
# Count tariff bonuses
|
||||
tariff_result = await db.execute(
|
||||
select(func.count(AdvertisingCampaignRegistration.id)).where(
|
||||
AdvertisingCampaignRegistration.bonus_type == 'tariff'
|
||||
)
|
||||
)
|
||||
)
|
||||
tariff_count = tariff_result.scalar() or 0
|
||||
tariff_count = tariff_result.scalar() or 0
|
||||
|
||||
return CampaignsOverviewResponse(
|
||||
total=overview['total'],
|
||||
active=overview['active'],
|
||||
inactive=overview['inactive'],
|
||||
total_registrations=overview['registrations'],
|
||||
total_balance_issued_kopeks=overview['balance_total'],
|
||||
total_balance_issued_rubles=overview['balance_total'] / 100,
|
||||
total_subscription_issued=overview['subscription_total'],
|
||||
total_tariff_issued=tariff_count,
|
||||
)
|
||||
return CampaignsOverviewResponse(
|
||||
total=overview['total'],
|
||||
active=overview['active'],
|
||||
inactive=overview['inactive'],
|
||||
total_registrations=overview['registrations'],
|
||||
total_balance_issued_kopeks=overview['balance_total'],
|
||||
total_balance_issued_rubles=_safe_div(overview['balance_total']),
|
||||
total_subscription_issued=overview['subscription_total'],
|
||||
total_tariff_issued=tariff_count,
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error('Failed to get campaigns overview', error=str(e), exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to load campaigns overview',
|
||||
)
|
||||
|
||||
|
||||
@router.get('/available-servers', response_model=list[ServerSquadInfo])
|
||||
@@ -183,7 +183,7 @@ async def list_campaigns(
|
||||
):
|
||||
"""Get list of all campaigns."""
|
||||
campaigns = await get_campaigns_list(db, offset=offset, limit=limit, include_inactive=include_inactive)
|
||||
total = await get_campaigns_count(db)
|
||||
total = await get_campaigns_count(db, is_active=True if not include_inactive else None)
|
||||
|
||||
items = []
|
||||
for campaign in campaigns:
|
||||
@@ -236,7 +236,7 @@ async def get_campaign(
|
||||
bonus_type=campaign.bonus_type,
|
||||
is_active=campaign.is_active,
|
||||
balance_bonus_kopeks=campaign.balance_bonus_kopeks or 0,
|
||||
balance_bonus_rubles=(campaign.balance_bonus_kopeks or 0) / 100,
|
||||
balance_bonus_rubles=_safe_div(campaign.balance_bonus_kopeks),
|
||||
subscription_duration_days=campaign.subscription_duration_days,
|
||||
subscription_traffic_gb=campaign.subscription_traffic_gb,
|
||||
subscription_device_limit=campaign.subscription_device_limit,
|
||||
@@ -249,11 +249,38 @@ async def get_campaign(
|
||||
created_by=campaign.created_by,
|
||||
created_at=campaign.created_at,
|
||||
updated_at=campaign.updated_at,
|
||||
deep_link=_get_deep_link(campaign.start_parameter),
|
||||
web_link=_get_web_link(campaign.start_parameter),
|
||||
deep_link=get_campaign_deep_link(campaign.start_parameter),
|
||||
web_link=get_campaign_web_link(campaign.start_parameter),
|
||||
)
|
||||
|
||||
|
||||
@router.get('/{campaign_id}/chart-data', response_model=AdminCampaignChartDataResponse)
|
||||
async def get_campaign_chart_data(
|
||||
campaign_id: int,
|
||||
admin: User = Depends(require_permission('campaigns:stats')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get chart data for admin campaign analytics."""
|
||||
try:
|
||||
campaign = await get_campaign_by_id(db, campaign_id)
|
||||
if not campaign:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Campaign not found',
|
||||
)
|
||||
|
||||
data = await PartnerStatsService.get_admin_campaign_chart_data(db, campaign_id)
|
||||
return AdminCampaignChartDataResponse(**data)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error('Failed to get campaign chart data', error=str(e), campaign_id=campaign_id, exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to load campaign chart data',
|
||||
)
|
||||
|
||||
|
||||
@router.get('/{campaign_id}/stats', response_model=CampaignStatisticsResponse)
|
||||
async def get_campaign_stats(
|
||||
campaign_id: int,
|
||||
@@ -261,41 +288,50 @@ async def get_campaign_stats(
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get detailed campaign statistics."""
|
||||
campaign = await get_campaign_by_id(db, campaign_id)
|
||||
if not campaign:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Campaign not found',
|
||||
try:
|
||||
campaign = await get_campaign_by_id(db, campaign_id)
|
||||
if not campaign:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Campaign not found',
|
||||
)
|
||||
|
||||
stats = await get_campaign_statistics(db, campaign_id)
|
||||
|
||||
return CampaignStatisticsResponse(
|
||||
id=campaign.id,
|
||||
name=campaign.name,
|
||||
start_parameter=campaign.start_parameter,
|
||||
bonus_type=campaign.bonus_type,
|
||||
is_active=campaign.is_active,
|
||||
registrations=stats['registrations'],
|
||||
balance_issued_kopeks=stats['balance_issued'],
|
||||
balance_issued_rubles=_safe_div(stats['balance_issued']),
|
||||
subscription_issued=stats['subscription_issued'],
|
||||
last_registration=stats['last_registration'],
|
||||
total_revenue_kopeks=stats['total_revenue_kopeks'],
|
||||
total_revenue_rubles=_safe_div(stats['total_revenue_kopeks']),
|
||||
avg_revenue_per_user_kopeks=stats['avg_revenue_per_user_kopeks'],
|
||||
avg_revenue_per_user_rubles=_safe_div(stats['avg_revenue_per_user_kopeks']),
|
||||
avg_first_payment_kopeks=stats['avg_first_payment_kopeks'],
|
||||
avg_first_payment_rubles=_safe_div(stats['avg_first_payment_kopeks']),
|
||||
trial_users_count=stats['trial_users_count'],
|
||||
active_trials_count=stats['active_trials_count'],
|
||||
conversion_count=stats['conversion_count'],
|
||||
paid_users_count=stats['paid_users_count'],
|
||||
conversion_rate=stats['conversion_rate'],
|
||||
trial_conversion_rate=stats['trial_conversion_rate'],
|
||||
deep_link=get_campaign_deep_link(campaign.start_parameter),
|
||||
web_link=get_campaign_web_link(campaign.start_parameter),
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error('Failed to get campaign stats', error=str(e), campaign_id=campaign_id, exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to load campaign statistics',
|
||||
)
|
||||
|
||||
stats = await get_campaign_statistics(db, campaign_id)
|
||||
|
||||
return CampaignStatisticsResponse(
|
||||
id=campaign.id,
|
||||
name=campaign.name,
|
||||
start_parameter=campaign.start_parameter,
|
||||
bonus_type=campaign.bonus_type,
|
||||
is_active=campaign.is_active,
|
||||
registrations=stats['registrations'],
|
||||
balance_issued_kopeks=stats['balance_issued'],
|
||||
balance_issued_rubles=stats['balance_issued'] / 100,
|
||||
subscription_issued=stats['subscription_issued'],
|
||||
last_registration=stats['last_registration'],
|
||||
total_revenue_kopeks=stats['total_revenue_kopeks'],
|
||||
total_revenue_rubles=stats['total_revenue_kopeks'] / 100,
|
||||
avg_revenue_per_user_kopeks=stats['avg_revenue_per_user_kopeks'],
|
||||
avg_revenue_per_user_rubles=stats['avg_revenue_per_user_kopeks'] / 100,
|
||||
avg_first_payment_kopeks=stats['avg_first_payment_kopeks'],
|
||||
avg_first_payment_rubles=stats['avg_first_payment_kopeks'] / 100,
|
||||
trial_users_count=stats['trial_users_count'],
|
||||
active_trials_count=stats['active_trials_count'],
|
||||
conversion_count=stats['conversion_count'],
|
||||
paid_users_count=stats['paid_users_count'],
|
||||
conversion_rate=stats['conversion_rate'],
|
||||
trial_conversion_rate=stats['trial_conversion_rate'],
|
||||
deep_link=_get_deep_link(campaign.start_parameter),
|
||||
web_link=_get_web_link(campaign.start_parameter),
|
||||
)
|
||||
|
||||
|
||||
@router.get('/{campaign_id}/registrations', response_model=CampaignRegistrationsResponse)
|
||||
@@ -411,7 +447,7 @@ async def create_new_campaign(
|
||||
# Validate partner exists and is approved
|
||||
if request.partner_user_id is not None:
|
||||
partner_user = await db.get(User, request.partner_user_id)
|
||||
if not partner_user or partner_user.partner_status != 'approved':
|
||||
if not partner_user or partner_user.partner_status != PartnerStatus.APPROVED.value:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Partner not found or not approved',
|
||||
@@ -434,9 +470,6 @@ async def create_new_campaign(
|
||||
partner_user_id=request.partner_user_id,
|
||||
)
|
||||
|
||||
# Reload to get tariff relationship
|
||||
campaign = await get_campaign_by_id(db, campaign.id)
|
||||
|
||||
logger.info('Admin created campaign', admin_id=admin.id, campaign_id=campaign.id, campaign_name=campaign.name)
|
||||
|
||||
return await get_campaign(campaign.id, admin, db)
|
||||
@@ -478,29 +511,29 @@ async def update_existing_campaign(
|
||||
detail='Tariff not found',
|
||||
)
|
||||
|
||||
# Build updates
|
||||
# Build updates using model_fields_set to distinguish "not sent" from "sent as None"
|
||||
updates = {}
|
||||
if request.name is not None:
|
||||
if 'name' in request.model_fields_set:
|
||||
updates['name'] = request.name
|
||||
if request.start_parameter is not None:
|
||||
if 'start_parameter' in request.model_fields_set:
|
||||
updates['start_parameter'] = request.start_parameter
|
||||
if request.bonus_type is not None:
|
||||
if 'bonus_type' in request.model_fields_set:
|
||||
updates['bonus_type'] = request.bonus_type
|
||||
if request.is_active is not None:
|
||||
if 'is_active' in request.model_fields_set:
|
||||
updates['is_active'] = request.is_active
|
||||
if request.balance_bonus_kopeks is not None:
|
||||
if 'balance_bonus_kopeks' in request.model_fields_set:
|
||||
updates['balance_bonus_kopeks'] = request.balance_bonus_kopeks
|
||||
if request.subscription_duration_days is not None:
|
||||
if 'subscription_duration_days' in request.model_fields_set:
|
||||
updates['subscription_duration_days'] = request.subscription_duration_days
|
||||
if request.subscription_traffic_gb is not None:
|
||||
if 'subscription_traffic_gb' in request.model_fields_set:
|
||||
updates['subscription_traffic_gb'] = request.subscription_traffic_gb
|
||||
if request.subscription_device_limit is not None:
|
||||
if 'subscription_device_limit' in request.model_fields_set:
|
||||
updates['subscription_device_limit'] = request.subscription_device_limit
|
||||
if request.subscription_squads is not None:
|
||||
if 'subscription_squads' in request.model_fields_set:
|
||||
updates['subscription_squads'] = request.subscription_squads
|
||||
if request.tariff_id is not None:
|
||||
if 'tariff_id' in request.model_fields_set:
|
||||
updates['tariff_id'] = request.tariff_id
|
||||
if request.tariff_duration_days is not None:
|
||||
if 'tariff_duration_days' in request.model_fields_set:
|
||||
updates['tariff_duration_days'] = request.tariff_duration_days
|
||||
|
||||
# Handle partner_user_id separately (allows explicit None to unassign)
|
||||
@@ -509,7 +542,7 @@ async def update_existing_campaign(
|
||||
new_partner_id = request.partner_user_id
|
||||
if new_partner_id is not None:
|
||||
partner_user = await db.get(User, new_partner_id)
|
||||
if not partner_user or partner_user.partner_status != 'approved':
|
||||
if not partner_user or partner_user.partner_status != PartnerStatus.APPROVED.value:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Partner not found or not approved',
|
||||
@@ -543,8 +576,13 @@ async def delete_existing_campaign(
|
||||
detail='Campaign not found',
|
||||
)
|
||||
|
||||
# Check if campaign has registrations
|
||||
reg_count = len(campaign.registrations) if campaign.registrations else 0
|
||||
# Check if campaign has registrations (COUNT query instead of loading all)
|
||||
reg_count_result = await db.execute(
|
||||
select(func.count(AdvertisingCampaignRegistration.id)).where(
|
||||
AdvertisingCampaignRegistration.campaign_id == campaign_id
|
||||
)
|
||||
)
|
||||
reg_count = reg_count_result.scalar() or 0
|
||||
if reg_count > 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
{
|
||||
'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,68 @@ 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',
|
||||
},
|
||||
'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']
|
||||
@@ -618,14 +731,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
|
||||
|
||||
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 = 3
|
||||
MAX_LABEL_LENGTH = 100
|
||||
URL_PATTERN = re.compile(r'^https?://')
|
||||
|
||||
|
||||
# ---- 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:// or https://.',
|
||||
)
|
||||
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)
|
||||
@@ -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)
|
||||
@@ -190,6 +191,7 @@ async def list_applications(
|
||||
telegram_channel=app.telegram_channel,
|
||||
description=app.description,
|
||||
expected_monthly_referrals=app.expected_monthly_referrals,
|
||||
desired_commission_percent=app.desired_commission_percent,
|
||||
status=app.status,
|
||||
admin_comment=app.admin_comment,
|
||||
approved_commission_percent=app.approved_commission_percent,
|
||||
@@ -417,17 +419,24 @@ async def get_partner_detail(
|
||||
|
||||
stats = await PartnerStatsService.get_referrer_detailed_stats(db, user_id)
|
||||
|
||||
# Get assigned campaigns
|
||||
# Get assigned campaigns with per-campaign stats
|
||||
campaigns_result = await db.execute(
|
||||
select(AdvertisingCampaign).where(AdvertisingCampaign.partner_user_id == user_id)
|
||||
)
|
||||
campaigns = campaigns_result.scalars().all()
|
||||
|
||||
campaign_ids = [c.id for c in campaigns]
|
||||
per_campaign_stats = await PartnerStatsService.get_per_campaign_stats(db, user_id, campaign_ids)
|
||||
|
||||
campaign_list = [
|
||||
CampaignSummary(
|
||||
id=c.id,
|
||||
name=c.name,
|
||||
start_parameter=c.start_parameter,
|
||||
is_active=c.is_active,
|
||||
registrations_count=per_campaign_stats.get(c.id, {}).get('registrations_count', 0),
|
||||
referrals_count=per_campaign_stats.get(c.id, {}).get('referrals_count', 0),
|
||||
earnings_kopeks=per_campaign_stats.get(c.id, {}).get('earnings_kopeks', 0),
|
||||
)
|
||||
for c in campaigns
|
||||
]
|
||||
@@ -551,6 +560,12 @@ async def assign_campaign(
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
'Кампания привязана к партнёру',
|
||||
campaign_id=campaign_id,
|
||||
partner_user_id=user_id,
|
||||
admin_id=admin.id,
|
||||
)
|
||||
return {'success': True}
|
||||
|
||||
|
||||
@@ -562,21 +577,32 @@ async def unassign_campaign(
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Unassign a campaign from a partner."""
|
||||
campaign = await db.get(AdvertisingCampaign, campaign_id)
|
||||
if not campaign:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Кампания не найдена',
|
||||
# Atomic check-and-unset to prevent race conditions
|
||||
result = await db.execute(
|
||||
update(AdvertisingCampaign)
|
||||
.where(
|
||||
AdvertisingCampaign.id == campaign_id,
|
||||
AdvertisingCampaign.partner_user_id == user_id,
|
||||
)
|
||||
|
||||
if campaign.partner_user_id != user_id:
|
||||
.values(partner_user_id=None, updated_at=datetime.now(UTC))
|
||||
)
|
||||
if result.rowcount == 0:
|
||||
campaign = await db.get(AdvertisingCampaign, campaign_id)
|
||||
if not campaign:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Кампания не найдена',
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Кампания не привязана к этому партнёру',
|
||||
)
|
||||
|
||||
campaign.partner_user_id = None
|
||||
campaign.updated_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
'Кампания откреплена от партнёра',
|
||||
campaign_id=campaign_id,
|
||||
partner_user_id=user_id,
|
||||
admin_id=admin.id,
|
||||
)
|
||||
return {'success': True}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -4,10 +4,14 @@ import math
|
||||
from datetime import 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 pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import PaymentMethod, User
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.services.payment_verification_service import (
|
||||
@@ -206,7 +210,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:
|
||||
@@ -390,8 +394,12 @@ async def check_payment_status(
|
||||
old_is_paid = record.is_paid
|
||||
|
||||
# Run manual check
|
||||
payment_service = PaymentService()
|
||||
updated = await run_manual_check(db, payment_method, payment_id, payment_service)
|
||||
bot = Bot(token=settings.BOT_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
|
||||
try:
|
||||
payment_service = PaymentService(bot=bot)
|
||||
updated = await run_manual_check(db, payment_method, payment_id, payment_service)
|
||||
finally:
|
||||
await bot.session.close()
|
||||
|
||||
if not updated:
|
||||
return ManualCheckResponse(
|
||||
|
||||
@@ -489,44 +489,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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -177,6 +177,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,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,7 +13,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,
|
||||
@@ -262,6 +262,9 @@ async def get_dashboard_stats(
|
||||
month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
trans_stats = await get_transactions_statistics(db, month_start, now)
|
||||
all_time_stats = await get_transactions_statistics(
|
||||
db, start_date=datetime(2020, 1, 1, tzinfo=UTC), end_date=now
|
||||
)
|
||||
|
||||
# Get revenue chart data (last 30 days)
|
||||
revenue_data = await get_revenue_by_period(db, days=30)
|
||||
@@ -291,10 +294,11 @@ async def get_dashboard_stats(
|
||||
income_today_rubles=trans_stats.get('today', {}).get('income_kopeks', 0) / 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=trans_stats.get('totals', {}).get('income_kopeks', 0),
|
||||
income_total_rubles=trans_stats.get('totals', {}).get('income_kopeks', 0) / 100,
|
||||
subscription_income_kopeks=trans_stats.get('totals', {}).get('subscription_income_kopeks', 0),
|
||||
subscription_income_rubles=trans_stats.get('totals', {}).get('subscription_income_kopeks', 0) / 100,
|
||||
income_total_kopeks=all_time_stats.get('totals', {}).get('income_kopeks', 0),
|
||||
income_total_rubles=all_time_stats.get('totals', {}).get('income_kopeks', 0) / 100,
|
||||
subscription_income_kopeks=abs(all_time_stats.get('totals', {}).get('subscription_income_kopeks', 0)),
|
||||
subscription_income_rubles=abs(all_time_stats.get('totals', {}).get('subscription_income_kopeks', 0))
|
||||
/ 100,
|
||||
),
|
||||
servers=ServerStats(
|
||||
total_servers=server_stats.get('total_servers', 0),
|
||||
@@ -686,53 +690,6 @@ async def get_top_referrers(
|
||||
if row.referrer_id in referrers_data:
|
||||
referrers_data[row.referrer_id]['earnings_month'] = row.total or 0
|
||||
|
||||
# Also add REFERRAL_REWARD transactions
|
||||
trans_total_query = await db.execute(
|
||||
select(Transaction.user_id.label('referrer_id'), func.sum(Transaction.amount_kopeks).label('total'))
|
||||
.where(Transaction.type == TransactionType.REFERRAL_REWARD.value)
|
||||
.group_by(Transaction.user_id)
|
||||
)
|
||||
for row in trans_total_query:
|
||||
if row.referrer_id in referrers_data:
|
||||
referrers_data[row.referrer_id]['earnings_total'] = referrers_data[row.referrer_id].get(
|
||||
'earnings_total', 0
|
||||
) + (row.total or 0)
|
||||
|
||||
trans_today_query = await db.execute(
|
||||
select(Transaction.user_id.label('referrer_id'), func.sum(Transaction.amount_kopeks).label('total'))
|
||||
.where(
|
||||
and_(Transaction.type == TransactionType.REFERRAL_REWARD.value, Transaction.created_at >= today_start)
|
||||
)
|
||||
.group_by(Transaction.user_id)
|
||||
)
|
||||
for row in trans_today_query:
|
||||
if row.referrer_id in referrers_data:
|
||||
referrers_data[row.referrer_id]['earnings_today'] = referrers_data[row.referrer_id].get(
|
||||
'earnings_today', 0
|
||||
) + (row.total or 0)
|
||||
|
||||
trans_week_query = await db.execute(
|
||||
select(Transaction.user_id.label('referrer_id'), func.sum(Transaction.amount_kopeks).label('total'))
|
||||
.where(and_(Transaction.type == TransactionType.REFERRAL_REWARD.value, Transaction.created_at >= week_ago))
|
||||
.group_by(Transaction.user_id)
|
||||
)
|
||||
for row in trans_week_query:
|
||||
if row.referrer_id in referrers_data:
|
||||
referrers_data[row.referrer_id]['earnings_week'] = referrers_data[row.referrer_id].get(
|
||||
'earnings_week', 0
|
||||
) + (row.total or 0)
|
||||
|
||||
trans_month_query = await db.execute(
|
||||
select(Transaction.user_id.label('referrer_id'), func.sum(Transaction.amount_kopeks).label('total'))
|
||||
.where(and_(Transaction.type == TransactionType.REFERRAL_REWARD.value, Transaction.created_at >= month_ago))
|
||||
.group_by(Transaction.user_id)
|
||||
)
|
||||
for row in trans_month_query:
|
||||
if row.referrer_id in referrers_data:
|
||||
referrers_data[row.referrer_id]['earnings_month'] = referrers_data[row.referrer_id].get(
|
||||
'earnings_month', 0
|
||||
) + (row.total or 0)
|
||||
|
||||
# Get user info for all referrers
|
||||
referrer_ids = list(referrers_data.keys())
|
||||
if referrer_ids:
|
||||
@@ -944,8 +901,8 @@ async def get_recent_payments(
|
||||
email=user.email,
|
||||
username=user.username,
|
||||
display_name=display_name,
|
||||
amount_kopeks=trans.amount_kopeks,
|
||||
amount_rubles=trans.amount_kopeks / 100,
|
||||
amount_kopeks=abs(trans.amount_kopeks),
|
||||
amount_rubles=abs(trans.amount_kopeks) / 100,
|
||||
type=trans.type,
|
||||
type_display=type_display.get(trans.type, trans.type),
|
||||
payment_method=trans.payment_method,
|
||||
@@ -974,6 +931,7 @@ async def get_recent_payments(
|
||||
Transaction.type == TransactionType.DEPOSIT.value,
|
||||
Transaction.is_completed == True,
|
||||
Transaction.created_at >= today_start,
|
||||
Transaction.payment_method.in_(REAL_PAYMENT_METHODS),
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -985,6 +943,7 @@ async def get_recent_payments(
|
||||
Transaction.type == TransactionType.DEPOSIT.value,
|
||||
Transaction.is_completed == True,
|
||||
Transaction.created_at >= week_ago,
|
||||
Transaction.payment_method.in_(REAL_PAYMENT_METHODS),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
"""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.database.crud.server_squad import get_all_server_squads
|
||||
from app.database.crud.tariff import (
|
||||
@@ -17,14 +20,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 +131,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 +164,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 +268,10 @@ 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,
|
||||
created_at=tariff.created_at,
|
||||
updated_at=tariff.updated_at,
|
||||
)
|
||||
@@ -276,7 +310,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 +326,10 @@ 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,
|
||||
)
|
||||
|
||||
logger.info('Admin created tariff', admin_id=admin.id, tariff_id=tariff.id, tariff_name=tariff.name)
|
||||
@@ -318,6 +356,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 +423,12 @@ 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
|
||||
|
||||
if updates:
|
||||
await update_tariff(db, tariff, **updates)
|
||||
@@ -394,6 +442,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 +614,217 @@ 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 = 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 = 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],
|
||||
)
|
||||
|
||||
@@ -246,6 +246,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 +281,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 +315,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)
|
||||
|
||||
@@ -4,9 +4,11 @@ from datetime import UTC, datetime, timedelta
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import Integer, and_, func, or_, select
|
||||
from sqlalchemy import Integer, and_, delete as sa_delete, func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.campaign import get_campaign_registration_by_user
|
||||
from app.database.crud.subscription import (
|
||||
extend_subscription,
|
||||
@@ -24,8 +26,11 @@ from app.database.crud.user import (
|
||||
get_users_statistics,
|
||||
subtract_user_balance,
|
||||
)
|
||||
from app.database.crud.user_promo_group import sync_user_primary_promo_group
|
||||
from app.database.models import (
|
||||
GuestPurchase,
|
||||
PromoGroup,
|
||||
ReferralEarning,
|
||||
Subscription,
|
||||
SubscriptionServer,
|
||||
SubscriptionStatus,
|
||||
@@ -33,12 +38,15 @@ from app.database.models import (
|
||||
Transaction,
|
||||
TransactionType,
|
||||
User,
|
||||
UserPromoGroup,
|
||||
UserStatus,
|
||||
)
|
||||
from app.utils.timezone import panel_datetime_to_utc
|
||||
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
from ..schemas.users import (
|
||||
AdminUserGiftItem,
|
||||
AdminUserGiftsResponse,
|
||||
DeleteDeviceResponse,
|
||||
DeleteUserRequest,
|
||||
DeleteUserResponse,
|
||||
@@ -205,10 +213,17 @@ async def _build_subscription_info_async(db: AsyncSession, subscription: Subscri
|
||||
return info
|
||||
|
||||
|
||||
async def _sync_subscription_to_panel(db: AsyncSession, user: User, subscription: Subscription) -> dict:
|
||||
async def _sync_subscription_to_panel(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
subscription: Subscription,
|
||||
reset_traffic: bool = False,
|
||||
reset_traffic_reason: str | None = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Sync user subscription to Remnawave panel.
|
||||
Creates user if not exists, updates if exists.
|
||||
Optionally resets traffic after sync.
|
||||
Returns dict with changes/errors.
|
||||
"""
|
||||
try:
|
||||
@@ -252,6 +267,13 @@ async def _sync_subscription_to_panel(db: AsyncSession, user: User, subscription
|
||||
hwid_limit = resolve_hwid_device_limit_for_payload(subscription)
|
||||
traffic_limit_bytes = subscription.traffic_limit_gb * (1024**3) if subscription.traffic_limit_gb > 0 else 0
|
||||
|
||||
# Загружаем tariff для определения внешнего сквада
|
||||
try:
|
||||
await db.refresh(subscription, ['tariff'])
|
||||
except Exception:
|
||||
pass
|
||||
ext_squad_uuid = subscription.tariff.external_squad_uuid if subscription.tariff else None
|
||||
|
||||
changes = {}
|
||||
async with service.get_api_client() as api:
|
||||
panel_uuid = user.remnawave_uuid
|
||||
@@ -296,6 +318,12 @@ async def _sync_subscription_to_panel(db: AsyncSession, user: User, subscription
|
||||
if hwid_limit is not None:
|
||||
update_kwargs['hwid_device_limit'] = hwid_limit
|
||||
|
||||
# Внешний сквад: синхронизируем из тарифа или сбрасываем
|
||||
if ext_squad_uuid is not None:
|
||||
update_kwargs['external_squad_uuid'] = ext_squad_uuid
|
||||
else:
|
||||
update_kwargs['external_squad_uuid'] = None
|
||||
|
||||
try:
|
||||
updated_panel_user = await api.update_user(**update_kwargs)
|
||||
subscription.subscription_url = updated_panel_user.subscription_url
|
||||
@@ -324,6 +352,8 @@ async def _sync_subscription_to_panel(db: AsyncSession, user: User, subscription
|
||||
}
|
||||
if hwid_limit is not None:
|
||||
create_kwargs['hwid_device_limit'] = hwid_limit
|
||||
if ext_squad_uuid is not None:
|
||||
create_kwargs['external_squad_uuid'] = ext_squad_uuid
|
||||
|
||||
new_panel_user = await api.create_user(**create_kwargs)
|
||||
user.remnawave_uuid = new_panel_user.uuid
|
||||
@@ -334,6 +364,16 @@ async def _sync_subscription_to_panel(db: AsyncSession, user: User, subscription
|
||||
changes['panel_uuid'] = new_panel_user.uuid
|
||||
logger.info('Created user in Remnawave panel', user_id=user.id, uuid=new_panel_user.uuid)
|
||||
|
||||
# Reset traffic on panel if requested
|
||||
if reset_traffic and user.remnawave_uuid:
|
||||
try:
|
||||
await api.reset_user_traffic(user.remnawave_uuid)
|
||||
changes['traffic_reset'] = True
|
||||
reason_text = f' ({reset_traffic_reason})' if reset_traffic_reason else ''
|
||||
logger.info('Reset RemnaWave traffic for user', user_id=user.id, reason=reason_text)
|
||||
except Exception as reset_exc:
|
||||
logger.warning('Failed to reset RemnaWave traffic', user_id=user.id, error=reset_exc)
|
||||
|
||||
user.last_remnawave_sync = datetime.now(UTC)
|
||||
await db.commit()
|
||||
|
||||
@@ -341,7 +381,7 @@ async def _sync_subscription_to_panel(db: AsyncSession, user: User, subscription
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Error syncing user to panel', user_id=user.id, error=e)
|
||||
return {'error': str(e)}
|
||||
return {'error': 'Ошибка синхронизации пользователя с панелью'}
|
||||
|
||||
|
||||
# === List & Search ===
|
||||
@@ -546,11 +586,9 @@ async def get_user_detail(
|
||||
referrals = await get_referrals(db, user.id)
|
||||
referrals_count = len(referrals)
|
||||
|
||||
# Calculate total referral earnings
|
||||
referral_earnings_q = select(func.sum(Transaction.amount_kopeks)).where(
|
||||
Transaction.user_id == user.id,
|
||||
Transaction.type == TransactionType.REFERRAL_REWARD.value,
|
||||
Transaction.is_completed == True,
|
||||
# Calculate total referral earnings (canonical source: ReferralEarning)
|
||||
referral_earnings_q = select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0)).where(
|
||||
ReferralEarning.user_id == user.id
|
||||
)
|
||||
referral_earnings = (await db.execute(referral_earnings_q)).scalar() or 0
|
||||
|
||||
@@ -579,14 +617,18 @@ async def get_user_detail(
|
||||
transactions_result = await db.execute(transactions_q)
|
||||
transactions = transactions_result.scalars().all()
|
||||
|
||||
_EXPENSE_TYPES = {TransactionType.WITHDRAWAL.value, TransactionType.SUBSCRIPTION_PAYMENT.value}
|
||||
_EXPENSE_TYPES = {
|
||||
TransactionType.WITHDRAWAL.value,
|
||||
TransactionType.SUBSCRIPTION_PAYMENT.value,
|
||||
TransactionType.GIFT_PAYMENT.value,
|
||||
}
|
||||
|
||||
recent_transactions = [
|
||||
UserTransactionItem(
|
||||
id=t.id,
|
||||
type=t.type,
|
||||
amount_kopeks=-t.amount_kopeks if t.type in _EXPENSE_TYPES else t.amount_kopeks,
|
||||
amount_rubles=-t.amount_kopeks / 100 if t.type in _EXPENSE_TYPES else t.amount_kopeks / 100,
|
||||
amount_kopeks=-abs(t.amount_kopeks) if t.type in _EXPENSE_TYPES else t.amount_kopeks,
|
||||
amount_rubles=-abs(t.amount_kopeks) / 100 if t.type in _EXPENSE_TYPES else t.amount_kopeks / 100,
|
||||
description=t.description,
|
||||
payment_method=t.payment_method,
|
||||
is_completed=t.is_completed,
|
||||
@@ -625,7 +667,7 @@ async def get_user_detail(
|
||||
referral=referral_info,
|
||||
total_spent_kopeks=user_stats.get('total_spent', 0),
|
||||
purchase_count=user_stats.get('purchase_count', 0),
|
||||
used_promocodes=user.used_promocodes,
|
||||
used_promocodes=user.used_promocodes or 0,
|
||||
has_had_paid_subscription=user.has_had_paid_subscription,
|
||||
lifetime_used_traffic_bytes=user.lifetime_used_traffic_bytes or 0,
|
||||
campaign_name=campaign_name,
|
||||
@@ -987,10 +1029,10 @@ async def update_user_subscription(
|
||||
)
|
||||
|
||||
if request.action == 'extend':
|
||||
if not request.days:
|
||||
if not request.days or request.days <= 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Days parameter is required for extend action',
|
||||
detail='Days must be a positive integer',
|
||||
)
|
||||
|
||||
await extend_subscription(db, subscription, request.days)
|
||||
@@ -1009,6 +1051,36 @@ async def update_user_subscription(
|
||||
subscription=await _build_subscription_info_async(db, subscription),
|
||||
)
|
||||
|
||||
if request.action == 'shorten':
|
||||
if not request.days or request.days <= 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Days must be a positive integer',
|
||||
)
|
||||
|
||||
# Сокращение через отрицательный аргумент: extend_subscription(-N) уменьшает end_date
|
||||
await extend_subscription(db, subscription, -request.days)
|
||||
await db.refresh(subscription)
|
||||
|
||||
# Check if subscription expired after shortening
|
||||
if subscription.end_date <= datetime.now(UTC):
|
||||
subscription.status = SubscriptionStatus.EXPIRED.value
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
# Sync to Remnawave panel
|
||||
await _sync_subscription_to_panel(db, user, subscription)
|
||||
|
||||
logger.info(
|
||||
'Admin shortened subscription for user by days', admin_id=admin.id, user_id=user_id, days=request.days
|
||||
)
|
||||
|
||||
return UpdateSubscriptionResponse(
|
||||
success=True,
|
||||
message=f'Subscription shortened by {request.days} days',
|
||||
subscription=await _build_subscription_info_async(db, subscription),
|
||||
)
|
||||
|
||||
if request.action == 'set_end_date':
|
||||
if not request.end_date:
|
||||
raise HTTPException(
|
||||
@@ -1050,17 +1122,66 @@ async def update_user_subscription(
|
||||
detail='Tariff not found',
|
||||
)
|
||||
|
||||
# Preserve extra purchased devices above the old tariff's base limit
|
||||
from app.database.crud.subscription import calc_device_limit_on_tariff_switch
|
||||
|
||||
old_tariff = await get_tariff_by_id(db, subscription.tariff_id) if subscription.tariff_id else None
|
||||
|
||||
subscription.tariff_id = request.tariff_id
|
||||
subscription.traffic_limit_gb = tariff.traffic_limit_gb
|
||||
subscription.device_limit = tariff.device_limit
|
||||
subscription.device_limit = calc_device_limit_on_tariff_switch(
|
||||
current_device_limit=subscription.device_limit,
|
||||
old_tariff_device_limit=old_tariff.device_limit if old_tariff else None,
|
||||
new_tariff_device_limit=tariff.device_limit,
|
||||
max_device_limit=tariff.max_device_limit,
|
||||
)
|
||||
# Set squads from tariff
|
||||
if tariff.allowed_squads:
|
||||
subscription.connected_squads = tariff.allowed_squads
|
||||
|
||||
# Convert trial subscription to paid when switching to a non-trial tariff
|
||||
if subscription.is_trial and not tariff.is_trial_available:
|
||||
subscription.is_trial = False
|
||||
if subscription.end_date and subscription.end_date > datetime.now(UTC):
|
||||
subscription.status = SubscriptionStatus.ACTIVE.value
|
||||
logger.info('Converted trial subscription to paid', user_id=user_id, tariff_name=tariff.name)
|
||||
|
||||
# Сбрасываем докупленный трафик при смене тарифа
|
||||
from sqlalchemy import delete as sql_delete
|
||||
|
||||
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
|
||||
|
||||
# Записываем транзакцию о смене тарифа
|
||||
from app.database.crud.transaction import create_transaction
|
||||
|
||||
await create_transaction(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
amount_kopeks=0,
|
||||
description=f"Смена тарифа администратором на '{tariff.name}'",
|
||||
commit=False,
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
# Sync to Remnawave panel
|
||||
await _sync_subscription_to_panel(db, user, subscription)
|
||||
# Синхронизируем с RemnaWave (discovery/create + сброс трафика по админ-настройке)
|
||||
try:
|
||||
await _sync_subscription_to_panel(
|
||||
db,
|
||||
user,
|
||||
subscription,
|
||||
reset_traffic=settings.RESET_TRAFFIC_ON_TARIFF_SWITCH,
|
||||
reset_traffic_reason='смена тарифа (cabinet admin)',
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error('Failed to sync tariff switch with RemnaWave', error=e)
|
||||
|
||||
logger.info('Admin changed tariff for user to', admin_id=admin.id, user_id=user_id, tariff_name=tariff.name)
|
||||
|
||||
@@ -1154,15 +1275,25 @@ async def update_user_subscription(
|
||||
detail='traffic_gb parameter is required for add_traffic action',
|
||||
)
|
||||
|
||||
from app.database.crud.subscription import add_subscription_traffic
|
||||
from app.database.crud.subscription import add_subscription_traffic, reactivate_subscription
|
||||
|
||||
await add_subscription_traffic(db, subscription, request.traffic_gb)
|
||||
await db.commit()
|
||||
|
||||
# Реактивируем подписку если она была DISABLED/EXPIRED (например, после LIMITED/EXPIRED в RemnaWave)
|
||||
await reactivate_subscription(db, subscription)
|
||||
|
||||
await db.refresh(subscription)
|
||||
|
||||
# Sync to Remnawave panel
|
||||
await _sync_subscription_to_panel(db, user, subscription)
|
||||
|
||||
# Явно включаем пользователя на панели (PATCH может не снять LIMITED-статус)
|
||||
if getattr(user, 'remnawave_uuid', None) and subscription.status == 'active':
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.enable_remnawave_user(user.remnawave_uuid)
|
||||
|
||||
logger.info('Admin added traffic for user', admin_id=admin.id, traffic_gb=request.traffic_gb, user_id=user_id)
|
||||
|
||||
return UpdateSubscriptionResponse(
|
||||
@@ -1516,8 +1647,22 @@ async def update_user_promo_group(
|
||||
)
|
||||
promo_group_name = promo_group.name
|
||||
|
||||
user.promo_group_id = new_promo_group_id
|
||||
user.updated_at = datetime.now(UTC)
|
||||
# Update M2M table (authoritative source) — not just the legacy FK column.
|
||||
# Without this, sync_user_primary_promo_group overwrites the admin change
|
||||
# on the next transaction.
|
||||
await db.execute(sa_delete(UserPromoGroup).where(UserPromoGroup.user_id == user_id))
|
||||
|
||||
if new_promo_group_id is not None:
|
||||
db.add(
|
||||
UserPromoGroup(
|
||||
user_id=user_id,
|
||||
promo_group_id=new_promo_group_id,
|
||||
assigned_by='admin',
|
||||
)
|
||||
)
|
||||
|
||||
await db.flush()
|
||||
await sync_user_primary_promo_group(db, user_id)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
@@ -1662,7 +1807,7 @@ async def delete_user_device(
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Error deleting device for user', hwid=hwid, user_id=user_id, error=e)
|
||||
return DeleteDeviceResponse(success=False, message=str(e))
|
||||
return DeleteDeviceResponse(success=False, message='Ошибка удаления устройства')
|
||||
|
||||
|
||||
@router.delete('/{user_id}/devices', response_model=ResetDevicesResponse)
|
||||
@@ -1706,7 +1851,7 @@ async def reset_user_devices(
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Error resetting devices for user', user_id=user_id, error=e)
|
||||
return ResetDevicesResponse(success=False, message=str(e))
|
||||
return ResetDevicesResponse(success=False, message='Ошибка сброса устройств')
|
||||
|
||||
|
||||
# === Delete User ===
|
||||
@@ -1774,28 +1919,32 @@ async def full_delete_user(
|
||||
detail='User not found',
|
||||
)
|
||||
|
||||
panel_error: str | None = None
|
||||
deleted_from_panel = False
|
||||
|
||||
# Pre-fetch admin.id to avoid MissingGreenlet after transaction rollback
|
||||
admin_id_val = admin.id
|
||||
|
||||
# UserService.delete_user_account handles both bot DB and Remnawave panel
|
||||
user_service = UserService()
|
||||
success = await user_service.delete_user_account(db, user_id, admin_id_val)
|
||||
|
||||
if success:
|
||||
deleted_from_panel = request.delete_from_panel and user.remnawave_uuid is not None
|
||||
delete_result = await user_service.delete_user_account(
|
||||
db, user_id, admin_id_val, force_panel_delete=request.delete_from_panel
|
||||
)
|
||||
|
||||
reason_text = f' (reason: {request.reason})' if request.reason else ''
|
||||
logger.info('Admin fully deleted user', admin_id=admin_id_val, user_id=user_id, reason_text=reason_text)
|
||||
logger.info(
|
||||
'Admin fully deleted user',
|
||||
admin_id=admin_id_val,
|
||||
user_id=user_id,
|
||||
reason_text=reason_text,
|
||||
bot_deleted=delete_result.bot_deleted,
|
||||
panel_deleted=delete_result.panel_deleted,
|
||||
panel_error=delete_result.panel_error,
|
||||
)
|
||||
|
||||
return FullDeleteUserResponse(
|
||||
success=success,
|
||||
message='User fully deleted from bot and panel' if success else 'Failed to delete user',
|
||||
deleted_from_bot=success,
|
||||
deleted_from_panel=deleted_from_panel,
|
||||
panel_error=panel_error,
|
||||
success=delete_result.bot_deleted,
|
||||
message='User fully deleted from bot and panel' if delete_result.bot_deleted else 'Failed to delete user',
|
||||
deleted_from_bot=delete_result.bot_deleted,
|
||||
deleted_from_panel=delete_result.panel_deleted,
|
||||
panel_error=delete_result.panel_error,
|
||||
)
|
||||
|
||||
|
||||
@@ -1904,21 +2053,6 @@ async def reset_user_subscription(
|
||||
panel_deactivated=False,
|
||||
)
|
||||
|
||||
from app.database.crud.subscription import is_active_paid_subscription
|
||||
|
||||
if is_active_paid_subscription(user.subscription):
|
||||
logger.info(
|
||||
'⏭️ Пропуск сброса подписки: у пользователя активная оплаченная подписка',
|
||||
user_id=user_id,
|
||||
remnawave_uuid=user.remnawave_uuid,
|
||||
)
|
||||
return ResetSubscriptionResponse(
|
||||
success=False,
|
||||
message='Cannot reset active paid subscription. Subscription is still active and paid.',
|
||||
subscription_deleted=False,
|
||||
panel_deactivated=False,
|
||||
)
|
||||
|
||||
# Deactivate in Remnawave panel if requested
|
||||
if request.deactivate_in_panel and user.remnawave_uuid:
|
||||
try:
|
||||
@@ -1929,7 +2063,7 @@ async def reset_user_subscription(
|
||||
if panel_deactivated:
|
||||
logger.info('Disabled Remnawave user for subscription reset', remnawave_uuid=user.remnawave_uuid)
|
||||
except Exception as e:
|
||||
panel_error = str(e)
|
||||
panel_error = 'Ошибка обработки пользователя в Remnawave'
|
||||
logger.warning('Failed to disable Remnawave user during subscription reset', error=e)
|
||||
|
||||
# Delete subscription from database
|
||||
@@ -1999,7 +2133,7 @@ async def disable_user(
|
||||
if panel_deactivated:
|
||||
logger.info('Disabled Remnawave user', remnawave_uuid=user.remnawave_uuid)
|
||||
except Exception as e:
|
||||
panel_error = str(e)
|
||||
panel_error = 'Ошибка обработки пользователя в Remnawave'
|
||||
logger.warning('Failed to disable Remnawave user', error=e)
|
||||
|
||||
# Deactivate subscription in bot database (skip if active paid subscription)
|
||||
@@ -2103,14 +2237,18 @@ async def get_user_transactions(
|
||||
result = await db.execute(query)
|
||||
transactions = result.scalars().all()
|
||||
|
||||
_EXPENSE_TYPES = {TransactionType.WITHDRAWAL.value, TransactionType.SUBSCRIPTION_PAYMENT.value}
|
||||
_EXPENSE_TYPES = {
|
||||
TransactionType.WITHDRAWAL.value,
|
||||
TransactionType.SUBSCRIPTION_PAYMENT.value,
|
||||
TransactionType.GIFT_PAYMENT.value,
|
||||
}
|
||||
|
||||
items = [
|
||||
UserTransactionItem(
|
||||
id=t.id,
|
||||
type=t.type,
|
||||
amount_kopeks=-t.amount_kopeks if t.type in _EXPENSE_TYPES else t.amount_kopeks,
|
||||
amount_rubles=-t.amount_kopeks / 100 if t.type in _EXPENSE_TYPES else t.amount_kopeks / 100,
|
||||
amount_kopeks=-abs(t.amount_kopeks) if t.type in _EXPENSE_TYPES else t.amount_kopeks,
|
||||
amount_rubles=-abs(t.amount_kopeks) / 100 if t.type in _EXPENSE_TYPES else t.amount_kopeks / 100,
|
||||
description=t.description,
|
||||
payment_method=t.payment_method,
|
||||
is_completed=t.is_completed,
|
||||
@@ -2378,8 +2516,25 @@ async def sync_user_from_panel(
|
||||
if panel_user.expire_at:
|
||||
panel_expire_utc = panel_datetime_to_utc(panel_user.expire_at)
|
||||
|
||||
sub_end_utc = sub.end_date if sub.end_date and sub.end_date.tzinfo else sub.end_date
|
||||
sub_end_utc = sub.end_date
|
||||
if sub_end_utc is not None and sub_end_utc.tzinfo is None:
|
||||
sub_end_utc = sub_end_utc.replace(tzinfo=UTC)
|
||||
if sub_end_utc != panel_expire_utc:
|
||||
# Предупреждаем если локальная дата новее панельной
|
||||
# (например, автопокупка уже продлила подписку)
|
||||
if sub_end_utc and panel_expire_utc and sub_end_utc > panel_expire_utc:
|
||||
logger.warning(
|
||||
'Sync: локальная end_date новее панельной, перезаписываем. '
|
||||
'Возможно автопокупка уже продлила подписку.',
|
||||
user_id=user_id,
|
||||
local_end_date=sub_end_utc.isoformat(),
|
||||
panel_expire_at=panel_expire_utc.isoformat(),
|
||||
)
|
||||
errors.append(
|
||||
f'Warning: local end_date ({sub_end_utc.isoformat()}) is newer than '
|
||||
f'panel expire_at ({panel_expire_utc.isoformat()}). '
|
||||
f'Panel value applied — check if auto-purchase extended subscription.'
|
||||
)
|
||||
changes['end_date'] = {
|
||||
'old': sub.end_date.isoformat() if sub.end_date else None,
|
||||
'new': panel_expire_utc.isoformat(),
|
||||
@@ -2564,6 +2719,13 @@ async def sync_user_to_panel(
|
||||
hwid_limit = resolve_hwid_device_limit_for_payload(sub)
|
||||
traffic_limit_bytes = sub.traffic_limit_gb * (1024**3) if sub.traffic_limit_gb > 0 else 0
|
||||
|
||||
# Загружаем tariff для внешнего сквада
|
||||
try:
|
||||
await db.refresh(sub, ['tariff'])
|
||||
except Exception:
|
||||
pass
|
||||
ext_squad_uuid = sub.tariff.external_squad_uuid if sub.tariff else None
|
||||
|
||||
async with service.get_api_client() as api:
|
||||
# Validate existing UUID
|
||||
if panel_uuid:
|
||||
@@ -2615,6 +2777,12 @@ async def sync_user_to_panel(
|
||||
update_kwargs['hwid_device_limit'] = hwid_limit
|
||||
changes['device_limit'] = hwid_limit
|
||||
|
||||
# Внешний сквад: синхронизируем из тарифа или сбрасываем
|
||||
if ext_squad_uuid is not None:
|
||||
update_kwargs['external_squad_uuid'] = ext_squad_uuid
|
||||
else:
|
||||
update_kwargs['external_squad_uuid'] = None
|
||||
|
||||
try:
|
||||
await api.update_user(**update_kwargs)
|
||||
action = 'updated'
|
||||
@@ -2641,6 +2809,8 @@ async def sync_user_to_panel(
|
||||
|
||||
if hwid_limit is not None:
|
||||
create_kwargs['hwid_device_limit'] = hwid_limit
|
||||
if ext_squad_uuid is not None:
|
||||
create_kwargs['external_squad_uuid'] = ext_squad_uuid
|
||||
|
||||
new_panel_user = await api.create_user(**create_kwargs)
|
||||
panel_uuid = new_panel_user.uuid
|
||||
@@ -2678,3 +2848,118 @@ async def sync_user_to_panel(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f'Sync error: {e!s}',
|
||||
)
|
||||
|
||||
|
||||
# === User Gifts ===
|
||||
|
||||
|
||||
@router.get('/{user_id}/gifts', response_model=AdminUserGiftsResponse)
|
||||
async def get_user_gifts(
|
||||
user_id: int,
|
||||
admin: User = Depends(require_permission('users:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> AdminUserGiftsResponse:
|
||||
"""Get all gift subscriptions sent and received by user."""
|
||||
from sqlalchemy.orm import noload
|
||||
|
||||
# Lightweight existence check (avoids eager-loading all User relationships)
|
||||
user_exists = await db.execute(select(User.id).where(User.id == user_id))
|
||||
if not user_exists.scalar_one_or_none():
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='User not found')
|
||||
|
||||
# True totals via COUNT queries
|
||||
sent_total = (
|
||||
await db.execute(
|
||||
select(func.count(GuestPurchase.id)).where(
|
||||
GuestPurchase.buyer_user_id == user_id,
|
||||
GuestPurchase.is_gift.is_(True),
|
||||
)
|
||||
)
|
||||
).scalar() or 0
|
||||
|
||||
received_total = (
|
||||
await db.execute(
|
||||
select(func.count(GuestPurchase.id)).where(
|
||||
GuestPurchase.user_id == user_id,
|
||||
GuestPurchase.is_gift.is_(True),
|
||||
)
|
||||
)
|
||||
).scalar() or 0
|
||||
|
||||
# Sent gifts (user is buyer) — suppress unneeded relationships
|
||||
sent_result = await db.execute(
|
||||
select(GuestPurchase)
|
||||
.options(
|
||||
selectinload(GuestPurchase.tariff),
|
||||
selectinload(GuestPurchase.user),
|
||||
noload(GuestPurchase.buyer),
|
||||
noload(GuestPurchase.landing),
|
||||
)
|
||||
.where(
|
||||
GuestPurchase.buyer_user_id == user_id,
|
||||
GuestPurchase.is_gift.is_(True),
|
||||
)
|
||||
.order_by(GuestPurchase.created_at.desc())
|
||||
.limit(200)
|
||||
)
|
||||
sent_purchases = sent_result.scalars().all()
|
||||
|
||||
# Received gifts (user is recipient) — suppress unneeded relationships
|
||||
received_result = await db.execute(
|
||||
select(GuestPurchase)
|
||||
.options(
|
||||
selectinload(GuestPurchase.tariff),
|
||||
selectinload(GuestPurchase.buyer),
|
||||
noload(GuestPurchase.user),
|
||||
noload(GuestPurchase.landing),
|
||||
)
|
||||
.where(
|
||||
GuestPurchase.user_id == user_id,
|
||||
GuestPurchase.is_gift.is_(True),
|
||||
)
|
||||
.order_by(GuestPurchase.created_at.desc())
|
||||
.limit(200)
|
||||
)
|
||||
received_purchases = received_result.scalars().all()
|
||||
|
||||
sent_items = [_build_gift_item(p, receiver=p.user) for p in sent_purchases]
|
||||
received_items = [_build_gift_item(p, buyer=p.buyer) for p in received_purchases]
|
||||
|
||||
return AdminUserGiftsResponse(
|
||||
sent=sent_items,
|
||||
received=received_items,
|
||||
sent_total=sent_total,
|
||||
received_total=received_total,
|
||||
)
|
||||
|
||||
|
||||
def _build_gift_item(
|
||||
p: GuestPurchase,
|
||||
receiver: User | None = None,
|
||||
buyer: User | None = None,
|
||||
) -> AdminUserGiftItem:
|
||||
"""Build an admin gift item from a GuestPurchase."""
|
||||
tariff_name = p.tariff.name if p.tariff else None
|
||||
device_limit = p.tariff.device_limit if p.tariff else 1
|
||||
return AdminUserGiftItem(
|
||||
id=p.id,
|
||||
token=p.token[:12],
|
||||
status=p.status,
|
||||
tariff_name=tariff_name,
|
||||
period_days=p.period_days,
|
||||
device_limit=device_limit,
|
||||
amount_kopeks=p.amount_kopeks,
|
||||
payment_method=p.payment_method,
|
||||
gift_recipient_type=p.gift_recipient_type,
|
||||
gift_recipient_value=p.gift_recipient_value,
|
||||
gift_message=p.gift_message,
|
||||
buyer_user_id=p.buyer_user_id,
|
||||
buyer_username=buyer.username if buyer else None,
|
||||
buyer_full_name=buyer.full_name if buyer else None,
|
||||
receiver_user_id=p.user_id,
|
||||
receiver_username=receiver.username if receiver else None,
|
||||
receiver_full_name=receiver.full_name if receiver else None,
|
||||
created_at=p.created_at,
|
||||
paid_at=p.paid_at,
|
||||
delivered_at=p.delivered_at,
|
||||
)
|
||||
|
||||
+365
-69
@@ -5,8 +5,8 @@ import hashlib
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -16,6 +16,7 @@ from app.database.crud.campaign import (
|
||||
get_campaign_registration_by_user,
|
||||
)
|
||||
from app.database.crud.rbac import UserRoleCRUD
|
||||
from app.database.crud.system_setting import get_setting_value
|
||||
from app.database.crud.user import (
|
||||
clear_email_change_pending,
|
||||
create_user,
|
||||
@@ -31,6 +32,7 @@ from app.database.models import CabinetRefreshToken, User
|
||||
from app.services.campaign_service import AdvertisingCampaignService
|
||||
from app.services.disposable_email_service import disposable_email_service
|
||||
from app.services.referral_service import process_referral_registration
|
||||
from app.utils.cache import RateLimitCache, TokenReplayCache
|
||||
from app.utils.timezone import panel_datetime_to_utc
|
||||
|
||||
from ..auth import (
|
||||
@@ -40,6 +42,7 @@ from ..auth import (
|
||||
hash_password,
|
||||
validate_telegram_init_data,
|
||||
validate_telegram_login_widget,
|
||||
validate_telegram_oidc_token,
|
||||
verify_password,
|
||||
)
|
||||
from ..auth.email_verification import (
|
||||
@@ -53,8 +56,10 @@ from ..auth.email_verification import (
|
||||
)
|
||||
from ..auth.jwt_handler import get_refresh_token_expires_at
|
||||
from ..dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
from ..ip_utils import get_client_ip
|
||||
from ..schemas.auth import (
|
||||
AuthResponse,
|
||||
AutoLoginRequest,
|
||||
CampaignBonusInfo,
|
||||
EmailChangeRequest,
|
||||
EmailChangeResponse,
|
||||
@@ -68,6 +73,7 @@ from ..schemas.auth import (
|
||||
RefreshTokenRequest,
|
||||
RegisterResponse,
|
||||
TelegramAuthRequest,
|
||||
TelegramOIDCAuthRequest,
|
||||
TelegramWidgetAuthRequest,
|
||||
TokenResponse,
|
||||
UserResponse,
|
||||
@@ -182,7 +188,12 @@ async def _process_campaign_bonus(
|
||||
user.referred_by_id = campaign.partner_user_id
|
||||
await db.flush()
|
||||
try:
|
||||
await process_referral_registration(db, user.id, campaign.partner_user_id, bot=None)
|
||||
from aiogram import Bot
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
|
||||
bot = Bot(token=settings.BOT_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
|
||||
await process_referral_registration(db, user.id, campaign.partner_user_id, bot=bot)
|
||||
logger.info(
|
||||
'Referral set from campaign partner',
|
||||
user_id=user.id,
|
||||
@@ -236,7 +247,12 @@ async def _process_referral_code(
|
||||
return
|
||||
user.referred_by_id = referrer.id
|
||||
await db.flush()
|
||||
await process_referral_registration(db, user.id, referrer.id, bot=None)
|
||||
from aiogram import Bot
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
|
||||
bot = Bot(token=settings.BOT_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
|
||||
await process_referral_registration(db, user.id, referrer.id, bot=bot)
|
||||
logger.info('Referral applied from code', user_id=user.id, referrer_id=referrer.id, referral_code=referral_code)
|
||||
except Exception as e:
|
||||
logger.error('Failed to process referral code', error=e, referral_code=referral_code)
|
||||
@@ -250,6 +266,8 @@ async def _sync_subscription_from_panel_by_email(db: AsyncSession, user: User) -
|
||||
if not user.email:
|
||||
return
|
||||
|
||||
user_email = user.email # Save before try block — ORM access may fail after rollback
|
||||
|
||||
try:
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
|
||||
@@ -269,6 +287,19 @@ async def _sync_subscription_from_panel_by_email(db: AsyncSession, user: User) -
|
||||
panel_user = panel_users[0]
|
||||
logger.info('Found subscription in panel for email', email=user.email, uuid=panel_user.uuid)
|
||||
|
||||
# Check if another user already owns this remnawave_uuid
|
||||
from app.database.crud.user import get_user_by_remnawave_uuid
|
||||
|
||||
existing_owner = await get_user_by_remnawave_uuid(db, panel_user.uuid)
|
||||
if existing_owner and existing_owner.id != user.id:
|
||||
logger.warning(
|
||||
'Panel UUID already belongs to another user, skipping sync',
|
||||
email=user.email,
|
||||
panel_uuid=panel_user.uuid,
|
||||
existing_owner_id=existing_owner.id,
|
||||
)
|
||||
return
|
||||
|
||||
# Link user to panel
|
||||
user.remnawave_uuid = panel_user.uuid
|
||||
|
||||
@@ -287,7 +318,7 @@ async def _sync_subscription_from_panel_by_email(db: AsyncSession, user: User) -
|
||||
connected_squads = [s.get('uuid', '') for s in (panel_user.active_internal_squads or []) if s.get('uuid')]
|
||||
|
||||
# Device limit from panel
|
||||
device_limit = panel_user.hwid_device_limit or 1
|
||||
device_limit = panel_user.hwid_device_limit or 0
|
||||
|
||||
# Determine status — expire_at is now naive UTC
|
||||
current_time = datetime.now(UTC)
|
||||
@@ -344,14 +375,16 @@ async def _sync_subscription_from_panel_by_email(db: AsyncSession, user: User) -
|
||||
await db.commit()
|
||||
|
||||
except Exception as e:
|
||||
logger.warning('Failed to sync subscription from panel for', email=user.email, error=e)
|
||||
# Don't rollback - it detaches user object and breaks subsequent operations
|
||||
# The sync is non-critical, main verification already succeeded
|
||||
logger.warning('Failed to sync subscription from panel for', email=user_email, error=e)
|
||||
await db.rollback()
|
||||
# Refresh user after rollback — object is expired and lazy loads fail in async
|
||||
await db.refresh(user)
|
||||
|
||||
|
||||
@router.post('/telegram', response_model=AuthResponse)
|
||||
async def auth_telegram(
|
||||
request: TelegramAuthRequest,
|
||||
raw_request: Request,
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""
|
||||
@@ -360,6 +393,13 @@ async def auth_telegram(
|
||||
This endpoint validates the initData from Telegram WebApp and returns
|
||||
JWT tokens for authenticated access.
|
||||
"""
|
||||
client_ip = get_client_ip(raw_request)
|
||||
if await RateLimitCache.is_ip_rate_limited(client_ip, 'telegram_initdata', 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'},
|
||||
)
|
||||
user_data = validate_telegram_init_data(request.init_data)
|
||||
|
||||
if not user_data:
|
||||
@@ -450,6 +490,7 @@ async def auth_telegram(
|
||||
@router.post('/telegram/widget', response_model=AuthResponse)
|
||||
async def auth_telegram_widget(
|
||||
request: TelegramWidgetAuthRequest,
|
||||
raw_request: Request,
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""
|
||||
@@ -458,6 +499,15 @@ async def auth_telegram_widget(
|
||||
This endpoint validates data from Telegram Login Widget and returns
|
||||
JWT tokens for authenticated access.
|
||||
"""
|
||||
# Rate limit
|
||||
client_ip = get_client_ip(raw_request)
|
||||
if await RateLimitCache.is_ip_rate_limited(client_ip, 'telegram_widget', 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'},
|
||||
)
|
||||
|
||||
widget_data = request.model_dump(exclude={'campaign_slug', 'referral_code'})
|
||||
|
||||
if not validate_telegram_login_widget(widget_data):
|
||||
@@ -525,6 +575,133 @@ async def auth_telegram_widget(
|
||||
return response
|
||||
|
||||
|
||||
@router.post('/telegram/oidc', response_model=AuthResponse)
|
||||
async def auth_telegram_oidc(
|
||||
request: TelegramOIDCAuthRequest,
|
||||
raw_request: Request,
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""
|
||||
Authenticate using Telegram OIDC id_token (popup flow).
|
||||
|
||||
The frontend uses Telegram.Login.init() popup which returns an id_token.
|
||||
We validate it via JWKS and create/login the user.
|
||||
"""
|
||||
# Rate limit
|
||||
client_ip = get_client_ip(raw_request)
|
||||
if await RateLimitCache.is_ip_rate_limited(client_ip, 'telegram_oidc', 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'},
|
||||
)
|
||||
|
||||
# Check OIDC enabled from DB first, fallback to env
|
||||
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: reject if this exact token was already used
|
||||
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',
|
||||
)
|
||||
|
||||
# Extract user info from OIDC claims
|
||||
try:
|
||||
telegram_id = int(claims.get('id', claims.get('sub', 0)))
|
||||
except (ValueError, TypeError) as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail='Invalid user ID in OIDC claims',
|
||||
) from e
|
||||
if not telegram_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail='Missing user ID in OIDC claims',
|
||||
)
|
||||
|
||||
first_name = claims.get('name', claims.get('given_name', ''))
|
||||
username = claims.get('preferred_username')
|
||||
last_name = claims.get('family_name')
|
||||
language = claims.get('locale', 'ru')[:2] if claims.get('locale') else 'ru'
|
||||
|
||||
user = await get_user_by_telegram_id(db, telegram_id)
|
||||
|
||||
# Resolve referral code for new users
|
||||
referrer_id = None
|
||||
if request.referral_code and not user:
|
||||
try:
|
||||
referrer = await get_user_by_referral_code(db, request.referral_code)
|
||||
if referrer:
|
||||
referrer_id = referrer.id
|
||||
except (ValueError, LookupError) as e:
|
||||
logger.warning('Failed to resolve referral code', referral_code=request.referral_code, error=str(e))
|
||||
|
||||
if not user:
|
||||
logger.info('Creating new user from cabinet OIDC', telegram_id=telegram_id, username=username)
|
||||
user = await create_user(
|
||||
db=db,
|
||||
telegram_id=telegram_id,
|
||||
username=username,
|
||||
first_name=first_name,
|
||||
last_name=last_name,
|
||||
language=language,
|
||||
referred_by_id=referrer_id,
|
||||
)
|
||||
logger.info('User created successfully', user_id=user.id, telegram_id=user.telegram_id)
|
||||
|
||||
if user.status != 'active':
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='User account is not active',
|
||||
)
|
||||
|
||||
# Update user info from OIDC claims
|
||||
if username and username != user.username:
|
||||
user.username = username
|
||||
if first_name and first_name != user.first_name:
|
||||
user.first_name = first_name
|
||||
if last_name is not None and last_name != user.last_name:
|
||||
user.last_name = last_name
|
||||
|
||||
user.cabinet_last_login = datetime.now(UTC)
|
||||
await db.commit()
|
||||
|
||||
response = await _create_auth_response(user, db)
|
||||
await _store_refresh_token(db, user.id, response.refresh_token)
|
||||
|
||||
await _process_referral_code(db, user, request.referral_code)
|
||||
|
||||
response.campaign_bonus = await _process_campaign_bonus(db, user, request.campaign_slug)
|
||||
if response.campaign_bonus:
|
||||
response.user = _user_to_response(user)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@router.post('/email/register')
|
||||
async def register_email(
|
||||
request: EmailRegisterRequest,
|
||||
@@ -544,8 +721,9 @@ async def register_email(
|
||||
detail='Disposable email addresses are not allowed',
|
||||
)
|
||||
|
||||
# Check if email already exists
|
||||
existing_user = await db.execute(select(User).where(User.email == request.email))
|
||||
# Check if email already exists (case-insensitive)
|
||||
email_lower = (request.email or '').strip().lower()
|
||||
existing_user = await db.execute(select(User).where(func.lower(User.email) == email_lower))
|
||||
if existing_user.scalar_one_or_none():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
@@ -559,53 +737,61 @@ async def register_email(
|
||||
detail='You already have a verified email',
|
||||
)
|
||||
|
||||
# Generate verification token
|
||||
verification_token = generate_verification_token()
|
||||
verification_expires = get_verification_expires_at()
|
||||
|
||||
# Update user
|
||||
user.email = request.email
|
||||
user.email_verified = False
|
||||
user.password_hash = hash_password(request.password)
|
||||
user.email_verification_token = verification_token
|
||||
user.email_verification_expires = verification_expires
|
||||
|
||||
await db.commit()
|
||||
if not settings.is_cabinet_email_verification_enabled():
|
||||
# Верификация отключена — сразу помечаем email как verified
|
||||
user.email_verified = True
|
||||
user.email_verified_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
else:
|
||||
# Generate verification token
|
||||
verification_token = generate_verification_token()
|
||||
verification_expires = get_verification_expires_at()
|
||||
|
||||
# Send verification email asynchronously (smtplib is blocking)
|
||||
if settings.is_cabinet_email_verification_enabled() and email_service.is_configured():
|
||||
cabinet_url = settings.CABINET_URL
|
||||
verification_url = f'{cabinet_url}/verify-email'
|
||||
lang = user.language or 'ru'
|
||||
full_url = f'{verification_url}?token={verification_token}'
|
||||
expire_hours = settings.get_cabinet_email_verification_expire_hours()
|
||||
user.email_verified = False
|
||||
user.email_verification_token = verification_token
|
||||
user.email_verification_expires = verification_expires
|
||||
await db.commit()
|
||||
|
||||
# Check for admin template override
|
||||
override = await get_rendered_override(
|
||||
'email_verification',
|
||||
lang,
|
||||
context={
|
||||
'username': user.first_name or '',
|
||||
'verification_url': full_url,
|
||||
'expire_hours': str(expire_hours),
|
||||
},
|
||||
db=db,
|
||||
)
|
||||
custom_subject, custom_body = override if override else (None, None)
|
||||
# Send verification email asynchronously (smtplib is blocking)
|
||||
if email_service.is_configured():
|
||||
cabinet_url = settings.CABINET_URL
|
||||
verification_url = f'{cabinet_url}/verify-email'
|
||||
lang = user.language or 'ru'
|
||||
full_url = f'{verification_url}?token={verification_token}'
|
||||
expire_hours = settings.get_cabinet_email_verification_expire_hours()
|
||||
|
||||
await asyncio.to_thread(
|
||||
email_service.send_verification_email,
|
||||
to_email=request.email,
|
||||
verification_token=verification_token,
|
||||
verification_url=verification_url,
|
||||
username=user.first_name,
|
||||
language=lang,
|
||||
custom_subject=custom_subject,
|
||||
custom_body_html=custom_body,
|
||||
)
|
||||
# Check for admin template override
|
||||
override = await get_rendered_override(
|
||||
'email_verification',
|
||||
lang,
|
||||
context={
|
||||
'username': user.first_name or '',
|
||||
'verification_url': full_url,
|
||||
'expire_hours': str(expire_hours),
|
||||
},
|
||||
db=db,
|
||||
)
|
||||
custom_subject, custom_body = override or (None, None)
|
||||
|
||||
await asyncio.to_thread(
|
||||
email_service.send_verification_email,
|
||||
to_email=request.email,
|
||||
verification_token=verification_token,
|
||||
verification_url=verification_url,
|
||||
username=user.first_name,
|
||||
language=lang,
|
||||
custom_subject=custom_subject,
|
||||
custom_body_html=custom_body,
|
||||
)
|
||||
|
||||
return {
|
||||
'message': 'Verification email sent',
|
||||
'message': 'Email linked successfully'
|
||||
if not settings.is_cabinet_email_verification_enabled()
|
||||
else 'Verification email sent',
|
||||
'email': request.email,
|
||||
}
|
||||
|
||||
@@ -613,6 +799,7 @@ async def register_email(
|
||||
@router.post('/email/register/standalone', response_model=RegisterResponse)
|
||||
async def register_email_standalone(
|
||||
request: EmailRegisterStandaloneRequest,
|
||||
raw_request: Request,
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""
|
||||
@@ -625,6 +812,13 @@ async def register_email_standalone(
|
||||
|
||||
If TEST_EMAIL is configured, test email accounts are auto-verified.
|
||||
"""
|
||||
client_ip = get_client_ip(raw_request)
|
||||
if await RateLimitCache.is_ip_rate_limited(client_ip, 'email_register', limit=5, window=60, fail_closed=True):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail='Too many requests',
|
||||
headers={'Retry-After': '60'},
|
||||
)
|
||||
# Check if this is a test email registration
|
||||
is_test_email = settings.is_test_email(request.email)
|
||||
|
||||
@@ -644,8 +838,9 @@ async def register_email_standalone(
|
||||
detail='Disposable email addresses are not allowed',
|
||||
)
|
||||
|
||||
# Проверить что email не занят
|
||||
existing = await db.execute(select(User).where(User.email == request.email))
|
||||
# Проверить что email не занят (без учёта регистра)
|
||||
email_lower = (request.email or '').strip().lower()
|
||||
existing = await db.execute(select(User).where(func.lower(User.email) == email_lower))
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
@@ -685,12 +880,12 @@ async def register_email_standalone(
|
||||
referred_by_id=referrer.id if referrer else None,
|
||||
)
|
||||
|
||||
# Для тестового email - автоматически верифицировать
|
||||
if is_test_email:
|
||||
# Для тестового email или отключённой верификации - автоматически верифицировать
|
||||
if is_test_email or not settings.is_cabinet_email_verification_enabled():
|
||||
user.email_verified = True
|
||||
user.email_verified_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
logger.info('Test email auto-verified: user_id', email=request.email, user_id=user.id)
|
||||
logger.info('Email auto-verified (test or verification disabled)', email=request.email, user_id=user.id)
|
||||
else:
|
||||
# Сгенерировать токен верификации
|
||||
verification_token = generate_verification_token()
|
||||
@@ -718,7 +913,7 @@ async def register_email_standalone(
|
||||
},
|
||||
db=db,
|
||||
)
|
||||
custom_subject, custom_body = override if override else (None, None)
|
||||
custom_subject, custom_body = override or (None, None)
|
||||
|
||||
await asyncio.to_thread(
|
||||
email_service.send_verification_email,
|
||||
@@ -734,7 +929,12 @@ async def register_email_standalone(
|
||||
# Обработать реферальную регистрацию (если есть реферер)
|
||||
if referrer:
|
||||
try:
|
||||
await process_referral_registration(db, user.id, referrer.id, bot=None)
|
||||
from aiogram import Bot
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
|
||||
bot = Bot(token=settings.BOT_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
|
||||
await process_referral_registration(db, user.id, referrer.id, bot=bot)
|
||||
logger.info(
|
||||
'Processed referral registration: user_id=, referrer_id', user_id=user.id, referrer_id=referrer.id
|
||||
)
|
||||
@@ -743,20 +943,29 @@ async def register_email_standalone(
|
||||
# Не прерываем регистрацию из-за ошибки реферальной системы
|
||||
|
||||
# Для тестового email - сразу можно логиниться (уже verified)
|
||||
# Для обычного email - требуется верификация
|
||||
# Для обычного email - требуется верификация (если включена)
|
||||
verification_required = not is_test_email and settings.is_cabinet_email_verification_enabled()
|
||||
return RegisterResponse(
|
||||
message='Verification email sent. Please check your inbox.',
|
||||
email=request.email,
|
||||
requires_verification=not is_test_email,
|
||||
requires_verification=verification_required,
|
||||
)
|
||||
|
||||
|
||||
@router.post('/email/verify', response_model=AuthResponse)
|
||||
async def verify_email(
|
||||
request: EmailVerifyRequest,
|
||||
raw_request: Request,
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Verify email with token and return auth tokens."""
|
||||
client_ip = get_client_ip(raw_request)
|
||||
if await RateLimitCache.is_ip_rate_limited(client_ip, 'email_verify', 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'},
|
||||
)
|
||||
# Find user with this token
|
||||
result = await db.execute(select(User).where(User.email_verification_token == request.token))
|
||||
user = result.scalar_one_or_none()
|
||||
@@ -842,7 +1051,7 @@ async def resend_verification(
|
||||
},
|
||||
db=db,
|
||||
)
|
||||
custom_subject, custom_body = override if override else (None, None)
|
||||
custom_subject, custom_body = override or (None, None)
|
||||
|
||||
await asyncio.to_thread(
|
||||
email_service.send_verification_email,
|
||||
@@ -871,17 +1080,27 @@ async def resend_verification(
|
||||
@router.post('/email/login', response_model=AuthResponse)
|
||||
async def login_email(
|
||||
request: EmailLoginRequest,
|
||||
raw_request: Request,
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Login with email and password.
|
||||
|
||||
Test email accounts (configured via TEST_EMAIL) bypass email verification.
|
||||
"""
|
||||
client_ip = get_client_ip(raw_request)
|
||||
if await RateLimitCache.is_ip_rate_limited(client_ip, 'email_login', 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'},
|
||||
)
|
||||
|
||||
# Check if this is a test email login
|
||||
is_test_email = settings.is_test_email(request.email)
|
||||
|
||||
# Find user by email
|
||||
result = await db.execute(select(User).where(User.email == request.email))
|
||||
# Find user by email (case-insensitive)
|
||||
email_lower = (request.email or '').strip().lower()
|
||||
result = await db.execute(select(User).where(func.lower(User.email) == email_lower))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user:
|
||||
@@ -917,8 +1136,8 @@ async def login_email(
|
||||
detail='Invalid email or password',
|
||||
)
|
||||
|
||||
# Test email bypasses verification check
|
||||
if not user.email_verified and not is_test_email:
|
||||
# Test email and disabled verification bypass the check
|
||||
if not user.email_verified and not is_test_email and settings.is_cabinet_email_verification_enabled():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Please verify your email first',
|
||||
@@ -960,11 +1179,11 @@ async def refresh_token(
|
||||
|
||||
try:
|
||||
user_id = int(payload.get('sub'))
|
||||
except (TypeError, ValueError):
|
||||
except (TypeError, ValueError) as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail='Invalid token payload',
|
||||
)
|
||||
) from e
|
||||
|
||||
# Verify token exists in database and is not revoked
|
||||
token_hash = hashlib.sha256(request.refresh_token.encode()).hexdigest()
|
||||
@@ -1036,17 +1255,86 @@ async def logout(
|
||||
return {'message': 'Logged out successfully'}
|
||||
|
||||
|
||||
@router.post('/login/auto', response_model=AuthResponse)
|
||||
async def auto_login(
|
||||
request: AutoLoginRequest,
|
||||
raw_request: Request,
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Auto-login using a short-lived JWT from guest purchase success page."""
|
||||
client_ip = get_client_ip(raw_request)
|
||||
if await RateLimitCache.is_ip_rate_limited(client_ip, 'auto_login', limit=5, window=60, fail_closed=True):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail='Too many requests',
|
||||
headers={'Retry-After': '60'},
|
||||
)
|
||||
|
||||
payload = get_token_payload(request.token, expected_type='auto_login')
|
||||
if not payload:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail='Invalid or expired auto-login token',
|
||||
)
|
||||
|
||||
try:
|
||||
user_id = int(payload['sub'])
|
||||
except (KeyError, ValueError, TypeError) as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail='Invalid token payload',
|
||||
) from e
|
||||
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail='User not found',
|
||||
)
|
||||
|
||||
if user.status != 'active':
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Account is deactivated',
|
||||
)
|
||||
|
||||
response = await _create_auth_response(user, db)
|
||||
await _store_refresh_token(db, user.id, response.refresh_token)
|
||||
user.cabinet_last_login = datetime.now(UTC)
|
||||
await db.commit()
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@router.post('/password/forgot')
|
||||
async def forgot_password(
|
||||
request: PasswordForgotRequest,
|
||||
raw_request: Request,
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Request password reset."""
|
||||
result = await db.execute(select(User).where(User.email == request.email))
|
||||
client_ip = get_client_ip(raw_request)
|
||||
if await RateLimitCache.is_ip_rate_limited(client_ip, 'password_forgot', limit=3, window=60, fail_closed=True):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail='Too many requests',
|
||||
headers={'Retry-After': '60'},
|
||||
)
|
||||
email_lower = (request.email or '').strip().lower()
|
||||
result = await db.execute(select(User).where(func.lower(User.email) == email_lower))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
# Always return success to prevent email enumeration
|
||||
if not user or not user.email_verified:
|
||||
if not user:
|
||||
return {'message': 'If the email exists, a password reset link has been sent'}
|
||||
|
||||
# Auto-fix guest-created email users who have a password but weren't verified
|
||||
if not user.email_verified and user.password_hash and user.auth_type == 'email':
|
||||
user.email_verified = True
|
||||
user.email_verified_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
|
||||
if not user.email_verified:
|
||||
return {'message': 'If the email exists, a password reset link has been sent'}
|
||||
|
||||
# Generate reset token
|
||||
@@ -1072,7 +1360,7 @@ async def forgot_password(
|
||||
context={'username': user.first_name or '', 'reset_url': full_url, 'expire_hours': str(expire_hours)},
|
||||
db=db,
|
||||
)
|
||||
custom_subject, custom_body = override if override else (None, None)
|
||||
custom_subject, custom_body = override or (None, None)
|
||||
|
||||
await asyncio.to_thread(
|
||||
email_service.send_password_reset_email,
|
||||
@@ -1091,9 +1379,17 @@ async def forgot_password(
|
||||
@router.post('/password/reset')
|
||||
async def reset_password(
|
||||
request: PasswordResetRequest,
|
||||
raw_request: Request,
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Reset password with token."""
|
||||
client_ip = get_client_ip(raw_request)
|
||||
if await RateLimitCache.is_ip_rate_limited(client_ip, 'password_reset', limit=5, window=60, fail_closed=True):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail='Too many requests',
|
||||
headers={'Retry-After': '60'},
|
||||
)
|
||||
result = await db.execute(select(User).where(User.password_reset_token == request.token))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
@@ -1232,7 +1528,7 @@ async def request_email_change(
|
||||
},
|
||||
db=db,
|
||||
)
|
||||
custom_subject, custom_body = override if override else (None, None)
|
||||
custom_subject, custom_body = override or (None, None)
|
||||
|
||||
try:
|
||||
await asyncio.to_thread(
|
||||
@@ -1287,7 +1583,7 @@ async def request_email_change(
|
||||
},
|
||||
db=db,
|
||||
)
|
||||
custom_subject, custom_body = override if override else (None, None)
|
||||
custom_subject, custom_body = override or (None, None)
|
||||
|
||||
await asyncio.to_thread(
|
||||
email_service.send_email_change_code,
|
||||
|
||||
+183
-13
@@ -6,11 +6,18 @@ 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.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
|
||||
@@ -32,6 +39,8 @@ from ..schemas.balance import (
|
||||
PaymentMethodResponse,
|
||||
PendingPaymentListResponse,
|
||||
PendingPaymentResponse,
|
||||
SavedCardResponse,
|
||||
SavedCardsListResponse,
|
||||
StarsInvoiceRequest,
|
||||
StarsInvoiceResponse,
|
||||
TopUpRequest,
|
||||
@@ -99,8 +108,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(
|
||||
@@ -195,7 +204,7 @@ async def get_payment_methods(
|
||||
'description': description,
|
||||
}
|
||||
)
|
||||
options = formatted_options if formatted_options else None
|
||||
options = formatted_options or None
|
||||
|
||||
methods.append(
|
||||
PaymentMethodResponse(
|
||||
@@ -241,13 +250,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(
|
||||
@@ -256,7 +268,7 @@ 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:
|
||||
@@ -268,7 +280,7 @@ async def create_stars_invoice(
|
||||
api_url,
|
||||
json={
|
||||
'title': 'Пополнение баланса VPN',
|
||||
'description': f'Пополнение баланса на {amount_rubles:.2f} ₽ ({stars_amount} ⭐)',
|
||||
'description': f'Пополнение баланса на {normalized_kopeks / 100:.2f} ₽ ({stars_amount} ⭐)',
|
||||
'payload': payload,
|
||||
'provider_token': '', # Empty for Stars
|
||||
'currency': 'XTR',
|
||||
@@ -296,7 +308,7 @@ async def create_stars_invoice(
|
||||
return StarsInvoiceResponse(
|
||||
invoice_url=invoice_url,
|
||||
stars_amount=stars_amount,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
amount_kopeks=normalized_kopeks,
|
||||
)
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
@@ -314,6 +326,12 @@ async def create_topup(
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Create payment for balance top-up."""
|
||||
if getattr(user, 'restriction_topup', False):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Balance top-up is restricted for this account',
|
||||
)
|
||||
|
||||
# Validate payment method
|
||||
methods = await get_payment_methods(user=user, db=db)
|
||||
method = next((m for m in methods if m.id == request.payment_method), None)
|
||||
@@ -340,6 +358,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,6 +385,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(
|
||||
@@ -372,11 +394,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,
|
||||
@@ -481,6 +504,8 @@ async def create_topup(
|
||||
),
|
||||
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'):
|
||||
@@ -506,6 +531,8 @@ async def create_topup(
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
description=settings.get_balance_payment_description(request.amount_kopeks),
|
||||
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'):
|
||||
@@ -553,7 +580,6 @@ 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(
|
||||
@@ -562,7 +588,6 @@ async def create_topup(
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
description=settings.get_balance_payment_description(request.amount_kopeks),
|
||||
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
|
||||
payment_method=provider_method,
|
||||
)
|
||||
|
||||
if result:
|
||||
@@ -603,6 +628,8 @@ async def create_topup(
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
description=settings.get_balance_payment_description(request.amount_kopeks),
|
||||
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'):
|
||||
@@ -629,6 +656,8 @@ async def create_topup(
|
||||
description=settings.get_balance_payment_description(request.amount_kopeks),
|
||||
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'):
|
||||
@@ -865,7 +894,7 @@ 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:
|
||||
@@ -956,6 +985,91 @@ 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 (
|
||||
CloudPaymentsPayment,
|
||||
CryptoBotPayment,
|
||||
FreekassaPayment,
|
||||
HeleketPayment,
|
||||
KassaAiPayment,
|
||||
MulenPayPayment,
|
||||
Pal24Payment,
|
||||
PlategaPayment,
|
||||
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,
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -1035,8 +1149,12 @@ async def check_payment_status(
|
||||
old_is_paid = record.is_paid
|
||||
|
||||
# Run manual check
|
||||
payment_service = PaymentService()
|
||||
updated = await run_manual_check(db, payment_method, payment_id, payment_service)
|
||||
bot = Bot(token=settings.BOT_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
|
||||
try:
|
||||
payment_service = PaymentService(bot=bot)
|
||||
updated = await run_manual_check(db, payment_method, payment_id, payment_service)
|
||||
finally:
|
||||
await bot.session.close()
|
||||
|
||||
if not updated:
|
||||
return ManualCheckResponse(
|
||||
@@ -1062,3 +1180,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'}
|
||||
|
||||
+272
-15
@@ -1,17 +1,20 @@
|
||||
"""Branding routes for cabinet - logo, project name, and theme colors management."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
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
|
||||
@@ -37,6 +40,24 @@ 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 = {
|
||||
'enabled': True,
|
||||
'type': 'aurora',
|
||||
'settings': {},
|
||||
'opacity': 1.0,
|
||||
'blur': 0,
|
||||
'reducedOnMobile': True,
|
||||
}
|
||||
|
||||
# Allowed image types
|
||||
ALLOWED_CONTENT_TYPES = {'image/png', 'image/jpeg', 'image/jpg', 'image/webp', 'image/svg+xml'}
|
||||
@@ -121,6 +142,92 @@ class AnimationEnabledUpdate(BaseModel):
|
||||
enabled: bool
|
||||
|
||||
|
||||
ALLOWED_BG_TYPES = (
|
||||
'aurora',
|
||||
'sparkles',
|
||||
'vortex',
|
||||
'shooting-stars',
|
||||
'background-beams',
|
||||
'background-beams-collision',
|
||||
'gradient-animation',
|
||||
'wavy',
|
||||
'background-lines',
|
||||
'boxes',
|
||||
'meteors',
|
||||
'grid',
|
||||
'dots',
|
||||
'spotlight',
|
||||
'ripple',
|
||||
'none',
|
||||
)
|
||||
|
||||
MAX_SETTINGS_KEYS = 20
|
||||
MAX_SETTINGS_VALUE_LEN = 200
|
||||
|
||||
|
||||
def _validate_settings(v: dict) -> dict:
|
||||
"""Validate settings dict: flat structure, bounded size, no nested objects."""
|
||||
if len(v) > MAX_SETTINGS_KEYS:
|
||||
raise ValueError(f'Settings must have at most {MAX_SETTINGS_KEYS} keys')
|
||||
for key, val in v.items():
|
||||
if not isinstance(key, str) or len(key) > 50:
|
||||
raise ValueError('Setting keys must be strings under 50 characters')
|
||||
if isinstance(val, dict | list):
|
||||
raise ValueError('Nested objects/arrays not allowed in settings')
|
||||
if isinstance(val, str) and len(val) > MAX_SETTINGS_VALUE_LEN:
|
||||
raise ValueError(f'String setting values must be under {MAX_SETTINGS_VALUE_LEN} characters')
|
||||
return v
|
||||
|
||||
|
||||
class AnimationConfigResponse(BaseModel):
|
||||
"""Full animation config."""
|
||||
|
||||
enabled: bool = True
|
||||
type: str = 'aurora'
|
||||
settings: dict = Field(default_factory=dict)
|
||||
opacity: float = Field(default=1.0, ge=0.0, le=1.0)
|
||||
blur: float = Field(default=0, ge=0, le=100)
|
||||
reducedOnMobile: bool = True
|
||||
|
||||
|
||||
class AnimationConfigUpdate(BaseModel):
|
||||
"""Request to update animation config (partial update)."""
|
||||
|
||||
enabled: bool | None = None
|
||||
type: (
|
||||
Literal[
|
||||
'aurora',
|
||||
'sparkles',
|
||||
'vortex',
|
||||
'shooting-stars',
|
||||
'background-beams',
|
||||
'background-beams-collision',
|
||||
'gradient-animation',
|
||||
'wavy',
|
||||
'background-lines',
|
||||
'boxes',
|
||||
'meteors',
|
||||
'grid',
|
||||
'dots',
|
||||
'spotlight',
|
||||
'ripple',
|
||||
'none',
|
||||
]
|
||||
| None
|
||||
) = None
|
||||
settings: dict | None = None
|
||||
opacity: float | None = Field(default=None, ge=0.0, le=1.0)
|
||||
blur: float | None = Field(default=None, ge=0, le=100)
|
||||
reducedOnMobile: bool | None = None
|
||||
|
||||
@field_validator('settings')
|
||||
@classmethod
|
||||
def validate_settings(cls, v: dict | None) -> dict | None:
|
||||
if v is None:
|
||||
return v
|
||||
return _validate_settings(v)
|
||||
|
||||
|
||||
class FullscreenEnabledResponse(BaseModel):
|
||||
"""Fullscreen enabled setting."""
|
||||
|
||||
@@ -145,6 +252,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."""
|
||||
|
||||
@@ -157,6 +278,18 @@ 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 AnalyticsCountersResponse(BaseModel):
|
||||
"""Analytics counter settings."""
|
||||
|
||||
@@ -198,13 +331,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))
|
||||
@@ -276,7 +402,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
|
||||
@@ -345,7 +471,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 = {
|
||||
@@ -358,12 +484,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')
|
||||
@@ -392,8 +518,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')
|
||||
@@ -598,6 +724,69 @@ async def update_animation_enabled(
|
||||
return AnimationEnabledResponse(enabled=payload.enabled)
|
||||
|
||||
|
||||
# ============ Animation Config Routes (new JSON-based) ============
|
||||
|
||||
|
||||
@router.get('/animation-config', response_model=AnimationConfigResponse)
|
||||
async def get_animation_config(
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get full animation config. Public endpoint."""
|
||||
config_value = await get_setting_value(db, ANIMATION_CONFIG_KEY)
|
||||
|
||||
if config_value is not None:
|
||||
try:
|
||||
config = json.loads(config_value)
|
||||
return AnimationConfigResponse(**config)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
# Auto-migrate from old ANIMATION_ENABLED_KEY
|
||||
old_value = await get_setting_value(db, ANIMATION_ENABLED_KEY)
|
||||
if old_value is not None:
|
||||
config = {**DEFAULT_ANIMATION_CONFIG, 'enabled': old_value.lower() == 'true'}
|
||||
await set_setting_value(db, ANIMATION_CONFIG_KEY, json.dumps(config))
|
||||
return AnimationConfigResponse(**config)
|
||||
|
||||
return AnimationConfigResponse(**DEFAULT_ANIMATION_CONFIG)
|
||||
|
||||
|
||||
@router.patch('/animation-config', response_model=AnimationConfigResponse)
|
||||
async def update_animation_config(
|
||||
payload: AnimationConfigUpdate,
|
||||
admin: User = Depends(require_permission('settings:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update animation config (partial update). Admin only."""
|
||||
# Get current config
|
||||
config_value = await get_setting_value(db, ANIMATION_CONFIG_KEY)
|
||||
if config_value:
|
||||
try:
|
||||
current = json.loads(config_value)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
current = dict(DEFAULT_ANIMATION_CONFIG)
|
||||
else:
|
||||
current = dict(DEFAULT_ANIMATION_CONFIG)
|
||||
|
||||
# Merge only provided fields
|
||||
update_data = payload.model_dump(exclude_none=True)
|
||||
current.update(update_data)
|
||||
|
||||
await set_setting_value(db, ANIMATION_CONFIG_KEY, json.dumps(current))
|
||||
|
||||
# Also sync old key for backwards compat
|
||||
await set_setting_value(db, ANIMATION_ENABLED_KEY, str(current.get('enabled', True)).lower())
|
||||
|
||||
logger.info(
|
||||
'Admin updated animation config',
|
||||
telegram_id=admin.telegram_id,
|
||||
type=current.get('type'),
|
||||
enabled=current.get('enabled'),
|
||||
)
|
||||
|
||||
return AnimationConfigResponse(**current)
|
||||
|
||||
|
||||
# ============ Fullscreen Routes ============
|
||||
|
||||
|
||||
@@ -669,6 +858,47 @@ async def update_email_auth_enabled(
|
||||
return EmailAuthEnabledResponse(enabled=payload.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 ============
|
||||
|
||||
|
||||
@@ -767,3 +997,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)
|
||||
|
||||
@@ -86,6 +86,7 @@ def _user_allowed(subscription) -> bool:
|
||||
return subscription.status in {
|
||||
SubscriptionStatus.ACTIVE.value,
|
||||
SubscriptionStatus.TRIAL.value,
|
||||
SubscriptionStatus.LIMITED.value,
|
||||
}
|
||||
|
||||
|
||||
@@ -121,7 +122,10 @@ async def _award_prize(db: AsyncSession, user_id: int, prize_type: str, prize_va
|
||||
if not user:
|
||||
return 'Error: user not found'
|
||||
|
||||
user.balance += amount
|
||||
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)
|
||||
|
||||
|
||||
@@ -0,0 +1,816 @@
|
||||
"""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,
|
||||
UserPromoGroup,
|
||||
)
|
||||
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
|
||||
promo_group_discount = 0
|
||||
if promo_group:
|
||||
promo_group_discount = promo_group.get_discount_percent('period', days)
|
||||
if promo_group_discount > 0:
|
||||
price = int(price * (100 - promo_group_discount) / 100)
|
||||
|
||||
# Apply active promo offer discount (stacks on top)
|
||||
if promo_offer_discount_percent > 0:
|
||||
price = price - price * promo_offer_discount_percent // 100
|
||||
|
||||
# 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',
|
||||
)
|
||||
|
||||
price_kopeks = tariff.get_price_for_period(body.period_days)
|
||||
if price_kopeks is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Price is not configured for this period',
|
||||
)
|
||||
|
||||
# Lock user row to prevent concurrent promo offer double-spend
|
||||
locked_result = await db.execute(
|
||||
select(User)
|
||||
.options(
|
||||
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
|
||||
selectinload(User.promo_group),
|
||||
)
|
||||
.where(User.id == user.id)
|
||||
.with_for_update()
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
user = locked_result.scalar_one()
|
||||
|
||||
# Apply promo group discount
|
||||
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)
|
||||
|
||||
if promo_group:
|
||||
discount_percent = promo_group.get_discount_percent('period', body.period_days)
|
||||
if discount_percent > 0:
|
||||
price_kopeks = int(price_kopeks * (100 - discount_percent) / 100)
|
||||
|
||||
# Apply active promo offer discount (stacks)
|
||||
promo_offer_discount_percent = get_user_active_promo_discount_percent(user)
|
||||
if promo_offer_discount_percent > 0:
|
||||
price_kopeks = price_kopeks - price_kopeks * promo_offer_discount_percent // 100
|
||||
|
||||
# Ensure minimum price of 1 kopek after all discounts
|
||||
price_kopeks = max(1, price_kopeks)
|
||||
|
||||
# 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 aiogram import Bot
|
||||
|
||||
async with Bot(token=settings.BOT_TOKEN) 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 aiogram import Bot
|
||||
|
||||
bot = Bot(token=settings.BOT_TOKEN)
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
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 promo_offer_discount_percent > 0 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
|
||||
if 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=promo_offer_discount_percent > 0,
|
||||
)
|
||||
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,
|
||||
)
|
||||
|
||||
# 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-'):
|
||||
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,
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,694 @@
|
||||
"""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
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
_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)
|
||||
|
||||
@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)
|
||||
price = max(1, price - (price * effective_discount // 100))
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
@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=5, 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,
|
||||
commit=False,
|
||||
)
|
||||
|
||||
# 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)
|
||||
|
||||
return PurchaseResponse(
|
||||
purchase_token=purchase.token,
|
||||
payment_url=payment_url,
|
||||
)
|
||||
+46
-20
@@ -24,6 +24,7 @@ from ..auth.oauth_providers import (
|
||||
validate_oauth_state,
|
||||
)
|
||||
from ..dependencies import get_cabinet_db
|
||||
from ..routes.account_linking import OAuthProviderName
|
||||
from ..schemas.auth import AuthResponse
|
||||
from .auth import _create_auth_response, _process_campaign_bonus, _store_refresh_token
|
||||
|
||||
@@ -75,12 +76,15 @@ class OAuthAuthorizeResponse(BaseModel):
|
||||
|
||||
|
||||
class OAuthCallbackRequest(BaseModel):
|
||||
code: str = Field(..., description='Authorization code from provider')
|
||||
state: str = Field(..., description='CSRF state token')
|
||||
code: str = Field(..., min_length=1, max_length=2048, description='Authorization code from provider')
|
||||
state: str = Field(..., min_length=1, max_length=128, description='CSRF state token')
|
||||
device_id: str | None = Field(None, max_length=256, description='Device ID from VK ID callback')
|
||||
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 ---
|
||||
@@ -99,48 +103,68 @@ async def get_oauth_providers():
|
||||
|
||||
|
||||
@router.get('/{provider}/authorize', response_model=OAuthAuthorizeResponse)
|
||||
async def get_oauth_authorize_url(provider: str):
|
||||
async def get_oauth_authorize_url(provider: OAuthProviderName):
|
||||
"""Get authorization URL for an OAuth provider."""
|
||||
oauth_provider = get_provider(provider)
|
||||
if not oauth_provider:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'OAuth provider "{provider}" is not enabled',
|
||||
detail='Requested OAuth provider is not available',
|
||||
)
|
||||
|
||||
state = await generate_oauth_state(provider)
|
||||
authorize_url = oauth_provider.get_authorization_url(state)
|
||||
# Generate extra state data (e.g., PKCE code_verifier for VK)
|
||||
auth_extra = oauth_provider.prepare_auth_state()
|
||||
state = await generate_oauth_state(provider, extra_data=auth_extra or None)
|
||||
# Only pass URL-safe params (prefixed with _) to authorize URL; exclude secrets like code_verifier
|
||||
url_params = {k: v for k, v in auth_extra.items() if k.startswith('_')} if auth_extra else {}
|
||||
authorize_url = oauth_provider.get_authorization_url(state, **url_params)
|
||||
|
||||
return OAuthAuthorizeResponse(authorize_url=authorize_url, state=state)
|
||||
|
||||
|
||||
@router.post('/{provider}/callback', response_model=AuthResponse)
|
||||
async def oauth_callback(
|
||||
provider: str,
|
||||
provider: OAuthProviderName,
|
||||
request: OAuthCallbackRequest,
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Handle OAuth callback: exchange code, find/create user, return JWT."""
|
||||
# 1. Validate CSRF state
|
||||
if not await validate_oauth_state(request.state, provider):
|
||||
# 1. Validate CSRF state and retrieve stored data (e.g., PKCE code_verifier)
|
||||
state_data = await validate_oauth_state(request.state, provider)
|
||||
if not state_data:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Invalid or expired OAuth state',
|
||||
)
|
||||
|
||||
# 1b. Reject linking-flow state tokens (must use link_provider_callback instead)
|
||||
if state_data.get('linking') == 'true':
|
||||
logger.warning('Linking-flow state token used in login callback', provider=provider)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='OAuth state was initiated for account linking, not login',
|
||||
)
|
||||
|
||||
# 2. Get provider instance
|
||||
oauth_provider = get_provider(provider)
|
||||
if not oauth_provider:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'OAuth provider "{provider}" is not enabled',
|
||||
detail='Requested OAuth provider is not available',
|
||||
)
|
||||
|
||||
# 3. Exchange code for tokens
|
||||
# 3. Exchange code for tokens (pass PKCE code_verifier and device_id if present)
|
||||
exchange_kwargs: dict[str, str] = {'state': request.state}
|
||||
code_verifier = state_data.get('code_verifier')
|
||||
if code_verifier:
|
||||
exchange_kwargs['code_verifier'] = code_verifier
|
||||
if request.device_id:
|
||||
exchange_kwargs['device_id'] = request.device_id
|
||||
|
||||
try:
|
||||
token_data = await oauth_provider.exchange_code(request.code)
|
||||
token_data = await oauth_provider.exchange_code(request.code, **exchange_kwargs)
|
||||
except Exception as exc:
|
||||
logger.error('OAuth code exchange failed for', provider=provider, exc=exc)
|
||||
logger.error('OAuth code exchange failed', provider=provider, exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Failed to exchange authorization code',
|
||||
@@ -150,7 +174,7 @@ async def oauth_callback(
|
||||
try:
|
||||
user_info: OAuthUserInfo = await oauth_provider.get_user_info(token_data)
|
||||
except Exception as exc:
|
||||
logger.error('OAuth user info fetch failed for', provider=provider, exc=exc)
|
||||
logger.error('OAuth user info fetch failed', provider=provider, exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Failed to fetch user information from provider',
|
||||
@@ -159,7 +183,7 @@ async def oauth_callback(
|
||||
# 5. Find user by provider ID
|
||||
user = await get_user_by_oauth_provider(db, provider, user_info.provider_id)
|
||||
if user:
|
||||
logger.info('OAuth login via for existing user', provider=provider, user_id=user.id)
|
||||
logger.info('OAuth login for existing user', provider=provider, user_id=user.id)
|
||||
return await _finalize_oauth_login(db, user, provider, request.campaign_slug, request.referral_code)
|
||||
|
||||
# 6. Find user by email (if verified) and link provider
|
||||
@@ -167,7 +191,7 @@ async def oauth_callback(
|
||||
user = await get_user_by_email(db, user_info.email)
|
||||
if user:
|
||||
await set_user_oauth_provider_id(db, user, provider, user_info.provider_id)
|
||||
logger.info('OAuth login via linked to existing email user', provider=provider, user_id=user.id)
|
||||
logger.info('OAuth provider linked to existing email user', provider=provider, user_id=user.id)
|
||||
return await _finalize_oauth_login(db, user, provider, request.campaign_slug, request.referral_code)
|
||||
|
||||
# 7. Resolve referral code for new user
|
||||
@@ -190,8 +214,10 @@ async def oauth_callback(
|
||||
)
|
||||
else:
|
||||
referrer_id = referrer.id
|
||||
except Exception as e:
|
||||
logger.warning('Failed to resolve referral code during OAuth', referral_code=request.referral_code, error=e)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
'Failed to resolve referral code during OAuth', referral_code=request.referral_code, exc_info=True
|
||||
)
|
||||
|
||||
# 8. Create new user
|
||||
user = await create_user_by_oauth(
|
||||
@@ -205,5 +231,5 @@ async def oauth_callback(
|
||||
username=user_info.username,
|
||||
referred_by_id=referrer_id,
|
||||
)
|
||||
logger.info('OAuth new user created via with id', provider=provider, user_id=user.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)
|
||||
|
||||
@@ -5,16 +5,24 @@ from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.cabinet.utils.links import get_campaign_deep_link, get_campaign_web_link
|
||||
from app.config import settings
|
||||
from app.database.models import AdvertisingCampaign, User
|
||||
from app.services.partner_application_service import partner_application_service
|
||||
from app.services.partner_stats_service import PartnerStatsService
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
from ..schemas.partners import (
|
||||
CampaignReferralItem,
|
||||
DailyStatItem,
|
||||
PartnerApplicationInfo,
|
||||
PartnerApplicationRequest,
|
||||
PartnerCampaignDetailedStats,
|
||||
PartnerCampaignInfo,
|
||||
PartnerStatusResponse,
|
||||
PeriodChange,
|
||||
PeriodComparison,
|
||||
PeriodStats,
|
||||
)
|
||||
|
||||
|
||||
@@ -23,22 +31,6 @@ logger = structlog.get_logger(__name__)
|
||||
router = APIRouter(prefix='/referral/partner', tags=['Cabinet Partner'])
|
||||
|
||||
|
||||
def _get_campaign_deep_link(start_parameter: str) -> str | None:
|
||||
"""Generate Telegram deep link for campaign."""
|
||||
bot_username = settings.get_bot_username()
|
||||
if bot_username:
|
||||
return f'https://t.me/{bot_username}?start={start_parameter}'
|
||||
return None
|
||||
|
||||
|
||||
def _get_campaign_web_link(start_parameter: str) -> str | None:
|
||||
"""Generate web link for campaign."""
|
||||
base_url = (settings.MINIAPP_CUSTOM_URL or '').rstrip('/')
|
||||
if base_url:
|
||||
return f'{base_url}/?campaign={start_parameter}'
|
||||
return None
|
||||
|
||||
|
||||
@router.get('/status', response_model=PartnerStatusResponse)
|
||||
async def get_partner_status(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
@@ -57,6 +49,7 @@ async def get_partner_status(
|
||||
telegram_channel=latest_app.telegram_channel,
|
||||
description=latest_app.description,
|
||||
expected_monthly_referrals=latest_app.expected_monthly_referrals,
|
||||
desired_commission_percent=latest_app.desired_commission_percent,
|
||||
admin_comment=latest_app.admin_comment,
|
||||
approved_commission_percent=latest_app.approved_commission_percent,
|
||||
created_at=latest_app.created_at,
|
||||
@@ -76,7 +69,14 @@ async def get_partner_status(
|
||||
AdvertisingCampaign.is_active.is_(True),
|
||||
)
|
||||
)
|
||||
for c in result.scalars().all():
|
||||
campaign_models = result.scalars().all()
|
||||
|
||||
# Fetch per-campaign stats in one batch
|
||||
campaign_ids = [c.id for c in campaign_models]
|
||||
campaign_stats = await PartnerStatsService.get_per_campaign_stats(db, user.id, campaign_ids)
|
||||
|
||||
for c in campaign_models:
|
||||
stats = campaign_stats.get(c.id, {})
|
||||
campaigns.append(
|
||||
PartnerCampaignInfo(
|
||||
id=c.id,
|
||||
@@ -86,8 +86,11 @@ async def get_partner_status(
|
||||
balance_bonus_kopeks=c.balance_bonus_kopeks or 0,
|
||||
subscription_duration_days=c.subscription_duration_days,
|
||||
subscription_traffic_gb=c.subscription_traffic_gb,
|
||||
deep_link=_get_campaign_deep_link(c.start_parameter),
|
||||
web_link=_get_campaign_web_link(c.start_parameter),
|
||||
deep_link=get_campaign_deep_link(c.start_parameter),
|
||||
web_link=get_campaign_web_link(c.start_parameter),
|
||||
registrations_count=stats.get('registrations_count', 0),
|
||||
referrals_count=stats.get('referrals_count', 0),
|
||||
earnings_kopeks=stats.get('earnings_kopeks', 0),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -99,6 +102,56 @@ async def get_partner_status(
|
||||
)
|
||||
|
||||
|
||||
@router.get('/campaigns/{campaign_id}/stats', response_model=PartnerCampaignDetailedStats)
|
||||
async def get_campaign_stats(
|
||||
campaign_id: int,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get detailed stats for a single campaign belonging to the current partner."""
|
||||
if not user.is_partner:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Partner status required',
|
||||
)
|
||||
|
||||
# Verify campaign belongs to this partner
|
||||
campaign_result = await db.execute(
|
||||
select(AdvertisingCampaign).where(
|
||||
AdvertisingCampaign.id == campaign_id,
|
||||
AdvertisingCampaign.partner_user_id == user.id,
|
||||
)
|
||||
)
|
||||
campaign = campaign_result.scalar_one_or_none()
|
||||
if not campaign:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Campaign not found or not assigned to you',
|
||||
)
|
||||
|
||||
raw = await PartnerStatsService.get_campaign_detailed_stats(db, user.id, campaign_id)
|
||||
|
||||
return PartnerCampaignDetailedStats(
|
||||
campaign_id=raw['campaign_id'],
|
||||
campaign_name=campaign.name,
|
||||
registrations_count=raw['registrations_count'],
|
||||
referrals_count=raw['referrals_count'],
|
||||
earnings_kopeks=raw['earnings_kopeks'],
|
||||
conversion_rate=raw['conversion_rate'],
|
||||
earnings_today=raw['earnings_today'],
|
||||
earnings_week=raw['earnings_week'],
|
||||
earnings_month=raw['earnings_month'],
|
||||
daily_stats=[DailyStatItem(**d) for d in raw['daily_stats']],
|
||||
period_comparison=PeriodComparison(
|
||||
current=PeriodStats(**raw['period_comparison']['current']),
|
||||
previous=PeriodStats(**raw['period_comparison']['previous']),
|
||||
referrals_change=PeriodChange(**raw['period_comparison']['referrals_change']),
|
||||
earnings_change=PeriodChange(**raw['period_comparison']['earnings_change']),
|
||||
),
|
||||
top_referrals=[CampaignReferralItem(**r) for r in raw['top_referrals']],
|
||||
)
|
||||
|
||||
|
||||
@router.post('/apply', response_model=PartnerApplicationInfo)
|
||||
async def apply_for_partner(
|
||||
request: PartnerApplicationRequest,
|
||||
@@ -114,6 +167,7 @@ async def apply_for_partner(
|
||||
telegram_channel=request.telegram_channel,
|
||||
description=request.description,
|
||||
expected_monthly_referrals=request.expected_monthly_referrals,
|
||||
desired_commission_percent=request.desired_commission_percent,
|
||||
)
|
||||
|
||||
if not application:
|
||||
@@ -140,6 +194,7 @@ async def apply_for_partner(
|
||||
'website_url': request.website_url,
|
||||
'description': request.description,
|
||||
'expected_monthly_referrals': request.expected_monthly_referrals,
|
||||
'desired_commission_percent': request.desired_commission_percent,
|
||||
},
|
||||
)
|
||||
finally:
|
||||
@@ -155,6 +210,7 @@ async def apply_for_partner(
|
||||
telegram_channel=application.telegram_channel,
|
||||
description=application.description,
|
||||
expected_monthly_referrals=application.expected_monthly_referrals,
|
||||
desired_commission_percent=application.desired_commission_percent,
|
||||
admin_comment=application.admin_comment,
|
||||
approved_commission_percent=application.approved_commission_percent,
|
||||
created_at=application.created_at,
|
||||
|
||||
+16
-11
@@ -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
|
||||
|
||||
@@ -71,7 +71,9 @@ async def activate_promocode(
|
||||
'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',
|
||||
'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',
|
||||
'server_error': 'Server error occurred',
|
||||
}
|
||||
|
||||
@@ -9,7 +9,15 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import AdvertisingCampaign, ReferralEarning, User
|
||||
from app.database.models import (
|
||||
AdvertisingCampaign,
|
||||
ReferralEarning,
|
||||
Subscription,
|
||||
SubscriptionStatus,
|
||||
User,
|
||||
WithdrawalRequest,
|
||||
WithdrawalRequestStatus,
|
||||
)
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
from ..schemas.referral import (
|
||||
@@ -38,12 +46,15 @@ async def get_referral_info(
|
||||
total_result = await db.execute(total_query)
|
||||
total_referrals = total_result.scalar() or 0
|
||||
|
||||
# Get active referrals (with subscription)
|
||||
# Get active referrals (with active subscription right now)
|
||||
active_query = (
|
||||
select(func.count())
|
||||
.select_from(User)
|
||||
.where(User.referred_by_id == user.id)
|
||||
.where(User.has_had_paid_subscription == True)
|
||||
select(func.count(func.distinct(User.id)))
|
||||
.join(Subscription, User.id == Subscription.user_id)
|
||||
.where(
|
||||
User.referred_by_id == user.id,
|
||||
Subscription.status == SubscriptionStatus.ACTIVE.value,
|
||||
Subscription.end_date > func.now(),
|
||||
)
|
||||
)
|
||||
active_result = await db.execute(active_query)
|
||||
active_referrals = active_result.scalar() or 0
|
||||
@@ -60,18 +71,48 @@ async def get_referral_info(
|
||||
if commission_percent is None:
|
||||
commission_percent = settings.REFERRAL_COMMISSION_PERCENT
|
||||
|
||||
# Build referral link
|
||||
bot_username = settings.get_bot_username() or 'bot'
|
||||
referral_link = f'https://t.me/{bot_username}?start={user.referral_code}'
|
||||
# Get withdrawn amount (approved + completed withdrawal requests)
|
||||
withdrawn_query = select(func.coalesce(func.sum(WithdrawalRequest.amount_kopeks), 0)).where(
|
||||
WithdrawalRequest.user_id == user.id,
|
||||
WithdrawalRequest.status.in_([WithdrawalRequestStatus.APPROVED.value, WithdrawalRequestStatus.COMPLETED.value]),
|
||||
)
|
||||
withdrawn_result = await db.execute(withdrawn_query)
|
||||
withdrawn = withdrawn_result.scalar() or 0
|
||||
|
||||
# Get pending withdrawal amount
|
||||
pending_query = select(func.coalesce(func.sum(WithdrawalRequest.amount_kopeks), 0)).where(
|
||||
WithdrawalRequest.user_id == user.id,
|
||||
WithdrawalRequest.status == WithdrawalRequestStatus.PENDING.value,
|
||||
)
|
||||
pending_result = await db.execute(pending_query)
|
||||
pending = pending_result.scalar() or 0
|
||||
|
||||
# Доступный баланс: мин(кошелёк, заработано - выведено - в ожидании)
|
||||
referral_entitlement = max(0, total_earnings - withdrawn - pending)
|
||||
available_balance = min(user.balance_kopeks, referral_entitlement)
|
||||
|
||||
# Build referral links
|
||||
referral_link = settings.get_referral_link(user.referral_code) if user.referral_code else ''
|
||||
bot_username = settings.get_bot_username()
|
||||
bot_referral_link = ''
|
||||
if user.referral_code and bot_username:
|
||||
from urllib.parse import quote
|
||||
|
||||
safe_code = quote(user.referral_code, safe='')
|
||||
bot_referral_link = f'https://t.me/{bot_username}?start={safe_code}'
|
||||
|
||||
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,
|
||||
total_earnings_rubles=total_earnings / 100,
|
||||
commission_percent=commission_percent,
|
||||
available_balance_kopeks=available_balance,
|
||||
available_balance_rubles=available_balance / 100,
|
||||
withdrawn_kopeks=withdrawn,
|
||||
)
|
||||
|
||||
|
||||
@@ -209,5 +250,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,
|
||||
)
|
||||
|
||||
+562
-283
File diff suppressed because it is too large
Load Diff
@@ -50,6 +50,12 @@ async def get_wheel_config(
|
||||
# Проверяем доступность
|
||||
availability = await wheel_service.check_availability(db, user)
|
||||
|
||||
# Проверяем наличие подписки
|
||||
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 = [
|
||||
WheelPrizeDisplay(
|
||||
id=p.id,
|
||||
@@ -77,6 +83,7 @@ async def get_wheel_config(
|
||||
can_pay_days=availability.can_pay_days,
|
||||
user_balance_kopeks=availability.user_balance_kopeks,
|
||||
required_balance_kopeks=availability.required_balance_kopeks,
|
||||
has_subscription=has_subscription,
|
||||
)
|
||||
|
||||
|
||||
@@ -213,6 +220,16 @@ async def create_stars_invoice(
|
||||
detail='Оплата Stars не включена',
|
||||
)
|
||||
|
||||
# Проверяем наличие активной подписки
|
||||
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,
|
||||
detail='Для использования колеса необходима активная подписка',
|
||||
)
|
||||
|
||||
# Проверяем лимит спинов
|
||||
spins_today = await get_user_spins_today(db, user.id)
|
||||
if config.daily_spin_limit > 0 and spins_today >= config.daily_spin_limit:
|
||||
|
||||
+39
-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,10 @@ 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'
|
||||
)
|
||||
|
||||
|
||||
class CampaignBonusInfo(BaseModel):
|
||||
@@ -154,7 +178,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):
|
||||
|
||||
@@ -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, ConfigDict, Field
|
||||
|
||||
|
||||
CampaignBonusType = Literal['balance', 'subscription', 'none', 'tariff']
|
||||
@@ -31,8 +31,7 @@ class CampaignListItem(BaseModel):
|
||||
partner_name: str | None = None
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class CampaignListResponse(BaseModel):
|
||||
@@ -73,8 +72,7 @@ class CampaignDetailResponse(BaseModel):
|
||||
deep_link: str | None = None
|
||||
web_link: str | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class CampaignCreateRequest(BaseModel):
|
||||
@@ -179,8 +177,7 @@ class CampaignRegistrationItem(BaseModel):
|
||||
has_subscription: bool = False
|
||||
has_paid: bool = False
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class CampaignRegistrationsResponse(BaseModel):
|
||||
@@ -220,3 +217,61 @@ class ServerSquadInfo(BaseModel):
|
||||
squad_uuid: str
|
||||
display_name: str
|
||||
country_code: str | None = None
|
||||
|
||||
|
||||
# --- Admin campaign chart data schemas ---
|
||||
|
||||
|
||||
class AdminDailyStatItem(BaseModel):
|
||||
"""Daily stat item for admin campaign charts."""
|
||||
|
||||
date: str
|
||||
referrals_count: int = 0 # actually registrations, named for frontend compat
|
||||
earnings_kopeks: int = 0 # actually revenue, named for frontend compat
|
||||
|
||||
|
||||
class AdminPeriodStats(BaseModel):
|
||||
"""Period stats for admin campaign comparison."""
|
||||
|
||||
days: int
|
||||
referrals_count: int = 0
|
||||
earnings_kopeks: int = 0
|
||||
|
||||
|
||||
class AdminPeriodChange(BaseModel):
|
||||
"""Change metrics between periods."""
|
||||
|
||||
absolute: int = 0
|
||||
percent: float = 0.0
|
||||
trend: str = 'stable'
|
||||
|
||||
|
||||
class AdminPeriodComparison(BaseModel):
|
||||
"""Comparison of current vs previous period."""
|
||||
|
||||
current: AdminPeriodStats
|
||||
previous: AdminPeriodStats
|
||||
referrals_change: AdminPeriodChange
|
||||
earnings_change: AdminPeriodChange
|
||||
|
||||
|
||||
class AdminTopRegistrationItem(BaseModel):
|
||||
"""Top user by spending in a campaign."""
|
||||
|
||||
id: int
|
||||
full_name: str
|
||||
created_at: datetime
|
||||
has_paid: bool = False
|
||||
is_active: bool = False
|
||||
total_earnings_kopeks: int = 0 # actually total spending, named for frontend compat
|
||||
|
||||
|
||||
class AdminCampaignChartDataResponse(BaseModel):
|
||||
"""Chart data for admin campaign stats page."""
|
||||
|
||||
campaign_id: int
|
||||
total_deposits_kopeks: int = 0
|
||||
total_spending_kopeks: int = 0
|
||||
daily_stats: list[AdminDailyStatItem] = []
|
||||
period_comparison: AdminPeriodComparison
|
||||
top_registrations: list[AdminTopRegistrationItem] = []
|
||||
|
||||
@@ -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
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
# ==================== User-facing ====================
|
||||
@@ -16,6 +16,7 @@ class PartnerApplicationRequest(BaseModel):
|
||||
telegram_channel: str | None = Field(None, max_length=255)
|
||||
description: str | None = Field(None, max_length=2000)
|
||||
expected_monthly_referrals: int | None = Field(None, ge=0, le=2_000_000_000)
|
||||
desired_commission_percent: int | None = Field(None, ge=1, le=100)
|
||||
|
||||
|
||||
class PartnerApplicationInfo(BaseModel):
|
||||
@@ -28,13 +29,13 @@ class PartnerApplicationInfo(BaseModel):
|
||||
telegram_channel: str | None = None
|
||||
description: str | None = None
|
||||
expected_monthly_referrals: int | None = None
|
||||
desired_commission_percent: int | None = None
|
||||
admin_comment: str | None = None
|
||||
approved_commission_percent: int | None = None
|
||||
created_at: datetime
|
||||
processed_at: datetime | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class PartnerCampaignInfo(BaseModel):
|
||||
@@ -49,6 +50,10 @@ class PartnerCampaignInfo(BaseModel):
|
||||
subscription_traffic_gb: int | None = None
|
||||
deep_link: str | None = None
|
||||
web_link: str | None = None
|
||||
# Per-campaign statistics
|
||||
registrations_count: int = 0
|
||||
referrals_count: int = 0
|
||||
earnings_kopeks: int = 0
|
||||
|
||||
|
||||
class PartnerStatusResponse(BaseModel):
|
||||
@@ -60,6 +65,75 @@ class PartnerStatusResponse(BaseModel):
|
||||
campaigns: list[PartnerCampaignInfo] = []
|
||||
|
||||
|
||||
# ==================== Campaign detailed stats ====================
|
||||
|
||||
|
||||
class DailyStatItem(BaseModel):
|
||||
"""Single day of campaign stats."""
|
||||
|
||||
date: str
|
||||
referrals_count: int = 0
|
||||
earnings_kopeks: int = 0
|
||||
|
||||
|
||||
class PeriodStats(BaseModel):
|
||||
"""Stats for a single period."""
|
||||
|
||||
days: int
|
||||
referrals_count: int = 0
|
||||
earnings_kopeks: int = 0
|
||||
|
||||
|
||||
class PeriodChange(BaseModel):
|
||||
"""Change metrics between periods."""
|
||||
|
||||
absolute: int = 0
|
||||
percent: float = 0.0
|
||||
trend: str = 'stable'
|
||||
|
||||
|
||||
class PeriodComparison(BaseModel):
|
||||
"""Comparison between current and previous period."""
|
||||
|
||||
current: PeriodStats
|
||||
previous: PeriodStats
|
||||
referrals_change: PeriodChange
|
||||
earnings_change: PeriodChange
|
||||
|
||||
|
||||
class CampaignReferralItem(BaseModel):
|
||||
"""Referral user in campaign stats."""
|
||||
|
||||
id: int
|
||||
full_name: str
|
||||
created_at: datetime
|
||||
has_paid: bool = False
|
||||
is_active: bool = False
|
||||
total_earnings_kopeks: int = 0
|
||||
|
||||
|
||||
class PartnerCampaignDetailedStats(BaseModel):
|
||||
"""Detailed stats for a single campaign."""
|
||||
|
||||
campaign_id: int
|
||||
campaign_name: str
|
||||
# Summary
|
||||
registrations_count: int = 0
|
||||
referrals_count: int = 0
|
||||
earnings_kopeks: int = 0
|
||||
conversion_rate: float = 0.0
|
||||
# Period earnings
|
||||
earnings_today: int = 0
|
||||
earnings_week: int = 0
|
||||
earnings_month: int = 0
|
||||
# Daily chart (30 days)
|
||||
daily_stats: list[DailyStatItem] = []
|
||||
# Period comparison (this week vs last week)
|
||||
period_comparison: PeriodComparison
|
||||
# Top referrals
|
||||
top_referrals: list[CampaignReferralItem] = []
|
||||
|
||||
|
||||
# ==================== Admin-facing ====================
|
||||
|
||||
|
||||
@@ -76,6 +150,7 @@ class AdminPartnerApplicationItem(BaseModel):
|
||||
telegram_channel: str | None = None
|
||||
description: str | None = None
|
||||
expected_monthly_referrals: int | None = None
|
||||
desired_commission_percent: int | None = None
|
||||
status: str
|
||||
admin_comment: str | None = None
|
||||
approved_commission_percent: int | None = None
|
||||
@@ -132,6 +207,9 @@ class CampaignSummary(BaseModel):
|
||||
name: str
|
||||
start_parameter: str
|
||||
is_active: bool
|
||||
registrations_count: int = 0
|
||||
referrals_count: int = 0
|
||||
earnings_kopeks: int = 0
|
||||
|
||||
|
||||
class AdminPartnerDetailResponse(BaseModel):
|
||||
|
||||
@@ -10,11 +10,15 @@ class ReferralInfoResponse(BaseModel):
|
||||
|
||||
referral_code: str
|
||||
referral_link: str
|
||||
bot_referral_link: str = ''
|
||||
total_referrals: int
|
||||
active_referrals: int
|
||||
total_earnings_kopeks: int
|
||||
total_earnings_rubles: float
|
||||
commission_percent: int
|
||||
available_balance_kopeks: int = 0
|
||||
available_balance_rubles: float = 0
|
||||
withdrawn_kopeks: int = 0
|
||||
|
||||
|
||||
class ReferralItemResponse(BaseModel):
|
||||
@@ -77,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -54,6 +54,7 @@ class TariffListItem(BaseModel):
|
||||
is_daily: bool = False
|
||||
daily_price_kopeks: int = 0
|
||||
allow_traffic_topup: bool = True
|
||||
show_in_gift: bool = True
|
||||
traffic_limit_gb: int
|
||||
device_limit: int
|
||||
tier_level: int
|
||||
@@ -112,6 +113,10 @@ class TariffDetailResponse(BaseModel):
|
||||
daily_price_kopeks: int = 0
|
||||
# Режим сброса трафика
|
||||
traffic_reset_mode: str | None = None # DAY, WEEK, MONTH, NO_RESET, None = глобальная настройка
|
||||
# Внешний сквад RemnaWave
|
||||
external_squad_uuid: str | None = None
|
||||
# Показывать в подарках
|
||||
show_in_gift: bool = True
|
||||
created_at: datetime
|
||||
updated_at: datetime | None = None
|
||||
|
||||
@@ -119,6 +124,17 @@ class TariffDetailResponse(BaseModel):
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ExternalSquadInfoResponse(BaseModel):
|
||||
"""External squad info from RemnaWave."""
|
||||
|
||||
uuid: str
|
||||
name: str
|
||||
members_count: int
|
||||
|
||||
|
||||
UUID_PATTERN = r'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$'
|
||||
|
||||
|
||||
class TariffCreateRequest(BaseModel):
|
||||
"""Request to create a tariff."""
|
||||
|
||||
@@ -155,6 +171,10 @@ class TariffCreateRequest(BaseModel):
|
||||
daily_price_kopeks: int = Field(0, ge=0)
|
||||
# Режим сброса трафика
|
||||
traffic_reset_mode: str | None = None # DAY, WEEK, MONTH, NO_RESET, None = глобальная настройка
|
||||
# Внешний сквад RemnaWave
|
||||
external_squad_uuid: str | None = Field(None, pattern=UUID_PATTERN)
|
||||
# Показывать в подарках
|
||||
show_in_gift: bool = True
|
||||
|
||||
|
||||
class TariffUpdateRequest(BaseModel):
|
||||
@@ -192,6 +212,10 @@ class TariffUpdateRequest(BaseModel):
|
||||
daily_price_kopeks: int | None = Field(None, ge=0)
|
||||
# Режим сброса трафика
|
||||
traffic_reset_mode: str | None = None # DAY, WEEK, MONTH, NO_RESET, None = глобальная настройка
|
||||
# Внешний сквад RemnaWave
|
||||
external_squad_uuid: str | None = Field(None, pattern=UUID_PATTERN)
|
||||
# Показывать в подарках
|
||||
show_in_gift: bool | None = None
|
||||
|
||||
|
||||
class TariffSortOrderRequest(BaseModel):
|
||||
@@ -226,3 +250,15 @@ class TariffStatsResponse(BaseModel):
|
||||
trial_subscriptions: int
|
||||
revenue_kopeks: int
|
||||
revenue_rubles: float
|
||||
|
||||
|
||||
class SyncSquadsResponse(BaseModel):
|
||||
"""Response after syncing squads for tariff subscriptions."""
|
||||
|
||||
tariff_id: int
|
||||
tariff_name: str
|
||||
total_subscriptions: int
|
||||
updated_count: int
|
||||
failed_count: int
|
||||
skipped_count: int
|
||||
errors: list[str] = Field(default_factory=list)
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
"""Schemas for Admin Users management in cabinet."""
|
||||
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from enum import StrEnum
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class UserStatusEnum(str, Enum):
|
||||
class UserStatusEnum(StrEnum):
|
||||
"""User status enum."""
|
||||
|
||||
ACTIVE = 'active'
|
||||
@@ -15,17 +15,18 @@ class UserStatusEnum(str, Enum):
|
||||
DELETED = 'deleted'
|
||||
|
||||
|
||||
class SubscriptionStatusEnum(str, Enum):
|
||||
class SubscriptionStatusEnum(StrEnum):
|
||||
"""Subscription status enum."""
|
||||
|
||||
TRIAL = 'trial'
|
||||
ACTIVE = 'active'
|
||||
EXPIRED = 'expired'
|
||||
DISABLED = 'disabled'
|
||||
LIMITED = 'limited'
|
||||
PENDING = 'pending'
|
||||
|
||||
|
||||
class SortByEnum(str, Enum):
|
||||
class SortByEnum(StrEnum):
|
||||
"""Sort options for users list."""
|
||||
|
||||
CREATED_AT = 'created_at'
|
||||
@@ -281,7 +282,7 @@ class UpdateSubscriptionRequest(BaseModel):
|
||||
"""Request to update user subscription."""
|
||||
|
||||
action: str = Field(
|
||||
..., description='Action: extend, set_end_date, change_tariff, set_traffic, toggle_autopay, cancel'
|
||||
..., description='Action: extend, shorten, set_end_date, change_tariff, set_traffic, toggle_autopay, cancel'
|
||||
)
|
||||
|
||||
# For extend action
|
||||
@@ -696,3 +697,40 @@ class DisableUserResponse(BaseModel):
|
||||
panel_deactivated: bool = False
|
||||
user_blocked: bool = False
|
||||
panel_error: str | None = None
|
||||
|
||||
|
||||
# === Gifts ===
|
||||
|
||||
|
||||
class AdminUserGiftItem(BaseModel):
|
||||
"""Gift item for admin user detail view."""
|
||||
|
||||
id: int
|
||||
token: str
|
||||
status: str
|
||||
tariff_name: str | None = None
|
||||
period_days: int
|
||||
device_limit: int = 1
|
||||
amount_kopeks: int
|
||||
payment_method: str | None = None
|
||||
gift_recipient_type: str | None = None
|
||||
gift_recipient_value: str | None = None
|
||||
gift_message: str | None = None
|
||||
buyer_user_id: int | None = None
|
||||
buyer_username: str | None = None
|
||||
buyer_full_name: str | None = None
|
||||
receiver_user_id: int | None = None
|
||||
receiver_username: str | None = None
|
||||
receiver_full_name: str | None = None
|
||||
created_at: datetime | None = None
|
||||
paid_at: datetime | None = None
|
||||
delivered_at: datetime | None = None
|
||||
|
||||
|
||||
class AdminUserGiftsResponse(BaseModel):
|
||||
"""Response with sent and received gifts for admin user detail."""
|
||||
|
||||
sent: list[AdminUserGiftItem] = []
|
||||
received: list[AdminUserGiftItem] = []
|
||||
sent_total: int = 0
|
||||
received_total: int = 0
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Схемы для колеса удачи (Fortune Wheel)."""
|
||||
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from enum import StrEnum
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
@@ -9,14 +9,14 @@ from pydantic import BaseModel, Field
|
||||
# ==================== ENUMS ====================
|
||||
|
||||
|
||||
class WheelPaymentType(str, Enum):
|
||||
class WheelPaymentType(StrEnum):
|
||||
"""Способы оплаты спина."""
|
||||
|
||||
TELEGRAM_STARS = 'telegram_stars'
|
||||
SUBSCRIPTION_DAYS = 'subscription_days'
|
||||
|
||||
|
||||
class WheelPrizeType(str, Enum):
|
||||
class WheelPrizeType(StrEnum):
|
||||
"""Типы призов."""
|
||||
|
||||
SUBSCRIPTION_DAYS = 'subscription_days'
|
||||
@@ -60,6 +60,7 @@ class WheelConfigResponse(BaseModel):
|
||||
can_pay_days: bool = False
|
||||
user_balance_kopeks: int = 0
|
||||
required_balance_kopeks: int = 0
|
||||
has_subscription: bool = False
|
||||
|
||||
|
||||
class SpinAvailabilityResponse(BaseModel):
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"""Email service for sending verification and password reset emails."""
|
||||
|
||||
import html
|
||||
import smtplib
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
from email.utils import formatdate, make_msgid
|
||||
|
||||
import structlog
|
||||
|
||||
@@ -30,7 +32,7 @@ class EmailService:
|
||||
|
||||
def _get_smtp_connection(self) -> smtplib.SMTP:
|
||||
"""Create and return SMTP connection."""
|
||||
smtp = smtplib.SMTP(self.host, self.port)
|
||||
smtp = smtplib.SMTP(self.host, self.port, timeout=30)
|
||||
smtp.ehlo()
|
||||
|
||||
if self.use_tls:
|
||||
@@ -69,11 +71,19 @@ class EmailService:
|
||||
logger.warning('SMTP is not configured, cannot send email')
|
||||
return False
|
||||
|
||||
# Defensive: strip newlines to prevent header injection
|
||||
to_email = to_email.strip().replace('\n', '').replace('\r', '')
|
||||
subject = subject.replace('\n', '').replace('\r', '')
|
||||
|
||||
try:
|
||||
msg = MIMEMultipart('alternative')
|
||||
msg['Subject'] = subject
|
||||
msg['From'] = f'{self.from_name} <{self.from_email}>'
|
||||
safe_from_name = self.from_name.replace('\n', '').replace('\r', '') if self.from_name else ''
|
||||
safe_from_email = self.from_email.replace('\n', '').replace('\r', '') if self.from_email else ''
|
||||
msg['From'] = f'{safe_from_name} <{safe_from_email}>'
|
||||
msg['To'] = to_email
|
||||
msg['Date'] = formatdate(localtime=False)
|
||||
msg['Message-ID'] = make_msgid(domain=self.from_email.split('@')[-1])
|
||||
|
||||
# Plain text version
|
||||
if body_text is None:
|
||||
@@ -133,10 +143,13 @@ class EmailService:
|
||||
full_url = f'{verification_url}?token={verification_token}'
|
||||
expire_hours = settings.get_cabinet_email_verification_expire_hours()
|
||||
|
||||
# Escape user-provided values for HTML context
|
||||
safe_username = html.escape(username) if username else None
|
||||
|
||||
# Localized content
|
||||
texts = {
|
||||
'ru': {
|
||||
'greeting': f'Здравствуйте{", " + username if username else ""}!',
|
||||
'greeting': f'Здравствуйте{", " + safe_username if safe_username else ""}!',
|
||||
'subject': 'Подтверждение email адреса',
|
||||
'intro': 'Спасибо за регистрацию! Пожалуйста, подтвердите ваш email адрес, нажав на кнопку ниже:',
|
||||
'button': 'Подтвердить email',
|
||||
@@ -146,7 +159,7 @@ class EmailService:
|
||||
'regards': 'С уважением,',
|
||||
},
|
||||
'en': {
|
||||
'greeting': f'Hello{", " + username if username else ""}!',
|
||||
'greeting': f'Hello{", " + safe_username if safe_username else ""}!',
|
||||
'subject': 'Verify your email address',
|
||||
'intro': 'Thank you for registering! Please verify your email address by clicking the button below:',
|
||||
'button': 'Verify Email',
|
||||
@@ -156,7 +169,7 @@ class EmailService:
|
||||
'regards': 'Best regards,',
|
||||
},
|
||||
'zh': {
|
||||
'greeting': f'您好{", " + username if username else ""}!',
|
||||
'greeting': f'您好{", " + safe_username if safe_username else ""}!',
|
||||
'subject': '验证您的邮箱地址',
|
||||
'intro': '感谢您的注册!请点击下方按钮验证您的邮箱地址:',
|
||||
'button': '验证邮箱',
|
||||
@@ -166,7 +179,7 @@ class EmailService:
|
||||
'regards': '此致,',
|
||||
},
|
||||
'ua': {
|
||||
'greeting': f'Вітаємо{", " + username if username else ""}!',
|
||||
'greeting': f'Вітаємо{", " + safe_username if safe_username else ""}!',
|
||||
'subject': 'Підтвердження email адреси',
|
||||
'intro': 'Дякуємо за реєстрацію! Будь ласка, підтвердіть вашу email адресу, натиснувши на кнопку нижче:',
|
||||
'button': 'Підтвердити email',
|
||||
@@ -176,7 +189,7 @@ class EmailService:
|
||||
'regards': 'З повагою,',
|
||||
},
|
||||
'fa': {
|
||||
'greeting': f'سلام{", " + username if username else ""}!',
|
||||
'greeting': f'سلام{", " + safe_username if safe_username else ""}!',
|
||||
'subject': 'تایید آدرس ایمیل',
|
||||
'intro': 'از ثبتنام شما سپاسگزاریم! لطفاً با کلیک روی دکمه زیر ایمیل خود را تایید کنید:',
|
||||
'button': 'تایید ایمیل',
|
||||
@@ -260,10 +273,13 @@ class EmailService:
|
||||
full_url = f'{reset_url}?token={reset_token}'
|
||||
expire_hours = settings.get_cabinet_password_reset_expire_hours()
|
||||
|
||||
# Escape user-provided values for HTML context
|
||||
safe_username = html.escape(username) if username else None
|
||||
|
||||
# Localized content
|
||||
texts = {
|
||||
'ru': {
|
||||
'greeting': f'Здравствуйте{", " + username if username else ""}!',
|
||||
'greeting': f'Здравствуйте{", " + safe_username if safe_username else ""}!',
|
||||
'subject': 'Сброс пароля',
|
||||
'intro': 'Мы получили запрос на сброс вашего пароля. Нажмите на кнопку ниже, чтобы установить новый пароль:',
|
||||
'button': 'Сбросить пароль',
|
||||
@@ -273,7 +289,7 @@ class EmailService:
|
||||
'regards': 'С уважением,',
|
||||
},
|
||||
'en': {
|
||||
'greeting': f'Hello{", " + username if username else ""}!',
|
||||
'greeting': f'Hello{", " + safe_username if safe_username else ""}!',
|
||||
'subject': 'Reset your password',
|
||||
'intro': 'We received a request to reset your password. Click the button below to set a new password:',
|
||||
'button': 'Reset Password',
|
||||
@@ -283,7 +299,7 @@ class EmailService:
|
||||
'regards': 'Best regards,',
|
||||
},
|
||||
'zh': {
|
||||
'greeting': f'您好{", " + username if username else ""}!',
|
||||
'greeting': f'您好{", " + safe_username if safe_username else ""}!',
|
||||
'subject': '重置您的密码',
|
||||
'intro': '我们收到了重置您密码的请求。点击下方按钮设置新密码:',
|
||||
'button': '重置密码',
|
||||
@@ -293,7 +309,7 @@ class EmailService:
|
||||
'regards': '此致,',
|
||||
},
|
||||
'ua': {
|
||||
'greeting': f'Вітаємо{", " + username if username else ""}!',
|
||||
'greeting': f'Вітаємо{", " + safe_username if safe_username else ""}!',
|
||||
'subject': 'Скидання пароля',
|
||||
'intro': 'Ми отримали запит на скидання вашого пароля. Натисніть на кнопку нижче, щоб встановити новий пароль:',
|
||||
'button': 'Скинути пароль',
|
||||
@@ -303,7 +319,7 @@ class EmailService:
|
||||
'regards': 'З повагою,',
|
||||
},
|
||||
'fa': {
|
||||
'greeting': f'سلام{", " + username if username else ""}!',
|
||||
'greeting': f'سلام{", " + safe_username if safe_username else ""}!',
|
||||
'subject': 'بازنشانی رمز عبور',
|
||||
'intro': 'درخواستی برای بازنشانی رمز عبور شما دریافت شد. برای تعیین رمز جدید روی دکمه زیر بزنید:',
|
||||
'button': 'بازنشانی رمز عبور',
|
||||
@@ -385,9 +401,12 @@ class EmailService:
|
||||
|
||||
expire_minutes = settings.get_cabinet_email_change_code_expire_minutes()
|
||||
|
||||
# Escape user-provided values for HTML context
|
||||
safe_username = html.escape(username) if username else None
|
||||
|
||||
texts = {
|
||||
'ru': {
|
||||
'greeting': f'Здравствуйте{", " + username if username else ""}!',
|
||||
'greeting': f'Здравствуйте{", " + safe_username if safe_username else ""}!',
|
||||
'subject': 'Код подтверждения для смены email',
|
||||
'intro': 'Вы запросили смену email адреса. Используйте код ниже для подтверждения:',
|
||||
'code_label': 'Ваш код подтверждения:',
|
||||
@@ -396,7 +415,7 @@ class EmailService:
|
||||
'regards': 'С уважением,',
|
||||
},
|
||||
'en': {
|
||||
'greeting': f'Hello{", " + username if username else ""}!',
|
||||
'greeting': f'Hello{", " + safe_username if safe_username else ""}!',
|
||||
'subject': 'Email change verification code',
|
||||
'intro': 'You requested to change your email address. Use the code below to confirm:',
|
||||
'code_label': 'Your verification code:',
|
||||
@@ -405,7 +424,7 @@ class EmailService:
|
||||
'regards': 'Best regards,',
|
||||
},
|
||||
'zh': {
|
||||
'greeting': f'您好{", " + username if username else ""}!',
|
||||
'greeting': f'您好{", " + safe_username if safe_username else ""}!',
|
||||
'subject': '邮箱更换验证码',
|
||||
'intro': '您请求更换邮箱地址。请使用以下验证码确认:',
|
||||
'code_label': '您的验证码:',
|
||||
@@ -414,7 +433,7 @@ class EmailService:
|
||||
'regards': '此致,',
|
||||
},
|
||||
'ua': {
|
||||
'greeting': f'Вітаємо{", " + username if username else ""}!',
|
||||
'greeting': f'Вітаємо{", " + safe_username if safe_username else ""}!',
|
||||
'subject': 'Код підтвердження для зміни email',
|
||||
'intro': 'Ви запросили зміну email адреси. Використовуйте код нижче для підтвердження:',
|
||||
'code_label': 'Ваш код підтвердження:',
|
||||
@@ -423,7 +442,7 @@ class EmailService:
|
||||
'regards': 'З повагою,',
|
||||
},
|
||||
'fa': {
|
||||
'greeting': f'سلام{", " + username if username else ""}!',
|
||||
'greeting': f'سلام{", " + safe_username if safe_username else ""}!',
|
||||
'subject': 'کد تایید تغییر ایمیل',
|
||||
'intro': 'شما درخواست تغییر ایمیل دادهاید. برای تایید از کد زیر استفاده کنید:',
|
||||
'code_label': 'کد تایید شما:',
|
||||
|
||||
@@ -4,6 +4,7 @@ Service for managing email template overrides stored in the database.
|
||||
Custom templates override the hardcoded defaults from email_templates.py.
|
||||
"""
|
||||
|
||||
import html
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
@@ -195,7 +196,7 @@ async def get_rendered_override(
|
||||
# Simple variable substitution for context vars like {username}, {verification_url}, etc.
|
||||
if context:
|
||||
for key, value in context.items():
|
||||
body_html = body_html.replace(f'{{{key}}}', str(value))
|
||||
body_html = body_html.replace(f'{{{key}}}', html.escape(str(value)))
|
||||
|
||||
rendered = templates._get_base_template(body_html, language)
|
||||
subject = override['subject']
|
||||
@@ -203,7 +204,8 @@ async def get_rendered_override(
|
||||
# Also substitute in subject
|
||||
if context:
|
||||
for key, value in context.items():
|
||||
subject = subject.replace(f'{{{key}}}', str(value))
|
||||
safe_value = str(value).replace('\r', '').replace('\n', '')
|
||||
subject = subject.replace(f'{{{key}}}', safe_value)
|
||||
|
||||
return (subject, rendered)
|
||||
|
||||
|
||||
@@ -62,6 +62,10 @@ class EmailNotificationTemplates:
|
||||
NotificationType.PAYMENT_RECEIVED: self._payment_received_template,
|
||||
NotificationType.EMAIL_VERIFICATION: self._email_verification_template,
|
||||
NotificationType.PASSWORD_RESET: self._password_reset_template,
|
||||
NotificationType.GUEST_SUBSCRIPTION_DELIVERED: self._guest_subscription_delivered_template,
|
||||
NotificationType.GUEST_ACTIVATION_REQUIRED: self._guest_activation_required_template,
|
||||
NotificationType.GUEST_GIFT_RECEIVED: self._guest_gift_received_template,
|
||||
NotificationType.GUEST_CABINET_CREDENTIALS: self._guest_cabinet_credentials_template,
|
||||
}
|
||||
|
||||
template_func = template_map.get(notification_type)
|
||||
@@ -1185,8 +1189,8 @@ class EmailNotificationTemplates:
|
||||
|
||||
def _email_verification_template(self, language: str, context: dict[str, Any]) -> dict[str, str]:
|
||||
"""Template for email verification."""
|
||||
username = context.get('username', '')
|
||||
verification_url = context.get('verification_url', '#')
|
||||
username = html.escape(context.get('username', ''))
|
||||
verification_url = html.escape(context.get('verification_url', '#'))
|
||||
expire_hours = context.get('expire_hours', 24)
|
||||
|
||||
subjects = {
|
||||
@@ -1257,8 +1261,8 @@ class EmailNotificationTemplates:
|
||||
|
||||
def _password_reset_template(self, language: str, context: dict[str, Any]) -> dict[str, str]:
|
||||
"""Template for password reset."""
|
||||
username = context.get('username', '')
|
||||
reset_url = context.get('reset_url', '#')
|
||||
username = html.escape(context.get('username', ''))
|
||||
reset_url = html.escape(context.get('reset_url', '#'))
|
||||
expire_hours = context.get('expire_hours', 1)
|
||||
|
||||
subjects = {
|
||||
@@ -1327,6 +1331,393 @@ class EmailNotificationTemplates:
|
||||
'body_html': self._get_base_template(bodies.get(language, bodies['ru']), language),
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Guest Purchase Templates
|
||||
# ============================================================================
|
||||
|
||||
def _guest_subscription_delivered_template(self, language: str, context: dict[str, Any]) -> dict[str, str]:
|
||||
"""Template for guest subscription delivered notification."""
|
||||
tariff_name = html.escape(context.get('tariff_name', ''))
|
||||
period_days = context.get('period_days', 0)
|
||||
cabinet_url = html.escape(context.get('cabinet_url', ''))
|
||||
|
||||
subjects = {
|
||||
'ru': 'Ваша VPN подписка готова',
|
||||
'en': 'Your VPN subscription is ready',
|
||||
'zh': '您的VPN订阅已准备就绪',
|
||||
'ua': 'Ваша VPN підписка готова',
|
||||
'fa': 'اشتراک VPN شما آماده است',
|
||||
}
|
||||
|
||||
bodies = {
|
||||
'ru': f"""
|
||||
<h2>Ваша VPN подписка готова!</h2>
|
||||
<div class="highlight success">
|
||||
<p>Тариф: <strong>{tariff_name}</strong></p>
|
||||
<p>Период: <strong>{period_days} дней</strong></p>
|
||||
</div>
|
||||
<p>Подписка активирована в вашем личном кабинете.</p>
|
||||
<p style="text-align: center;"><a href="{cabinet_url}" class="button">Перейти в личный кабинет</a></p>
|
||||
""",
|
||||
'en': f"""
|
||||
<h2>Your VPN subscription is ready!</h2>
|
||||
<div class="highlight success">
|
||||
<p>Plan: <strong>{tariff_name}</strong></p>
|
||||
<p>Period: <strong>{period_days} days</strong></p>
|
||||
</div>
|
||||
<p>Your subscription has been activated in your cabinet.</p>
|
||||
<p style="text-align: center;"><a href="{cabinet_url}" class="button">Go to Cabinet</a></p>
|
||||
""",
|
||||
'zh': f"""
|
||||
<h2>您的VPN订阅已准备就绪!</h2>
|
||||
<div class="highlight success">
|
||||
<p>套餐: <strong>{tariff_name}</strong></p>
|
||||
<p>期限: <strong>{period_days} 天</strong></p>
|
||||
</div>
|
||||
<p>订阅已在您的个人中心激活。</p>
|
||||
<p style="text-align: center;"><a href="{cabinet_url}" class="button">前往个人中心</a></p>
|
||||
""",
|
||||
'ua': f"""
|
||||
<h2>Ваша VPN підписка готова!</h2>
|
||||
<div class="highlight success">
|
||||
<p>Тариф: <strong>{tariff_name}</strong></p>
|
||||
<p>Період: <strong>{period_days} днів</strong></p>
|
||||
</div>
|
||||
<p>Підписка активована у вашому особистому кабінеті.</p>
|
||||
<p style="text-align: center;"><a href="{cabinet_url}" class="button">Перейти до кабінету</a></p>
|
||||
""",
|
||||
'fa': f"""
|
||||
<h2>اشتراک VPN شما آماده است!</h2>
|
||||
<div class="highlight success">
|
||||
<p>طرح: <strong>{tariff_name}</strong></p>
|
||||
<p>مدت: <strong>{period_days} روز</strong></p>
|
||||
</div>
|
||||
<p>اشتراک شما در پنل کاربری فعال شده است.</p>
|
||||
<p style="text-align: center;"><a href="{cabinet_url}" class="button">رفتن به پنل کاربری</a></p>
|
||||
""",
|
||||
}
|
||||
|
||||
return {
|
||||
'subject': subjects.get(language, subjects['ru']),
|
||||
'body_html': self._get_base_template(bodies.get(language, bodies['ru']), language),
|
||||
}
|
||||
|
||||
def _guest_activation_required_template(self, language: str, context: dict[str, Any]) -> dict[str, str]:
|
||||
"""Template for guest purchase pending activation (user already has a subscription)."""
|
||||
tariff_name = html.escape(context.get('tariff_name', ''))
|
||||
period_days = context.get('period_days', 0)
|
||||
success_page_url = html.escape(context.get('success_page_url', ''))
|
||||
gift_message = context.get('gift_message')
|
||||
is_gift = context.get('is_gift', False)
|
||||
|
||||
gift_block_ru = ''
|
||||
gift_block_en = ''
|
||||
gift_block_zh = ''
|
||||
gift_block_ua = ''
|
||||
gift_block_fa = ''
|
||||
if is_gift and gift_message:
|
||||
escaped_msg = html.escape(gift_message)
|
||||
gift_block_ru = f'<div class="highlight"><p><em>Сообщение: {escaped_msg}</em></p></div>'
|
||||
gift_block_en = f'<div class="highlight"><p><em>Message: {escaped_msg}</em></p></div>'
|
||||
gift_block_zh = f'<div class="highlight"><p><em>留言: {escaped_msg}</em></p></div>'
|
||||
gift_block_ua = f'<div class="highlight"><p><em>Повідомлення: {escaped_msg}</em></p></div>'
|
||||
gift_block_fa = f'<div class="highlight"><p><em>پیام: {escaped_msg}</em></p></div>'
|
||||
|
||||
subjects = {
|
||||
'ru': 'Требуется активация подписки',
|
||||
'en': 'Subscription activation required',
|
||||
'zh': '需要激活订阅',
|
||||
'ua': 'Потрібна активація підписки',
|
||||
'fa': 'فعالسازی اشتراک لازم است',
|
||||
}
|
||||
|
||||
bodies = {
|
||||
'ru': f"""
|
||||
<h2>Требуется активация подписки</h2>
|
||||
{gift_block_ru}
|
||||
<div class="highlight">
|
||||
<p>Тариф: <strong>{tariff_name}</strong></p>
|
||||
<p>Период: <strong>{period_days} дней</strong></p>
|
||||
</div>
|
||||
<p class="warning">У вас уже есть активная подписка. Активация новой заменит текущую.</p>
|
||||
<p style="text-align: center;"><a href="{success_page_url}" class="button">Активировать подписку</a></p>
|
||||
""",
|
||||
'en': f"""
|
||||
<h2>Subscription activation required</h2>
|
||||
{gift_block_en}
|
||||
<div class="highlight">
|
||||
<p>Plan: <strong>{tariff_name}</strong></p>
|
||||
<p>Period: <strong>{period_days} days</strong></p>
|
||||
</div>
|
||||
<p class="warning">You already have an active subscription. Activating will replace your current one.</p>
|
||||
<p style="text-align: center;"><a href="{success_page_url}" class="button">Activate subscription</a></p>
|
||||
""",
|
||||
'zh': f"""
|
||||
<h2>需要激活订阅</h2>
|
||||
{gift_block_zh}
|
||||
<div class="highlight">
|
||||
<p>套餐: <strong>{tariff_name}</strong></p>
|
||||
<p>期限: <strong>{period_days} 天</strong></p>
|
||||
</div>
|
||||
<p class="warning">您已有活跃订阅。激活新订阅将替换当前订阅。</p>
|
||||
<p style="text-align: center;"><a href="{success_page_url}" class="button">激活订阅</a></p>
|
||||
""",
|
||||
'ua': f"""
|
||||
<h2>Потрібна активація підписки</h2>
|
||||
{gift_block_ua}
|
||||
<div class="highlight">
|
||||
<p>Тариф: <strong>{tariff_name}</strong></p>
|
||||
<p>Період: <strong>{period_days} днів</strong></p>
|
||||
</div>
|
||||
<p class="warning">У вас вже є активна підписка. Активація нової замінить поточну.</p>
|
||||
<p style="text-align: center;"><a href="{success_page_url}" class="button">Активувати підписку</a></p>
|
||||
""",
|
||||
'fa': f"""
|
||||
<h2>فعالسازی اشتراک لازم است</h2>
|
||||
{gift_block_fa}
|
||||
<div class="highlight">
|
||||
<p>طرح: <strong>{tariff_name}</strong></p>
|
||||
<p>مدت: <strong>{period_days} روز</strong></p>
|
||||
</div>
|
||||
<p class="warning">شما از قبل اشتراک فعالی دارید. فعالسازی اشتراک جدید جایگزین فعلی خواهد شد.</p>
|
||||
<p style="text-align: center;"><a href="{success_page_url}" class="button">فعالسازی اشتراک</a></p>
|
||||
""",
|
||||
}
|
||||
|
||||
return {
|
||||
'subject': subjects.get(language, subjects['ru']),
|
||||
'body_html': self._get_base_template(bodies.get(language, bodies['ru']), language),
|
||||
}
|
||||
|
||||
def _guest_gift_received_template(self, language: str, context: dict[str, Any]) -> dict[str, str]:
|
||||
"""Template for gift subscription received notification."""
|
||||
tariff_name = html.escape(context.get('tariff_name', ''))
|
||||
period_days = context.get('period_days', 0)
|
||||
gift_message = context.get('gift_message')
|
||||
cabinet_password = context.get('cabinet_password')
|
||||
cabinet_email = html.escape(context.get('cabinet_email', ''))
|
||||
cabinet_url = html.escape(context.get('cabinet_url', ''))
|
||||
|
||||
# Credentials block for gift recipients who got a new cabinet account
|
||||
cred_block = {'ru': '', 'en': '', 'zh': '', 'ua': '', 'fa': ''}
|
||||
if cabinet_password and cabinet_email:
|
||||
escaped_pw = html.escape(cabinet_password)
|
||||
cred_block = {
|
||||
'ru': f"""
|
||||
<div class="highlight">
|
||||
<p><strong>Данные для входа в личный кабинет:</strong></p>
|
||||
<p>Email: <code>{cabinet_email}</code></p>
|
||||
<p>Пароль: <code>{escaped_pw}</code></p>
|
||||
</div>
|
||||
<p style="text-align: center;"><a href="{cabinet_url}" class="button">Перейти в личный кабинет</a></p>
|
||||
""",
|
||||
'en': f"""
|
||||
<div class="highlight">
|
||||
<p><strong>Your cabinet login credentials:</strong></p>
|
||||
<p>Email: <code>{cabinet_email}</code></p>
|
||||
<p>Password: <code>{escaped_pw}</code></p>
|
||||
</div>
|
||||
<p style="text-align: center;"><a href="{cabinet_url}" class="button">Go to Cabinet</a></p>
|
||||
""",
|
||||
'zh': f"""
|
||||
<div class="highlight">
|
||||
<p><strong>个人中心登录信息:</strong></p>
|
||||
<p>邮箱: <code>{cabinet_email}</code></p>
|
||||
<p>密码: <code>{escaped_pw}</code></p>
|
||||
</div>
|
||||
<p style="text-align: center;"><a href="{cabinet_url}" class="button">前往个人中心</a></p>
|
||||
""",
|
||||
'ua': f"""
|
||||
<div class="highlight">
|
||||
<p><strong>Дані для входу в особистий кабінет:</strong></p>
|
||||
<p>Email: <code>{cabinet_email}</code></p>
|
||||
<p>Пароль: <code>{escaped_pw}</code></p>
|
||||
</div>
|
||||
<p style="text-align: center;"><a href="{cabinet_url}" class="button">Перейти до кабінету</a></p>
|
||||
""",
|
||||
'fa': f"""
|
||||
<div class="highlight">
|
||||
<p><strong>اطلاعات ورود به پنل کاربری:</strong></p>
|
||||
<p>ایمیل: <code dir="ltr">{cabinet_email}</code></p>
|
||||
<p>رمز عبور: <code dir="ltr">{escaped_pw}</code></p>
|
||||
</div>
|
||||
<p style="text-align: center;"><a href="{cabinet_url}" class="button">رفتن به پنل کاربری</a></p>
|
||||
""",
|
||||
}
|
||||
|
||||
gift_block_ru = ''
|
||||
gift_block_en = ''
|
||||
gift_block_zh = ''
|
||||
gift_block_ua = ''
|
||||
gift_block_fa = ''
|
||||
if gift_message:
|
||||
escaped_msg = html.escape(gift_message)
|
||||
gift_block_ru = f'<div class="highlight"><p><em>Сообщение: {escaped_msg}</em></p></div>'
|
||||
gift_block_en = f'<div class="highlight"><p><em>Message: {escaped_msg}</em></p></div>'
|
||||
gift_block_zh = f'<div class="highlight"><p><em>留言: {escaped_msg}</em></p></div>'
|
||||
gift_block_ua = f'<div class="highlight"><p><em>Повідомлення: {escaped_msg}</em></p></div>'
|
||||
gift_block_fa = f'<div class="highlight"><p><em>پیام: {escaped_msg}</em></p></div>'
|
||||
|
||||
subjects = {
|
||||
'ru': 'Вам подарили VPN подписку!',
|
||||
'en': "You've been gifted a VPN subscription!",
|
||||
'zh': '您收到了VPN订阅礼物!',
|
||||
'ua': 'Вам подарували VPN підписку!',
|
||||
'fa': 'یک اشتراک VPN به شما هدیه داده شده است!',
|
||||
}
|
||||
|
||||
bodies = {
|
||||
'ru': f"""
|
||||
<h2>Вам подарили VPN подписку!</h2>
|
||||
{gift_block_ru}
|
||||
<div class="highlight success">
|
||||
<p>Тариф: <strong>{tariff_name}</strong></p>
|
||||
<p>Период: <strong>{period_days} дней</strong></p>
|
||||
</div>
|
||||
<p>Подписка активирована в личном кабинете.</p>
|
||||
{cred_block['ru']}
|
||||
<p style="text-align: center;"><a href="{cabinet_url}" class="button">Перейти в личный кабинет</a></p>
|
||||
""",
|
||||
'en': f"""
|
||||
<h2>You've been gifted a VPN subscription!</h2>
|
||||
{gift_block_en}
|
||||
<div class="highlight success">
|
||||
<p>Plan: <strong>{tariff_name}</strong></p>
|
||||
<p>Period: <strong>{period_days} days</strong></p>
|
||||
</div>
|
||||
<p>Your subscription has been activated in the cabinet.</p>
|
||||
{cred_block['en']}
|
||||
<p style="text-align: center;"><a href="{cabinet_url}" class="button">Go to Cabinet</a></p>
|
||||
""",
|
||||
'zh': f"""
|
||||
<h2>您收到了VPN订阅礼物!</h2>
|
||||
{gift_block_zh}
|
||||
<div class="highlight success">
|
||||
<p>套餐: <strong>{tariff_name}</strong></p>
|
||||
<p>期限: <strong>{period_days} 天</strong></p>
|
||||
</div>
|
||||
<p>订阅已在个人中心激活。</p>
|
||||
{cred_block['zh']}
|
||||
<p style="text-align: center;"><a href="{cabinet_url}" class="button">前往个人中心</a></p>
|
||||
""",
|
||||
'ua': f"""
|
||||
<h2>Вам подарували VPN підписку!</h2>
|
||||
{gift_block_ua}
|
||||
<div class="highlight success">
|
||||
<p>Тариф: <strong>{tariff_name}</strong></p>
|
||||
<p>Період: <strong>{period_days} днів</strong></p>
|
||||
</div>
|
||||
<p>Підписка активована в особистому кабінеті.</p>
|
||||
{cred_block['ua']}
|
||||
<p style="text-align: center;"><a href="{cabinet_url}" class="button">Перейти до кабінету</a></p>
|
||||
""",
|
||||
'fa': f"""
|
||||
<h2>یک اشتراک VPN به شما هدیه داده شده است!</h2>
|
||||
{gift_block_fa}
|
||||
<div class="highlight success">
|
||||
<p>طرح: <strong>{tariff_name}</strong></p>
|
||||
<p>مدت: <strong>{period_days} روز</strong></p>
|
||||
</div>
|
||||
<p>اشتراک در پنل کاربری فعال شده است.</p>
|
||||
{cred_block['fa']}
|
||||
<p style="text-align: center;"><a href="{cabinet_url}" class="button">رفتن به پنل کاربری</a></p>
|
||||
""",
|
||||
}
|
||||
|
||||
return {
|
||||
'subject': subjects.get(language, subjects['ru']),
|
||||
'body_html': self._get_base_template(bodies.get(language, bodies['ru']), language),
|
||||
}
|
||||
|
||||
def _guest_cabinet_credentials_template(self, language: str, context: dict[str, Any]) -> dict[str, str]:
|
||||
"""Template for cabinet login credentials email (sent separately from subscription)."""
|
||||
cabinet_email = html.escape(context.get('cabinet_email', ''))
|
||||
cabinet_password = html.escape(context.get('cabinet_password', ''))
|
||||
cabinet_url = html.escape(context.get('cabinet_url', ''))
|
||||
tariff_name = html.escape(context.get('tariff_name', ''))
|
||||
period_days = context.get('period_days', 0)
|
||||
|
||||
subjects = {
|
||||
'ru': 'Данные для входа в личный кабинет',
|
||||
'en': 'Your cabinet login credentials',
|
||||
'zh': '您的个人中心登录信息',
|
||||
'ua': 'Дані для входу в особистий кабінет',
|
||||
'fa': 'اطلاعات ورود به پنل کاربری',
|
||||
}
|
||||
|
||||
bodies = {
|
||||
'ru': f"""
|
||||
<h2>Данные для входа в личный кабинет</h2>
|
||||
<div class="highlight success">
|
||||
<p>Тариф: <strong>{tariff_name}</strong></p>
|
||||
<p>Период: <strong>{period_days} дней</strong></p>
|
||||
</div>
|
||||
<div class="highlight">
|
||||
<p><strong>Email:</strong> <code>{cabinet_email}</code></p>
|
||||
<p><strong>Пароль:</strong> <code>{cabinet_password}</code></p>
|
||||
</div>
|
||||
<p>Сохраните эти данные для входа. Вы можете изменить пароль в настройках кабинета.</p>
|
||||
<p style="text-align: center;"><a href="{cabinet_url}" class="button">Перейти в личный кабинет</a></p>
|
||||
""",
|
||||
'en': f"""
|
||||
<h2>Your cabinet login credentials</h2>
|
||||
<div class="highlight success">
|
||||
<p>Plan: <strong>{tariff_name}</strong></p>
|
||||
<p>Period: <strong>{period_days} days</strong></p>
|
||||
</div>
|
||||
<div class="highlight">
|
||||
<p><strong>Email:</strong> <code>{cabinet_email}</code></p>
|
||||
<p><strong>Password:</strong> <code>{cabinet_password}</code></p>
|
||||
</div>
|
||||
<p>Save these credentials. You can change your password in cabinet settings.</p>
|
||||
<p style="text-align: center;"><a href="{cabinet_url}" class="button">Go to Cabinet</a></p>
|
||||
""",
|
||||
'zh': f"""
|
||||
<h2>您的个人中心登录信息</h2>
|
||||
<div class="highlight success">
|
||||
<p>套餐: <strong>{tariff_name}</strong></p>
|
||||
<p>期限: <strong>{period_days} 天</strong></p>
|
||||
</div>
|
||||
<div class="highlight">
|
||||
<p><strong>邮箱:</strong> <code>{cabinet_email}</code></p>
|
||||
<p><strong>密码:</strong> <code>{cabinet_password}</code></p>
|
||||
</div>
|
||||
<p>请保存这些登录信息。您可以在个人中心设置中更改密码。</p>
|
||||
<p style="text-align: center;"><a href="{cabinet_url}" class="button">前往个人中心</a></p>
|
||||
""",
|
||||
'ua': f"""
|
||||
<h2>Дані для входу в особистий кабінет</h2>
|
||||
<div class="highlight success">
|
||||
<p>Тариф: <strong>{tariff_name}</strong></p>
|
||||
<p>Період: <strong>{period_days} днів</strong></p>
|
||||
</div>
|
||||
<div class="highlight">
|
||||
<p><strong>Email:</strong> <code>{cabinet_email}</code></p>
|
||||
<p><strong>Пароль:</strong> <code>{cabinet_password}</code></p>
|
||||
</div>
|
||||
<p>Збережіть ці дані. Ви можете змінити пароль у налаштуваннях кабінету.</p>
|
||||
<p style="text-align: center;"><a href="{cabinet_url}" class="button">Перейти до кабінету</a></p>
|
||||
""",
|
||||
'fa': f"""
|
||||
<h2>اطلاعات ورود به پنل کاربری</h2>
|
||||
<div class="highlight success">
|
||||
<p>طرح: <strong>{tariff_name}</strong></p>
|
||||
<p>مدت: <strong>{period_days} روز</strong></p>
|
||||
</div>
|
||||
<div class="highlight">
|
||||
<p><strong>ایمیل:</strong> <code dir="ltr">{cabinet_email}</code></p>
|
||||
<p><strong>رمز عبور:</strong> <code dir="ltr">{cabinet_password}</code></p>
|
||||
</div>
|
||||
<p>این اطلاعات را ذخیره کنید. میتوانید رمز عبور خود را در تنظیمات پنل تغییر دهید.</p>
|
||||
<p style="text-align: center;"><a href="{cabinet_url}" class="button">رفتن به پنل کاربری</a></p>
|
||||
""",
|
||||
}
|
||||
|
||||
return {
|
||||
'subject': subjects.get(language, subjects['ru']),
|
||||
'body_html': self._get_base_template(bodies.get(language, bodies['ru']), language),
|
||||
}
|
||||
|
||||
|
||||
# Singleton instance
|
||||
email_notification_templates = EmailNotificationTemplates()
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
"""Shared utility for generating campaign deep links and web links."""
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
def get_campaign_deep_link(start_parameter: str) -> str:
|
||||
"""Generate a Telegram deep link for a campaign."""
|
||||
bot_username = settings.get_bot_username()
|
||||
if bot_username:
|
||||
return f'https://t.me/{bot_username}?start={start_parameter}'
|
||||
return f'?start={start_parameter}'
|
||||
|
||||
|
||||
def get_campaign_web_link(start_parameter: str) -> str | None:
|
||||
"""Generate a web app link for a campaign."""
|
||||
base_url = (settings.MINIAPP_CUSTOM_URL or '').rstrip('/')
|
||||
if base_url:
|
||||
return f'{base_url}/?campaign={start_parameter}'
|
||||
return None
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Locale resolution utilities for multi-locale landing page text fields."""
|
||||
|
||||
SUPPORTED_LOCALES: tuple[str, ...] = ('ru', 'en', 'zh', 'fa')
|
||||
DEFAULT_LOCALE: str = 'ru'
|
||||
|
||||
|
||||
def resolve_locale_text(data: dict[str, str] | str | None, lang: str = DEFAULT_LOCALE) -> str:
|
||||
"""Resolve a localized text dict to a single string for the given language.
|
||||
|
||||
Fallback chain: requested lang -> 'ru' -> 'en' -> first available value -> ''.
|
||||
Accepts plain strings for backward compatibility with pre-migration data.
|
||||
"""
|
||||
if data is None:
|
||||
return ''
|
||||
if isinstance(data, str):
|
||||
return data
|
||||
return data.get(lang) or data.get('ru') or data.get('en') or next(iter(data.values()), '')
|
||||
|
||||
|
||||
def ensure_locale_dict(value: dict[str, str] | str | None) -> dict[str, str]:
|
||||
"""Coerce a value to a locale dict. Plain strings become ``{'ru': value}``."""
|
||||
if value is None:
|
||||
return {}
|
||||
if isinstance(value, str):
|
||||
return {'ru': value} if value else {}
|
||||
return value
|
||||
|
||||
|
||||
def validate_locale_dict(
|
||||
value: dict[str, str],
|
||||
*,
|
||||
max_length: int | None = None,
|
||||
field_name: str = 'field',
|
||||
) -> dict[str, str]:
|
||||
"""Validate that all keys are supported locales and values respect length limits."""
|
||||
for locale, text in value.items():
|
||||
if locale not in SUPPORTED_LOCALES:
|
||||
raise ValueError(f'Unsupported locale "{locale}" in {field_name}. Allowed: {", ".join(SUPPORTED_LOCALES)}')
|
||||
if not isinstance(text, str):
|
||||
raise ValueError(f'{field_name}[{locale}] must be a string')
|
||||
if max_length is not None and len(text) > max_length:
|
||||
raise ValueError(f'{field_name}[{locale}] exceeds max length {max_length} (got {len(text)})')
|
||||
return value
|
||||
+154
-28
@@ -1,12 +1,12 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
import html
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
from collections import defaultdict
|
||||
from datetime import time
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
from urllib.parse import urlparse
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
@@ -134,6 +134,7 @@ class Settings(BaseSettings):
|
||||
DEFAULT_DEVICE_LIMIT: int = 1
|
||||
DEFAULT_TRAFFIC_RESET_STRATEGY: str = 'MONTH'
|
||||
RESET_TRAFFIC_ON_PAYMENT: bool = False
|
||||
RESET_TRAFFIC_ON_TARIFF_SWITCH: bool = True
|
||||
MAX_DEVICES_LIMIT: int = 20
|
||||
|
||||
TRIAL_WARNING_HOURS: int = 2
|
||||
@@ -218,6 +219,7 @@ class Settings(BaseSettings):
|
||||
REFERRAL_FIRST_TOPUP_BONUS_KOPEKS: int = 10000
|
||||
REFERRAL_INVITER_BONUS_KOPEKS: int = 10000
|
||||
REFERRAL_COMMISSION_PERCENT: int = 25
|
||||
REFERRAL_MAX_COMMISSION_PAYMENTS: int = 0 # Макс. кол-во платежей реферала с комиссией (0 = без лимита)
|
||||
|
||||
REFERRAL_PROGRAM_ENABLED: bool = True
|
||||
REFERRAL_NOTIFICATIONS_ENABLED: bool = True
|
||||
@@ -317,6 +319,17 @@ class Settings(BaseSettings):
|
||||
TELEGRAM_STARS_RATE_RUB: float = 1.3
|
||||
TELEGRAM_STARS_DISPLAY_NAME: str = 'Telegram Stars'
|
||||
|
||||
# Telegram Login Widget (cabinet auth page)
|
||||
TELEGRAM_WIDGET_SIZE: Literal['large', 'medium', 'small'] = 'large'
|
||||
TELEGRAM_WIDGET_RADIUS: int = Field(default=8, ge=0, le=20)
|
||||
TELEGRAM_WIDGET_USERPIC: bool = True
|
||||
TELEGRAM_WIDGET_REQUEST_ACCESS: bool = True
|
||||
|
||||
# Telegram Login OIDC (new system via oauth.telegram.org)
|
||||
TELEGRAM_OIDC_ENABLED: bool = False
|
||||
TELEGRAM_OIDC_CLIENT_ID: str = ''
|
||||
TELEGRAM_OIDC_CLIENT_SECRET: str = ''
|
||||
|
||||
TRIBUTE_ENABLED: bool = False
|
||||
TRIBUTE_API_KEY: str | None = None
|
||||
TRIBUTE_DONATE_LINK: str | None = None
|
||||
@@ -341,6 +354,8 @@ class Settings(BaseSettings):
|
||||
YOOKASSA_MIN_AMOUNT_KOPEKS: int = 5000
|
||||
YOOKASSA_MAX_AMOUNT_KOPEKS: int = 1000000
|
||||
YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED: bool = False
|
||||
YOOKASSA_RECURRENT_ENABLED: bool = False
|
||||
YOOKASSA_RECURRENT_REQUIRED: bool = False
|
||||
DISABLE_TOPUP_BUTTONS: bool = False
|
||||
SUPPORT_TOPUP_ENABLED: bool = True
|
||||
PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED: bool = False
|
||||
@@ -500,6 +515,11 @@ class Settings(BaseSettings):
|
||||
FREEKASSA_USE_API: bool = False
|
||||
# Публичный IP сервера для Freekassa API (если не задан - определяется автоматически)
|
||||
SERVER_PUBLIC_IP: str | None = None
|
||||
# Раздельные методы оплаты Freekassa (отображаются как отдельные кнопки)
|
||||
FREEKASSA_SBP_ENABLED: bool = False # СБП (QR код) — i=44
|
||||
FREEKASSA_SBP_DISPLAY_NAME: str = 'СБП (QR код)'
|
||||
FREEKASSA_CARD_ENABLED: bool = False # Карты РФ — i=36
|
||||
FREEKASSA_CARD_DISPLAY_NAME: str = 'Карта РФ'
|
||||
|
||||
# KassaAI (api.fk.life) - отдельная платёжка
|
||||
KASSA_AI_ENABLED: bool = False
|
||||
@@ -516,6 +536,18 @@ class Settings(BaseSettings):
|
||||
# Способ оплаты: 44 = СБП (QR код), 36 = Карты РФ, 43 = SberPay
|
||||
KASSA_AI_PAYMENT_SYSTEM_ID: int = 44
|
||||
|
||||
# RioPay (api.riopay.online) v2.0.1
|
||||
RIOPAY_ENABLED: bool = False
|
||||
RIOPAY_API_TOKEN: str | None = None # x-api-token header
|
||||
RIOPAY_WEBHOOK_SECRET: str | None = None # HMAC-SHA512 ключ для вебхуков (по умолчанию = API_TOKEN)
|
||||
RIOPAY_DISPLAY_NAME: str = 'RioPay'
|
||||
RIOPAY_CURRENCY: str = 'RUB'
|
||||
RIOPAY_MIN_AMOUNT_KOPEKS: int = 10000 # 100₽
|
||||
RIOPAY_MAX_AMOUNT_KOPEKS: int = 100000000 # 1 000 000₽
|
||||
RIOPAY_WEBHOOK_PATH: str = '/riopay-webhook'
|
||||
RIOPAY_SUCCESS_URL: str | None = None
|
||||
RIOPAY_FAIL_URL: str | None = None
|
||||
|
||||
MAIN_MENU_MODE: str = 'default' # 'default' | 'cabinet'
|
||||
# Стиль кнопок Cabinet: primary (синий), success (зелёный), danger (красный), '' (по умолчанию для каждой секции)
|
||||
CABINET_BUTTON_STYLE: str = ''
|
||||
@@ -708,6 +740,9 @@ class Settings(BaseSettings):
|
||||
CABINET_EMAIL_CHANGE_CODE_EXPIRE_MINUTES: int = 15 # Email change verification code expiration
|
||||
CABINET_EMAIL_AUTH_ENABLED: bool = True # Enable email registration/login in cabinet
|
||||
CABINET_URL: str = 'https://example.com/cabinet' # Base URL for cabinet (used in verification emails)
|
||||
CABINET_TRUSTED_PROXIES: str = (
|
||||
'' # Comma-separated IPs/CIDRs of trusted reverse proxies (e.g. '127.0.0.1,10.0.0.0/8')
|
||||
)
|
||||
|
||||
# OAuth 2.0 provider settings for cabinet
|
||||
OAUTH_GOOGLE_CLIENT_ID: str = ''
|
||||
@@ -916,12 +951,12 @@ class Settings(BaseSettings):
|
||||
def get_test_email(self) -> str | None:
|
||||
"""Get test email for development/testing."""
|
||||
email = (self.TEST_EMAIL or '').strip().lower()
|
||||
return email if email else None
|
||||
return email or None
|
||||
|
||||
def get_test_email_password(self) -> str | None:
|
||||
"""Get test email password."""
|
||||
password = (self.TEST_EMAIL_PASSWORD or '').strip()
|
||||
return password if password else None
|
||||
return password or None
|
||||
|
||||
def is_test_email(self, email: str) -> bool:
|
||||
"""Check if email is the configured test email."""
|
||||
@@ -1380,6 +1415,26 @@ class Settings(BaseSettings):
|
||||
return value
|
||||
return None
|
||||
|
||||
_CABINET_URL_DEFAULT = 'https://example.com/cabinet'
|
||||
|
||||
def get_referral_link(self, referral_code: str, bot_username: str | None = None) -> str:
|
||||
"""Build a referral link pointing to the web cabinet.
|
||||
|
||||
Falls back to a Telegram bot deep link when CABINET_URL is not configured.
|
||||
"""
|
||||
from urllib.parse import quote
|
||||
|
||||
if not referral_code:
|
||||
raise ValueError('referral_code must not be empty or None')
|
||||
|
||||
safe_code = quote(referral_code, safe='')
|
||||
cabinet_url = (self.CABINET_URL or '').strip().rstrip('/')
|
||||
if cabinet_url and cabinet_url != self._CABINET_URL_DEFAULT:
|
||||
sep = '&' if '?' in cabinet_url else '?'
|
||||
return f'{cabinet_url}{sep}ref={safe_code}'
|
||||
username = bot_username or self.get_bot_username() or 'bot'
|
||||
return f'https://t.me/{username}?start={safe_code}'
|
||||
|
||||
def is_deep_links_enabled(self) -> bool:
|
||||
return self.ENABLE_DEEP_LINKS
|
||||
|
||||
@@ -1486,7 +1541,7 @@ class Settings(BaseSettings):
|
||||
except (ValueError, IndexError):
|
||||
continue
|
||||
|
||||
return packages if packages else self.get_traffic_packages()
|
||||
return packages or self.get_traffic_packages()
|
||||
|
||||
def get_traffic_topup_price(self, gb: int | None) -> int:
|
||||
"""Возвращает цену докупки для указанного количества ГБ."""
|
||||
@@ -1525,8 +1580,8 @@ class Settings(BaseSettings):
|
||||
logger.warning('Некорректное значение DEVICES_SELECTION_DISABLED_AMOUNT', raw_value=raw_value)
|
||||
return None
|
||||
|
||||
if value < 0:
|
||||
return 0
|
||||
if value <= 0:
|
||||
return None
|
||||
|
||||
return value
|
||||
|
||||
@@ -1576,7 +1631,7 @@ class Settings(BaseSettings):
|
||||
|
||||
def get_yookassa_display_name(self) -> str:
|
||||
name = (self.YOOKASSA_DISPLAY_NAME or '').strip()
|
||||
return name if name else 'YooKassa'
|
||||
return name or 'YooKassa'
|
||||
|
||||
def is_nalogo_enabled(self) -> bool:
|
||||
return self.NALOGO_ENABLED and self.NALOGO_INN is not None and self.NALOGO_PASSWORD is not None
|
||||
@@ -1596,14 +1651,14 @@ class Settings(BaseSettings):
|
||||
|
||||
def get_cryptobot_display_name(self) -> str:
|
||||
name = (self.CRYPTOBOT_DISPLAY_NAME or '').strip()
|
||||
return name if name else 'CryptoBot'
|
||||
return name or 'CryptoBot'
|
||||
|
||||
def is_heleket_enabled(self) -> bool:
|
||||
return self.HELEKET_ENABLED and self.HELEKET_MERCHANT_ID is not None and self.HELEKET_API_KEY is not None
|
||||
|
||||
def get_heleket_display_name(self) -> str:
|
||||
name = (self.HELEKET_DISPLAY_NAME or '').strip()
|
||||
return name if name else 'Heleket Crypto'
|
||||
return name or 'Heleket Crypto'
|
||||
|
||||
def is_mulenpay_enabled(self) -> bool:
|
||||
return (
|
||||
@@ -1641,7 +1696,7 @@ class Settings(BaseSettings):
|
||||
|
||||
def get_pal24_display_name(self) -> str:
|
||||
name = (self.PAL24_DISPLAY_NAME or '').strip()
|
||||
return name if name else 'PAL24'
|
||||
return name or 'PAL24'
|
||||
|
||||
def is_platega_enabled(self) -> bool:
|
||||
return self.PLATEGA_ENABLED and self.PLATEGA_MERCHANT_ID is not None and self.PLATEGA_SECRET is not None
|
||||
@@ -1717,11 +1772,11 @@ class Settings(BaseSettings):
|
||||
return info.get('title') or info.get('name') or f'Platega {method_code}'
|
||||
|
||||
def is_wata_enabled(self) -> bool:
|
||||
return self.WATA_ENABLED and self.WATA_ACCESS_TOKEN is not None and self.WATA_TERMINAL_PUBLIC_ID is not None
|
||||
return self.WATA_ENABLED and self.WATA_ACCESS_TOKEN is not None
|
||||
|
||||
def get_wata_display_name(self) -> str:
|
||||
name = (self.WATA_DISPLAY_NAME or '').strip()
|
||||
return name if name else 'Wata'
|
||||
return name or 'Wata'
|
||||
|
||||
def is_cloudpayments_enabled(self) -> bool:
|
||||
return (
|
||||
@@ -1732,7 +1787,7 @@ class Settings(BaseSettings):
|
||||
|
||||
def get_cloudpayments_display_name(self) -> str:
|
||||
name = (self.CLOUDPAYMENTS_DISPLAY_NAME or '').strip()
|
||||
return name if name else 'CloudPayments'
|
||||
return name or 'CloudPayments'
|
||||
|
||||
def is_freekassa_enabled(self) -> bool:
|
||||
return (
|
||||
@@ -1745,11 +1800,31 @@ class Settings(BaseSettings):
|
||||
|
||||
def get_freekassa_display_name(self) -> str:
|
||||
name = (self.FREEKASSA_DISPLAY_NAME or '').strip()
|
||||
return name if name else 'Freekassa'
|
||||
return name or 'Freekassa'
|
||||
|
||||
def get_freekassa_display_name_html(self) -> str:
|
||||
return html.escape(self.get_freekassa_display_name())
|
||||
|
||||
def is_freekassa_sbp_enabled(self) -> bool:
|
||||
return self.FREEKASSA_SBP_ENABLED and self.is_freekassa_enabled()
|
||||
|
||||
def get_freekassa_sbp_display_name(self) -> str:
|
||||
name = (self.FREEKASSA_SBP_DISPLAY_NAME or '').strip()
|
||||
return name or 'СБП (QR код)'
|
||||
|
||||
def get_freekassa_sbp_display_name_html(self) -> str:
|
||||
return html.escape(self.get_freekassa_sbp_display_name())
|
||||
|
||||
def is_freekassa_card_enabled(self) -> bool:
|
||||
return self.FREEKASSA_CARD_ENABLED and self.is_freekassa_enabled()
|
||||
|
||||
def get_freekassa_card_display_name(self) -> str:
|
||||
name = (self.FREEKASSA_CARD_DISPLAY_NAME or '').strip()
|
||||
return name or 'Карта РФ'
|
||||
|
||||
def get_freekassa_card_display_name_html(self) -> str:
|
||||
return html.escape(self.get_freekassa_card_display_name())
|
||||
|
||||
def is_kassa_ai_enabled(self) -> bool:
|
||||
return (
|
||||
self.KASSA_AI_ENABLED
|
||||
@@ -1760,11 +1835,21 @@ class Settings(BaseSettings):
|
||||
|
||||
def get_kassa_ai_display_name(self) -> str:
|
||||
name = (self.KASSA_AI_DISPLAY_NAME or '').strip()
|
||||
return name if name else 'KassaAI'
|
||||
return name or 'KassaAI'
|
||||
|
||||
def get_kassa_ai_display_name_html(self) -> str:
|
||||
return html.escape(self.get_kassa_ai_display_name())
|
||||
|
||||
def is_riopay_enabled(self) -> bool:
|
||||
return self.RIOPAY_ENABLED and self.RIOPAY_API_TOKEN is not None
|
||||
|
||||
def get_riopay_display_name(self) -> str:
|
||||
name = (self.RIOPAY_DISPLAY_NAME or '').strip()
|
||||
return name or 'RioPay'
|
||||
|
||||
def get_riopay_display_name_html(self) -> str:
|
||||
return html.escape(self.get_riopay_display_name())
|
||||
|
||||
def is_payment_verification_auto_check_enabled(self) -> bool:
|
||||
return self.PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED
|
||||
|
||||
@@ -1860,7 +1945,7 @@ class Settings(BaseSettings):
|
||||
'windows': ((self.HAPP_DOWNLOAD_LINK_WINDOWS or '').strip() or (self.HAPP_DOWNLOAD_LINK_PC or '').strip()),
|
||||
}
|
||||
link = links.get(platform_key)
|
||||
return link if link else None
|
||||
return link or None
|
||||
|
||||
def is_maintenance_mode(self) -> bool:
|
||||
return self.MAINTENANCE_MODE
|
||||
@@ -1948,7 +2033,7 @@ class Settings(BaseSettings):
|
||||
# т.к. в режиме classic цена складывается из серверов/трафика/устройств)
|
||||
periods = sorted(allowed_periods)
|
||||
|
||||
return periods if periods else [30, 90, 180]
|
||||
return periods or [30, 90, 180]
|
||||
|
||||
def get_available_renewal_periods(self) -> list[int]:
|
||||
"""
|
||||
@@ -1973,7 +2058,7 @@ class Settings(BaseSettings):
|
||||
# Возвращаем только разрешённые периоды (без фильтрации по цене)
|
||||
periods = sorted(allowed_periods)
|
||||
|
||||
return periods if periods else [30, 90, 180]
|
||||
return periods or [30, 90, 180]
|
||||
|
||||
def get_configured_subscription_periods(self) -> list[int]:
|
||||
"""
|
||||
@@ -2038,7 +2123,7 @@ class Settings(BaseSettings):
|
||||
|
||||
def get_telegram_stars_display_name(self) -> str:
|
||||
name = (self.TELEGRAM_STARS_DISPLAY_NAME or '').strip()
|
||||
return name if name else 'Telegram Stars'
|
||||
return name or 'Telegram Stars'
|
||||
|
||||
def stars_to_rubles(self, stars: int) -> float:
|
||||
return stars * self.get_stars_rate()
|
||||
@@ -2047,7 +2132,7 @@ class Settings(BaseSettings):
|
||||
rate = self.get_stars_rate()
|
||||
if rate <= 0:
|
||||
raise ValueError('Stars rate must be positive')
|
||||
return max(1, math.ceil(rubles / rate))
|
||||
return max(1, round(rubles / rate))
|
||||
|
||||
def get_admin_notifications_chat_id(self) -> int | None:
|
||||
if not self.ADMIN_NOTIFICATIONS_CHAT_ID:
|
||||
@@ -2075,7 +2160,7 @@ class Settings(BaseSettings):
|
||||
|
||||
def get_backup_archive_password(self) -> str | None:
|
||||
password = (self.BACKUP_ARCHIVE_PASSWORD or '').strip()
|
||||
return password if password else None
|
||||
return password or None
|
||||
|
||||
# === Log Rotation Methods ===
|
||||
|
||||
@@ -2156,7 +2241,7 @@ class Settings(BaseSettings):
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
return packages if packages else self._get_fallback_traffic_packages()
|
||||
return packages or self._get_fallback_traffic_packages()
|
||||
|
||||
except Exception as e:
|
||||
logger.warning('ERROR PARSING CONFIG', error=e)
|
||||
@@ -2289,13 +2374,13 @@ class Settings(BaseSettings):
|
||||
|
||||
if contact.startswith(('t.me/', 'telegram.me/', 'telegram.dog/')):
|
||||
url = self.get_support_contact_url()
|
||||
return url if url else contact
|
||||
return url or contact
|
||||
|
||||
contact_without_prefix = contact.lstrip('@')
|
||||
|
||||
if '.' in contact_without_prefix:
|
||||
url = self.get_support_contact_url()
|
||||
return url if url else contact
|
||||
return url or contact
|
||||
|
||||
if re.fullmatch(r'[A-Za-z0-9_]{3,}', contact_without_prefix):
|
||||
return f'@{contact_without_prefix}'
|
||||
@@ -2439,6 +2524,15 @@ class Settings(BaseSettings):
|
||||
def get_cabinet_jwt_secret(self) -> str:
|
||||
if self.CABINET_JWT_SECRET:
|
||||
return self.CABINET_JWT_SECRET
|
||||
import warnings
|
||||
|
||||
warnings.warn(
|
||||
'CABINET_JWT_SECRET is not set, falling back to BOT_TOKEN. '
|
||||
'Set CABINET_JWT_SECRET to a unique secret in production: '
|
||||
'python -c "import secrets; print(secrets.token_urlsafe(64))"',
|
||||
UserWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
return self.BOT_TOKEN
|
||||
|
||||
def get_cabinet_access_token_expire_minutes(self) -> int:
|
||||
@@ -2467,6 +2561,12 @@ class Settings(BaseSettings):
|
||||
def is_cabinet_email_auth_enabled(self) -> bool:
|
||||
return bool(self.CABINET_EMAIL_AUTH_ENABLED)
|
||||
|
||||
def get_cabinet_trusted_proxies(self) -> set[str]:
|
||||
"""Parse CABINET_TRUSTED_PROXIES into a set of IP strings/CIDRs."""
|
||||
if not self.CABINET_TRUSTED_PROXIES:
|
||||
return set()
|
||||
return {p.strip() for p in self.CABINET_TRUSTED_PROXIES.split(',') if p.strip()}
|
||||
|
||||
def is_smtp_configured(self) -> bool:
|
||||
# For servers without AUTH, only host and from_email are required
|
||||
has_from = bool(self.SMTP_FROM_EMAIL or self.SMTP_USER)
|
||||
@@ -2572,18 +2672,25 @@ def get_db_period_prices() -> dict[int, int] | None:
|
||||
return _DB_PERIOD_PRICES
|
||||
|
||||
|
||||
def clear_db_period_prices() -> None:
|
||||
"""Очищает кеш цен из тарифов (при переключении в classic mode)."""
|
||||
global _DB_PERIOD_PRICES
|
||||
_DB_PERIOD_PRICES = None
|
||||
|
||||
|
||||
def refresh_period_prices() -> None:
|
||||
"""
|
||||
Rebuild cached period price mapping.
|
||||
Приоритет: БД > .env
|
||||
В режиме tariffs: приоритет у _DB_PERIOD_PRICES (из таблицы Tariff).
|
||||
В режиме classic: ВСЕГДА используются settings.PRICE_*_DAYS.
|
||||
"""
|
||||
PERIOD_PRICES.clear()
|
||||
|
||||
if _DB_PERIOD_PRICES:
|
||||
# Используем цены из БД
|
||||
if _DB_PERIOD_PRICES and settings.is_tariffs_mode():
|
||||
# Используем цены из БД тарифов (только в режиме tariffs)
|
||||
PERIOD_PRICES.update(_DB_PERIOD_PRICES)
|
||||
else:
|
||||
# Fallback на .env
|
||||
# Classic mode или нет цен в БД — берём из settings
|
||||
PERIOD_PRICES.update(
|
||||
{days: getattr(settings, field_name, 0) for days, field_name in _PERIOD_PRICE_FIELDS.items()}
|
||||
)
|
||||
@@ -2593,6 +2700,25 @@ PERIOD_PRICES: dict[int, int] = {}
|
||||
refresh_period_prices()
|
||||
|
||||
|
||||
def _build_classic_period_prices() -> dict[int, int]:
|
||||
"""Build classic-mode period prices directly from PRICE_*_DAYS settings.
|
||||
|
||||
Unlike PERIOD_PRICES (which may use DB tariff prices in tariffs mode),
|
||||
this always reflects the env/settings values — the canonical prices for
|
||||
classic (non-tariff) subscriptions.
|
||||
"""
|
||||
return {days: getattr(settings, field_name, 0) for days, field_name in _PERIOD_PRICE_FIELDS.items()}
|
||||
|
||||
|
||||
CLASSIC_PERIOD_PRICES: dict[int, int] = _build_classic_period_prices()
|
||||
|
||||
|
||||
def refresh_classic_period_prices() -> None:
|
||||
"""Rebuild CLASSIC_PERIOD_PRICES from current settings."""
|
||||
CLASSIC_PERIOD_PRICES.clear()
|
||||
CLASSIC_PERIOD_PRICES.update(_build_classic_period_prices())
|
||||
|
||||
|
||||
def get_traffic_prices() -> dict[int, int]:
|
||||
packages = settings.get_traffic_packages()
|
||||
return {package['gb']: package['price'] for package in packages}
|
||||
|
||||
@@ -5,6 +5,7 @@ from sqlalchemy import and_, delete, func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.crud.transaction import REAL_PAYMENT_METHODS
|
||||
from app.database.models import (
|
||||
AdvertisingCampaign,
|
||||
AdvertisingCampaignRegistration,
|
||||
@@ -104,9 +105,9 @@ async def get_campaigns_list(
|
||||
stmt = (
|
||||
select(AdvertisingCampaign)
|
||||
.options(
|
||||
selectinload(AdvertisingCampaign.registrations),
|
||||
selectinload(AdvertisingCampaign.tariff),
|
||||
selectinload(AdvertisingCampaign.partner),
|
||||
selectinload(AdvertisingCampaign.registrations),
|
||||
)
|
||||
.order_by(AdvertisingCampaign.created_at.desc())
|
||||
.offset(offset)
|
||||
@@ -148,10 +149,22 @@ async def update_campaign(
|
||||
'partner_user_id',
|
||||
}
|
||||
|
||||
nullable_fields = {
|
||||
'partner_user_id',
|
||||
'tariff_id',
|
||||
'subscription_duration_days',
|
||||
'subscription_traffic_gb',
|
||||
'subscription_device_limit',
|
||||
'tariff_duration_days',
|
||||
}
|
||||
|
||||
update_data = {}
|
||||
for key, value in kwargs.items():
|
||||
if key in allowed_fields and value is not None:
|
||||
update_data[key] = value
|
||||
if key not in allowed_fields:
|
||||
continue
|
||||
if value is None and key not in nullable_fields:
|
||||
continue
|
||||
update_data[key] = value
|
||||
|
||||
if not update_data:
|
||||
return campaign
|
||||
@@ -256,11 +269,13 @@ async def get_campaign_statistics(
|
||||
)
|
||||
subscription_bonuses_issued = subscription_count_result.scalar() or 0
|
||||
|
||||
# Only count real deposits (exclude promo bonuses, wheel prizes, admin top-ups)
|
||||
deposits_result = await db.execute(
|
||||
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
|
||||
Transaction.user_id.in_(select(registrations_subquery.c.user_id)),
|
||||
Transaction.type == TransactionType.DEPOSIT.value,
|
||||
Transaction.is_completed.is_(True),
|
||||
Transaction.payment_method.in_(REAL_PAYMENT_METHODS),
|
||||
)
|
||||
)
|
||||
deposits_total = deposits_result.scalar() or 0
|
||||
@@ -351,7 +366,8 @@ async def get_campaign_statistics(
|
||||
first_payment_amount_by_user[user_id] = amount_value
|
||||
first_payment_time_by_user[user_id] = created_at
|
||||
|
||||
total_revenue = deposits_total + subscription_payments_total
|
||||
# Revenue = only real deposits (exclude bonus-funded subscription spending)
|
||||
total_revenue = deposits_total
|
||||
|
||||
paid_user_ids = set(paid_users_from_transactions)
|
||||
paid_user_ids.update(conversion_user_ids)
|
||||
|
||||
@@ -18,7 +18,7 @@ logger = structlog.get_logger(__name__)
|
||||
async def create_cloudpayments_payment(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int,
|
||||
user_id: int | None,
|
||||
invoice_id: str,
|
||||
amount_kopeks: int,
|
||||
description: str | None = None,
|
||||
@@ -92,6 +92,16 @@ async def get_cloudpayments_payment_by_id(
|
||||
return result.scalars().first()
|
||||
|
||||
|
||||
async def get_cloudpayments_payment_by_id_for_update(
|
||||
db: AsyncSession,
|
||||
payment_id: int,
|
||||
) -> CloudPaymentsPayment | None:
|
||||
result = await db.execute(
|
||||
select(CloudPaymentsPayment).where(CloudPaymentsPayment.id == payment_id).with_for_update()
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_cloudpayments_payment_by_transaction_id(
|
||||
db: AsyncSession,
|
||||
transaction_id_cp: int,
|
||||
|
||||
@@ -13,7 +13,7 @@ logger = structlog.get_logger(__name__)
|
||||
|
||||
async def create_cryptobot_payment(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
user_id: int | None,
|
||||
invoice_id: str,
|
||||
amount: str,
|
||||
asset: str,
|
||||
@@ -67,8 +67,18 @@ async def get_cryptobot_payment_by_id(db: AsyncSession, payment_id: int) -> Cryp
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_cryptobot_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> CryptoBotPayment | None:
|
||||
result = await db.execute(select(CryptoBotPayment).where(CryptoBotPayment.id == payment_id).with_for_update())
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def update_cryptobot_payment_status(
|
||||
db: AsyncSession, invoice_id: str, status: str, paid_at: datetime | None = None
|
||||
db: AsyncSession,
|
||||
invoice_id: str,
|
||||
status: str,
|
||||
paid_at: datetime | None = None,
|
||||
*,
|
||||
commit: bool = True,
|
||||
) -> CryptoBotPayment | None:
|
||||
payment = await get_cryptobot_payment_by_invoice_id(db, invoice_id)
|
||||
|
||||
@@ -81,8 +91,11 @@ async def update_cryptobot_payment_status(
|
||||
if status == 'paid' and paid_at:
|
||||
payment.paid_at = paid_at
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
if commit:
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
else:
|
||||
await db.flush()
|
||||
|
||||
logger.info('Обновлен статус CryptoBot платежа', invoice_id=invoice_id, status=status)
|
||||
return payment
|
||||
@@ -99,7 +112,7 @@ async def link_cryptobot_payment_to_transaction(
|
||||
payment.transaction_id = transaction_id
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
|
||||
await db.commit()
|
||||
await db.flush()
|
||||
await db.refresh(payment)
|
||||
|
||||
logger.info('Связан CryptoBot платеж с транзакцией', invoice_id=invoice_id, transaction_id=transaction_id)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""CRUD операции для платежей Freekassa."""
|
||||
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
@@ -16,14 +15,14 @@ logger = structlog.get_logger(__name__)
|
||||
async def create_freekassa_payment(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int,
|
||||
user_id: int | None,
|
||||
order_id: str,
|
||||
amount_kopeks: int,
|
||||
currency: str = 'RUB',
|
||||
description: str | None = None,
|
||||
payment_url: str | None = None,
|
||||
expires_at: datetime | None = None,
|
||||
metadata_json: str | None = None,
|
||||
metadata_json: dict | None = None,
|
||||
) -> FreekassaPayment:
|
||||
"""Создает запись о платеже Freekassa."""
|
||||
payment = FreekassaPayment(
|
||||
@@ -34,7 +33,7 @@ async def create_freekassa_payment(
|
||||
description=description,
|
||||
payment_url=payment_url,
|
||||
expires_at=expires_at,
|
||||
metadata_json=json.loads(metadata_json) if metadata_json else None,
|
||||
metadata_json=metadata_json,
|
||||
status='pending',
|
||||
is_paid=False,
|
||||
)
|
||||
@@ -63,6 +62,11 @@ async def get_freekassa_payment_by_id(db: AsyncSession, payment_id: int) -> Free
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_freekassa_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> FreekassaPayment | None:
|
||||
result = await db.execute(select(FreekassaPayment).where(FreekassaPayment.id == payment_id).with_for_update())
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def update_freekassa_payment_status(
|
||||
db: AsyncSession,
|
||||
payment: FreekassaPayment,
|
||||
|
||||
@@ -15,7 +15,7 @@ logger = structlog.get_logger(__name__)
|
||||
async def create_heleket_payment(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int,
|
||||
user_id: int | None,
|
||||
uuid: str,
|
||||
order_id: str,
|
||||
amount: str,
|
||||
@@ -91,6 +91,11 @@ async def get_heleket_payment_by_id(
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_heleket_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> HeleketPayment | None:
|
||||
result = await db.execute(select(HeleketPayment).where(HeleketPayment.id == payment_id).with_for_update())
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def update_heleket_payment(
|
||||
db: AsyncSession,
|
||||
uuid: str,
|
||||
@@ -159,7 +164,7 @@ async def link_heleket_payment_to_transaction(
|
||||
payment.transaction_id = transaction_id
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
|
||||
await db.commit()
|
||||
await db.flush()
|
||||
await db.refresh(payment)
|
||||
|
||||
logger.info('Heleket платеж связан с транзакцией', uuid=uuid, transaction_id=transaction_id)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""CRUD операции для платежей KassaAI."""
|
||||
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
@@ -16,7 +15,7 @@ logger = structlog.get_logger(__name__)
|
||||
async def create_kassa_ai_payment(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int,
|
||||
user_id: int | None,
|
||||
order_id: str,
|
||||
amount_kopeks: int,
|
||||
currency: str = 'RUB',
|
||||
@@ -24,7 +23,7 @@ async def create_kassa_ai_payment(
|
||||
payment_url: str | None = None,
|
||||
payment_system_id: int | None = None,
|
||||
expires_at: datetime | None = None,
|
||||
metadata_json: str | None = None,
|
||||
metadata_json: dict | None = None,
|
||||
) -> KassaAiPayment:
|
||||
"""Создает запись о платеже KassaAI."""
|
||||
payment = KassaAiPayment(
|
||||
@@ -36,7 +35,7 @@ async def create_kassa_ai_payment(
|
||||
payment_url=payment_url,
|
||||
payment_system_id=payment_system_id,
|
||||
expires_at=expires_at,
|
||||
metadata_json=json.loads(metadata_json) if metadata_json else None,
|
||||
metadata_json=metadata_json,
|
||||
status='pending',
|
||||
is_paid=False,
|
||||
)
|
||||
@@ -65,6 +64,11 @@ async def get_kassa_ai_payment_by_id(db: AsyncSession, payment_id: int) -> Kassa
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_kassa_ai_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> KassaAiPayment | None:
|
||||
result = await db.execute(select(KassaAiPayment).where(KassaAiPayment.id == payment_id).with_for_update())
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def update_kassa_ai_payment_status(
|
||||
db: AsyncSession,
|
||||
payment: KassaAiPayment,
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
import secrets
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import GuestPurchase, GuestPurchaseStatus, LandingPage
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def get_landing_by_slug(db: AsyncSession, slug: str) -> LandingPage | None:
|
||||
"""Get a landing page by its slug."""
|
||||
result = await db.execute(select(LandingPage).where(LandingPage.slug == slug))
|
||||
return result.scalars().first()
|
||||
|
||||
|
||||
async def get_landing_by_id(db: AsyncSession, landing_id: int) -> LandingPage | None:
|
||||
"""Get a landing page by its ID."""
|
||||
result = await db.execute(select(LandingPage).where(LandingPage.id == landing_id))
|
||||
return result.scalars().first()
|
||||
|
||||
|
||||
async def get_active_landing_by_slug(db: AsyncSession, slug: str) -> LandingPage | None:
|
||||
"""Get an active landing page by its slug."""
|
||||
result = await db.execute(
|
||||
select(LandingPage).where(
|
||||
LandingPage.slug == slug,
|
||||
LandingPage.is_active.is_(True),
|
||||
)
|
||||
)
|
||||
return result.scalars().first()
|
||||
|
||||
|
||||
async def get_all_landings(db: AsyncSession) -> list[LandingPage]:
|
||||
"""Get all landing pages ordered by display_order."""
|
||||
result = await db.execute(select(LandingPage).order_by(LandingPage.display_order, LandingPage.id))
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def create_landing(db: AsyncSession, **kwargs) -> LandingPage:
|
||||
"""Create a new landing page."""
|
||||
landing = LandingPage(**kwargs)
|
||||
db.add(landing)
|
||||
await db.flush()
|
||||
await db.commit()
|
||||
await db.refresh(landing)
|
||||
logger.info(
|
||||
'Created landing page',
|
||||
slug=landing.slug,
|
||||
landing_id=landing.id,
|
||||
)
|
||||
return landing
|
||||
|
||||
|
||||
_LANDING_UPDATABLE_FIELDS = frozenset(
|
||||
{
|
||||
'slug',
|
||||
'title',
|
||||
'subtitle',
|
||||
'is_active',
|
||||
'features',
|
||||
'footer_text',
|
||||
'allowed_tariff_ids',
|
||||
'allowed_periods',
|
||||
'payment_methods',
|
||||
'gift_enabled',
|
||||
'custom_css',
|
||||
'meta_title',
|
||||
'meta_description',
|
||||
'display_order',
|
||||
'discount_percent',
|
||||
'discount_overrides',
|
||||
'discount_starts_at',
|
||||
'discount_ends_at',
|
||||
'discount_badge_text',
|
||||
'background_config',
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def update_landing(db: AsyncSession, landing_id: int, data: dict) -> LandingPage | None:
|
||||
"""Update a landing page by ID. Returns None if not found."""
|
||||
landing = await get_landing_by_id(db, landing_id)
|
||||
if landing is None:
|
||||
return None
|
||||
|
||||
for key, value in data.items():
|
||||
if key in _LANDING_UPDATABLE_FIELDS:
|
||||
setattr(landing, key, value)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(landing)
|
||||
logger.info(
|
||||
'Updated landing page',
|
||||
landing_id=landing.id,
|
||||
slug=landing.slug,
|
||||
updated_fields=list(data.keys()),
|
||||
)
|
||||
return landing
|
||||
|
||||
|
||||
async def delete_landing(db: AsyncSession, landing_id: int) -> bool:
|
||||
"""Delete a landing page by ID. Returns True if deleted."""
|
||||
landing = await get_landing_by_id(db, landing_id)
|
||||
if landing is None:
|
||||
return False
|
||||
|
||||
await db.delete(landing)
|
||||
await db.commit()
|
||||
logger.info(
|
||||
'Deleted landing page',
|
||||
landing_id=landing_id,
|
||||
slug=landing.slug,
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
async def update_landing_order(db: AsyncSession, landing_ids: list[int]) -> None:
|
||||
"""Set display_order for landing pages based on position in list."""
|
||||
for order, landing_id in enumerate(landing_ids):
|
||||
await db.execute(update(LandingPage).where(LandingPage.id == landing_id).values(display_order=order))
|
||||
await db.commit()
|
||||
logger.info('Updated landing page order', landing_ids=landing_ids)
|
||||
|
||||
|
||||
def generate_purchase_token() -> str:
|
||||
"""Generate a cryptographically secure purchase token."""
|
||||
return secrets.token_urlsafe(48)
|
||||
|
||||
|
||||
async def create_guest_purchase(db: AsyncSession, *, commit: bool = True, **kwargs) -> GuestPurchase:
|
||||
"""Create a new guest purchase with an auto-generated token."""
|
||||
if 'token' not in kwargs:
|
||||
kwargs['token'] = generate_purchase_token()
|
||||
|
||||
purchase = GuestPurchase(**kwargs)
|
||||
db.add(purchase)
|
||||
await db.flush()
|
||||
if commit:
|
||||
await db.commit()
|
||||
await db.refresh(purchase)
|
||||
logger.info(
|
||||
'Created guest purchase',
|
||||
purchase_id=purchase.id,
|
||||
token_prefix=purchase.token[:5],
|
||||
status=purchase.status,
|
||||
landing_id=purchase.landing_id,
|
||||
)
|
||||
return purchase
|
||||
|
||||
|
||||
async def get_purchase_by_token(db: AsyncSession, token: str) -> GuestPurchase | None:
|
||||
"""Get a guest purchase by its token."""
|
||||
result = await db.execute(select(GuestPurchase).where(GuestPurchase.token == token))
|
||||
return result.scalars().first()
|
||||
|
||||
|
||||
_PURCHASE_UPDATABLE_FIELDS = frozenset(
|
||||
{
|
||||
'payment_id',
|
||||
'paid_at',
|
||||
'delivered_at',
|
||||
'subscription_url',
|
||||
'subscription_crypto_link',
|
||||
'user_id',
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def update_purchase_status(
|
||||
db: AsyncSession,
|
||||
token: str,
|
||||
status: GuestPurchaseStatus | str,
|
||||
*,
|
||||
commit: bool = True,
|
||||
**extra_fields,
|
||||
) -> GuestPurchase | None:
|
||||
"""Update the status of a guest purchase and optional extra fields."""
|
||||
purchase = await get_purchase_by_token(db, token)
|
||||
if purchase is None:
|
||||
return None
|
||||
|
||||
old_status = purchase.status
|
||||
purchase.status = status.value if isinstance(status, GuestPurchaseStatus) else status
|
||||
|
||||
for key, value in extra_fields.items():
|
||||
if key not in _PURCHASE_UPDATABLE_FIELDS:
|
||||
logger.warning('Ignoring disallowed field in purchase update', field=key)
|
||||
continue
|
||||
setattr(purchase, key, value)
|
||||
|
||||
if commit:
|
||||
await db.commit()
|
||||
await db.refresh(purchase)
|
||||
else:
|
||||
await db.flush()
|
||||
|
||||
logger.info(
|
||||
'Updated guest purchase status',
|
||||
purchase_id=purchase.id,
|
||||
token_prefix=token[:5],
|
||||
old_status=old_status,
|
||||
new_status=purchase.status,
|
||||
)
|
||||
return purchase
|
||||
|
||||
|
||||
async def get_landing_purchase_stats(db: AsyncSession, landing_id: int) -> dict:
|
||||
"""Get purchase counts grouped by status for a landing page."""
|
||||
result = await db.execute(
|
||||
select(
|
||||
GuestPurchase.status,
|
||||
func.count(GuestPurchase.id),
|
||||
)
|
||||
.where(GuestPurchase.landing_id == landing_id)
|
||||
.group_by(GuestPurchase.status)
|
||||
)
|
||||
rows = result.all()
|
||||
|
||||
stats = {s.value: 0 for s in GuestPurchaseStatus}
|
||||
stats['total'] = 0
|
||||
for status_value, count in rows:
|
||||
stats[status_value] = count
|
||||
stats['total'] += count
|
||||
|
||||
return stats
|
||||
|
||||
|
||||
async def get_all_landing_purchase_stats(db: AsyncSession) -> dict[int, dict]:
|
||||
"""Get purchase counts grouped by landing_id and status in a single query.
|
||||
|
||||
Returns a dict mapping landing_id -> {status: count, 'total': count}.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(
|
||||
GuestPurchase.landing_id,
|
||||
GuestPurchase.status,
|
||||
func.count(GuestPurchase.id),
|
||||
)
|
||||
.where(GuestPurchase.landing_id.is_not(None))
|
||||
.group_by(GuestPurchase.landing_id, GuestPurchase.status)
|
||||
)
|
||||
rows = result.all()
|
||||
|
||||
all_stats: dict[int, dict] = {}
|
||||
for landing_id, status_value, count in rows:
|
||||
if landing_id not in all_stats:
|
||||
stats = {s.value: 0 for s in GuestPurchaseStatus}
|
||||
stats['total'] = 0
|
||||
all_stats[landing_id] = stats
|
||||
all_stats[landing_id][status_value] = count
|
||||
all_stats[landing_id]['total'] += count
|
||||
|
||||
return all_stats
|
||||
@@ -14,7 +14,7 @@ logger = structlog.get_logger(__name__)
|
||||
async def create_mulenpay_payment(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int,
|
||||
user_id: int | None,
|
||||
amount_kopeks: int,
|
||||
uuid: str,
|
||||
description: str,
|
||||
@@ -57,6 +57,11 @@ async def get_mulenpay_payment_by_local_id(db: AsyncSession, payment_id: int) ->
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_mulenpay_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> MulenPayPayment | None:
|
||||
result = await db.execute(select(MulenPayPayment).where(MulenPayPayment.id == payment_id).with_for_update())
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_mulenpay_payment_by_uuid(db: AsyncSession, uuid: str) -> MulenPayPayment | None:
|
||||
result = await db.execute(select(MulenPayPayment).where(MulenPayPayment.uuid == uuid))
|
||||
return result.scalar_one_or_none()
|
||||
@@ -117,6 +122,6 @@ async def link_mulenpay_payment_to_transaction(
|
||||
) -> MulenPayPayment:
|
||||
payment.transaction_id = transaction_id
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
await db.flush()
|
||||
await db.refresh(payment)
|
||||
return payment
|
||||
|
||||
@@ -48,9 +48,10 @@ async def record_notification(
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def clear_notifications(db: AsyncSession, subscription_id: int) -> None:
|
||||
async def clear_notifications(db: AsyncSession, subscription_id: int, *, commit: bool = True) -> None:
|
||||
await db.execute(delete(SentNotification).where(SentNotification.subscription_id == subscription_id))
|
||||
await db.commit()
|
||||
if commit:
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def clear_notification_by_type(
|
||||
|
||||
@@ -18,7 +18,7 @@ logger = structlog.get_logger(__name__)
|
||||
async def create_pal24_payment(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int,
|
||||
user_id: int | None,
|
||||
bill_id: str,
|
||||
amount_kopeks: int,
|
||||
description: str | None,
|
||||
@@ -66,6 +66,11 @@ async def get_pal24_payment_by_id(db: AsyncSession, payment_id: int) -> Pal24Pay
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_pal24_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> Pal24Payment | None:
|
||||
result = await db.execute(select(Pal24Payment).where(Pal24Payment.id == payment_id).with_for_update())
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_pal24_payment_by_bill_id(db: AsyncSession, bill_id: str) -> Pal24Payment | None:
|
||||
result = await db.execute(select(Pal24Payment).where(Pal24Payment.bill_id == bill_id))
|
||||
return result.scalar_one_or_none()
|
||||
@@ -143,7 +148,7 @@ async def link_pal24_payment_to_transaction(
|
||||
transaction_id: int,
|
||||
) -> Pal24Payment:
|
||||
await db.execute(update(Pal24Payment).where(Pal24Payment.id == payment.id).values(transaction_id=transaction_id))
|
||||
await db.commit()
|
||||
await db.flush()
|
||||
await db.refresh(payment)
|
||||
logger.info('Pal24 платеж привязан к транзакции', bill_id=payment.bill_id, transaction_id=transaction_id)
|
||||
return payment
|
||||
|
||||
@@ -18,7 +18,7 @@ logger = structlog.get_logger(__name__)
|
||||
async def create_platega_payment(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int,
|
||||
user_id: int | None,
|
||||
amount_kopeks: int,
|
||||
currency: str,
|
||||
description: str | None,
|
||||
@@ -130,6 +130,6 @@ async def link_platega_payment_to_transaction(
|
||||
) -> PlategaPayment:
|
||||
payment.transaction_id = transaction_id
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
await db.flush()
|
||||
await db.refresh(payment)
|
||||
return payment
|
||||
|
||||
@@ -97,9 +97,9 @@ async def create_promo_group(
|
||||
) -> PromoGroup:
|
||||
normalized_period_discounts = _normalize_period_discounts(period_discounts)
|
||||
|
||||
auto_assign_total_spent_kopeks = (
|
||||
max(0, auto_assign_total_spent_kopeks) if auto_assign_total_spent_kopeks is not None else None
|
||||
)
|
||||
if auto_assign_total_spent_kopeks is not None:
|
||||
value = max(0, auto_assign_total_spent_kopeks)
|
||||
auto_assign_total_spent_kopeks = value if value > 0 else None
|
||||
|
||||
existing_default = await get_default_promo_group(db)
|
||||
should_be_default = existing_default is None or is_default
|
||||
@@ -168,7 +168,8 @@ async def update_promo_group(
|
||||
normalized_period_discounts = _normalize_period_discounts(period_discounts)
|
||||
group.period_discounts = normalized_period_discounts or None
|
||||
if auto_assign_total_spent_kopeks is not None:
|
||||
group.auto_assign_total_spent_kopeks = max(0, auto_assign_total_spent_kopeks)
|
||||
value = max(0, auto_assign_total_spent_kopeks)
|
||||
group.auto_assign_total_spent_kopeks = value if value > 0 else None
|
||||
if apply_discounts_to_addons is not None:
|
||||
group.apply_discounts_to_addons = bool(apply_discounts_to_addons)
|
||||
|
||||
|
||||
@@ -43,6 +43,8 @@ async def log_promo_offer_action(
|
||||
except Exception:
|
||||
logger.exception('Failed to commit promo offer log entry')
|
||||
raise
|
||||
else:
|
||||
await db.flush()
|
||||
|
||||
return entry
|
||||
|
||||
|
||||
@@ -84,28 +84,6 @@ async def create_promocode(
|
||||
return promocode
|
||||
|
||||
|
||||
async def use_promocode(db: AsyncSession, promocode_id: int, user_id: int) -> bool:
|
||||
try:
|
||||
promocode = await get_promocode_by_id(db, promocode_id)
|
||||
if not promocode:
|
||||
return False
|
||||
|
||||
usage = PromoCodeUse(promocode_id=promocode_id, user_id=user_id)
|
||||
db.add(usage)
|
||||
|
||||
promocode.current_uses += 1
|
||||
|
||||
await db.commit()
|
||||
|
||||
logger.info('✅ Промокод использован пользователем', code=promocode.code, user_id=user_id)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Ошибка использования промокода', error=e)
|
||||
await db.rollback()
|
||||
return False
|
||||
|
||||
|
||||
async def check_user_promocode_usage(db: AsyncSession, user_id: int, promocode_id: int) -> bool:
|
||||
result = await db.execute(
|
||||
select(PromoCodeUse).where(and_(PromoCodeUse.user_id == user_id, PromoCodeUse.promocode_id == promocode_id))
|
||||
@@ -113,12 +91,22 @@ async def check_user_promocode_usage(db: AsyncSession, user_id: int, promocode_i
|
||||
return result.scalar_one_or_none() is not None
|
||||
|
||||
|
||||
async def create_promocode_use(db: AsyncSession, promocode_id: int, user_id: int) -> PromoCodeUse:
|
||||
async def create_promocode_use(db: AsyncSession, promocode_id: int, user_id: int) -> PromoCodeUse | None:
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
promocode_use = PromoCodeUse(promocode_id=promocode_id, user_id=user_id, used_at=datetime.now(UTC))
|
||||
|
||||
db.add(promocode_use)
|
||||
await db.commit()
|
||||
await db.refresh(promocode_use)
|
||||
try:
|
||||
async with db.begin_nested():
|
||||
db.add(promocode_use)
|
||||
await db.flush()
|
||||
except IntegrityError:
|
||||
logger.warning(
|
||||
'⚠️ Дублирующая запись использования промокода (race condition)',
|
||||
promocode_id=promocode_id,
|
||||
user_id=user_id,
|
||||
)
|
||||
return None
|
||||
|
||||
logger.info('📝 Записано использование промокода пользователем', promocode_id=promocode_id, user_id=user_id)
|
||||
return promocode_use
|
||||
|
||||
@@ -5,7 +5,7 @@ from sqlalchemy import and_, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import AdvertisingCampaignRegistration, ReferralEarning, User
|
||||
from app.database.models import AdvertisingCampaignRegistration, ReferralEarning, Subscription, SubscriptionStatus, User
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
@@ -50,6 +50,20 @@ async def create_referral_earning(
|
||||
return earning
|
||||
|
||||
|
||||
async def get_commission_payment_count(db: AsyncSession, referrer_id: int, referral_id: int) -> int:
|
||||
"""Подсчитать количество комиссионных начислений реферера за платежи конкретного реферала."""
|
||||
result = await db.execute(
|
||||
select(func.count(ReferralEarning.id)).where(
|
||||
and_(
|
||||
ReferralEarning.user_id == referrer_id,
|
||||
ReferralEarning.referral_id == referral_id,
|
||||
ReferralEarning.reason == 'referral_commission_topup',
|
||||
)
|
||||
)
|
||||
)
|
||||
return result.scalar() or 0
|
||||
|
||||
|
||||
async def get_referral_earnings_by_user(
|
||||
db: AsyncSession, user_id: int, limit: int = 50, offset: int = 0
|
||||
) -> list[ReferralEarning]:
|
||||
@@ -89,7 +103,7 @@ async def get_referral_earnings_sum(
|
||||
query = query.where(ReferralEarning.created_at <= end_date)
|
||||
|
||||
result = await db.execute(query)
|
||||
return result.scalar()
|
||||
return result.scalar() or 0
|
||||
|
||||
|
||||
async def get_referral_statistics(db: AsyncSession) -> dict:
|
||||
@@ -104,18 +118,7 @@ async def get_referral_statistics(db: AsyncSession) -> dict:
|
||||
active_referrers = active_referrers_result.scalar()
|
||||
|
||||
referral_paid_result = await db.execute(select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0)))
|
||||
referral_paid = referral_paid_result.scalar()
|
||||
|
||||
from app.database.models import Transaction, TransactionType
|
||||
|
||||
transaction_paid_result = await db.execute(
|
||||
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
|
||||
Transaction.type == TransactionType.REFERRAL_REWARD.value
|
||||
)
|
||||
)
|
||||
transaction_paid = transaction_paid_result.scalar()
|
||||
|
||||
total_paid = referral_paid + transaction_paid
|
||||
total_paid = referral_paid_result.scalar()
|
||||
|
||||
referrals_stats_result = await db.execute(
|
||||
select(User.referred_by_id.label('referrer_id'), func.count(User.id).label('referrals_count'))
|
||||
@@ -132,15 +135,6 @@ async def get_referral_statistics(db: AsyncSession) -> dict:
|
||||
)
|
||||
referral_earnings = {row.referrer_id: row.referral_earnings for row in referral_earnings_result.all()}
|
||||
|
||||
transaction_earnings_result = await db.execute(
|
||||
select(
|
||||
Transaction.user_id.label('referrer_id'), func.sum(Transaction.amount_kopeks).label('transaction_earnings')
|
||||
)
|
||||
.where(Transaction.type == TransactionType.REFERRAL_REWARD.value)
|
||||
.group_by(Transaction.user_id)
|
||||
)
|
||||
transaction_earnings = {row.referrer_id: row.transaction_earnings for row in transaction_earnings_result.all()}
|
||||
|
||||
top_referrers_data = {}
|
||||
|
||||
for referrer_id, count in referrals_stats.items():
|
||||
@@ -153,11 +147,6 @@ async def get_referral_statistics(db: AsyncSession) -> dict:
|
||||
top_referrers_data[referrer_id] = {'referrals_count': 0, 'total_earned': 0}
|
||||
top_referrers_data[referrer_id]['total_earned'] += earnings or 0
|
||||
|
||||
for referrer_id, earnings in transaction_earnings.items():
|
||||
if referrer_id not in top_referrers_data:
|
||||
top_referrers_data[referrer_id] = {'referrals_count': 0, 'total_earned': 0}
|
||||
top_referrers_data[referrer_id]['total_earned'] += earnings or 0
|
||||
|
||||
sorted_referrers = sorted(
|
||||
top_referrers_data.items(), key=lambda x: (x[1]['total_earned'], x[1]['referrals_count']), reverse=True
|
||||
)
|
||||
@@ -197,37 +186,22 @@ async def get_referral_statistics(db: AsyncSession) -> dict:
|
||||
|
||||
today = datetime.now(UTC).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
today_referral_earnings_result = await db.execute(
|
||||
today_earnings_result = await db.execute(
|
||||
select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0)).where(ReferralEarning.created_at >= today)
|
||||
)
|
||||
today_transaction_earnings_result = await db.execute(
|
||||
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
|
||||
and_(Transaction.type == TransactionType.REFERRAL_REWARD.value, Transaction.created_at >= today)
|
||||
)
|
||||
)
|
||||
today_earnings = today_referral_earnings_result.scalar() + today_transaction_earnings_result.scalar()
|
||||
today_earnings = today_earnings_result.scalar()
|
||||
|
||||
week_ago = datetime.now(UTC) - timedelta(days=7)
|
||||
week_referral_earnings_result = await db.execute(
|
||||
week_earnings_result = await db.execute(
|
||||
select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0)).where(ReferralEarning.created_at >= week_ago)
|
||||
)
|
||||
week_transaction_earnings_result = await db.execute(
|
||||
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
|
||||
and_(Transaction.type == TransactionType.REFERRAL_REWARD.value, Transaction.created_at >= week_ago)
|
||||
)
|
||||
)
|
||||
week_earnings = week_referral_earnings_result.scalar() + week_transaction_earnings_result.scalar()
|
||||
week_earnings = week_earnings_result.scalar()
|
||||
|
||||
month_ago = datetime.now(UTC) - timedelta(days=30)
|
||||
month_referral_earnings_result = await db.execute(
|
||||
month_earnings_result = await db.execute(
|
||||
select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0)).where(ReferralEarning.created_at >= month_ago)
|
||||
)
|
||||
month_transaction_earnings_result = await db.execute(
|
||||
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
|
||||
and_(Transaction.type == TransactionType.REFERRAL_REWARD.value, Transaction.created_at >= month_ago)
|
||||
)
|
||||
)
|
||||
month_earnings = month_referral_earnings_result.scalar() + month_transaction_earnings_result.scalar()
|
||||
month_earnings = month_earnings_result.scalar()
|
||||
|
||||
logger.info(
|
||||
'Реферальная статистика: рефералов, рефереров, выплачено копеек',
|
||||
@@ -264,8 +238,6 @@ async def get_top_referrers_by_period(
|
||||
Returns:
|
||||
Список словарей с данными рефереров
|
||||
"""
|
||||
from app.database.models import Transaction, TransactionType
|
||||
|
||||
now = datetime.now(UTC)
|
||||
if period == 'week':
|
||||
start_date = now - timedelta(days=7)
|
||||
@@ -292,18 +264,6 @@ async def get_top_referrers_by_period(
|
||||
)
|
||||
earnings = earnings_result.scalar() or 0
|
||||
|
||||
# Добавляем транзакции REFERRAL_REWARD
|
||||
trans_earnings_result = await db.execute(
|
||||
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
|
||||
and_(
|
||||
Transaction.user_id == row.referrer_id,
|
||||
Transaction.type == TransactionType.REFERRAL_REWARD.value,
|
||||
Transaction.created_at >= start_date,
|
||||
)
|
||||
)
|
||||
)
|
||||
earnings += trans_earnings_result.scalar() or 0
|
||||
|
||||
top_data.append(
|
||||
{'referrer_id': row.referrer_id, 'invited_count': row.invited_count, 'earnings_kopeks': earnings}
|
||||
)
|
||||
@@ -320,27 +280,8 @@ async def get_top_referrers_by_period(
|
||||
)
|
||||
referral_earnings = {row.referrer_id: row.ref_earnings for row in referral_earnings_result}
|
||||
|
||||
# Добавляем транзакции REFERRAL_REWARD
|
||||
transaction_earnings_result = await db.execute(
|
||||
select(
|
||||
Transaction.user_id.label('referrer_id'), func.sum(Transaction.amount_kopeks).label('trans_earnings')
|
||||
)
|
||||
.where(
|
||||
and_(Transaction.type == TransactionType.REFERRAL_REWARD.value, Transaction.created_at >= start_date)
|
||||
)
|
||||
.group_by(Transaction.user_id)
|
||||
)
|
||||
|
||||
# Объединяем заработки
|
||||
combined_earnings = dict(referral_earnings)
|
||||
for row in transaction_earnings_result:
|
||||
if row.referrer_id in combined_earnings:
|
||||
combined_earnings[row.referrer_id] += row.trans_earnings or 0
|
||||
else:
|
||||
combined_earnings[row.referrer_id] = row.trans_earnings or 0
|
||||
|
||||
# Сортируем и берём топ
|
||||
sorted_referrers = sorted(combined_earnings.items(), key=lambda x: x[1], reverse=True)[:limit]
|
||||
sorted_referrers = sorted(referral_earnings.items(), key=lambda x: x[1], reverse=True)[:limit]
|
||||
|
||||
top_data = []
|
||||
for referrer_id, earnings in sorted_referrers:
|
||||
@@ -400,22 +341,18 @@ async def get_user_referral_stats(db: AsyncSession, user_id: int) -> dict:
|
||||
month_ago = datetime.now(UTC) - timedelta(days=30)
|
||||
month_earned = await get_referral_earnings_sum(db, user_id, start_date=month_ago)
|
||||
|
||||
from app.database.models import Subscription, SubscriptionStatus
|
||||
|
||||
current_time = datetime.now(UTC)
|
||||
|
||||
active_referrals_result = await db.execute(
|
||||
select(func.count(User.id))
|
||||
select(func.count(func.distinct(User.id)))
|
||||
.join(Subscription, User.id == Subscription.user_id)
|
||||
.where(
|
||||
and_(
|
||||
User.referred_by_id == user_id,
|
||||
Subscription.status == SubscriptionStatus.ACTIVE.value,
|
||||
Subscription.end_date > current_time,
|
||||
Subscription.end_date > func.now(),
|
||||
)
|
||||
)
|
||||
)
|
||||
active_referrals = active_referrals_result.scalar()
|
||||
active_referrals = active_referrals_result.scalar() or 0
|
||||
|
||||
return {
|
||||
'invited_count': invited_count,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from collections.abc import Sequence
|
||||
from datetime import UTC, date, datetime, time
|
||||
from datetime import UTC, date, datetime, time, timedelta
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import and_, desc, func, select
|
||||
@@ -120,18 +120,29 @@ async def get_contests_for_events(
|
||||
*,
|
||||
contest_types: list[str] | None = None,
|
||||
) -> list[ReferralContest]:
|
||||
# Расширяем SQL-фильтр на 1 день для полночных end_at (нормализуются в 23:59:59)
|
||||
query = select(ReferralContest).where(
|
||||
and_(
|
||||
ReferralContest.is_active.is_(True),
|
||||
ReferralContest.start_at <= now_utc,
|
||||
ReferralContest.end_at >= now_utc,
|
||||
ReferralContest.end_at >= now_utc - timedelta(days=1),
|
||||
)
|
||||
)
|
||||
if contest_types:
|
||||
query = query.where(ReferralContest.contest_type.in_(contest_types))
|
||||
|
||||
result = await db.execute(query)
|
||||
return list(result.scalars().all())
|
||||
contests = list(result.scalars().all())
|
||||
|
||||
# Точная фильтрация с нормализацией полночных end_at
|
||||
filtered = []
|
||||
for contest in contests:
|
||||
contest_end = contest.end_at
|
||||
if contest_end.hour == 0 and contest_end.minute == 0 and contest_end.second == 0:
|
||||
contest_end = contest_end.replace(hour=23, minute=59, second=59, microsecond=999999)
|
||||
if contest_end >= now_utc:
|
||||
filtered.append(contest)
|
||||
return filtered
|
||||
|
||||
|
||||
async def get_contests_for_summaries(db: AsyncSession) -> list[ReferralContest]:
|
||||
@@ -148,7 +159,7 @@ async def add_contest_event(
|
||||
amount_kopeks: int = 0,
|
||||
event_type: str = 'subscription_purchase',
|
||||
) -> ReferralContestEvent | None:
|
||||
existing = await db.execute(
|
||||
existing_result = await db.execute(
|
||||
select(ReferralContestEvent).where(
|
||||
and_(
|
||||
ReferralContestEvent.contest_id == contest_id,
|
||||
@@ -156,7 +167,13 @@ async def add_contest_event(
|
||||
)
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
existing = existing_result.scalar_one_or_none()
|
||||
if existing:
|
||||
# Обновляем amount_kopeks если повторная покупка (upsert)
|
||||
if amount_kopeks and existing.amount_kopeks != amount_kopeks:
|
||||
existing.amount_kopeks = amount_kopeks
|
||||
await db.commit()
|
||||
await db.refresh(existing)
|
||||
return None
|
||||
|
||||
event = ReferralContestEvent(
|
||||
@@ -197,7 +214,7 @@ async def get_contest_leaderboard(
|
||||
select(
|
||||
User,
|
||||
func.count(ReferralContestEvent.id).label('referral_count'),
|
||||
func.coalesce(func.sum(ReferralContestEvent.amount_kopeks), 0).label('total_amount'),
|
||||
func.coalesce(func.sum(func.abs(ReferralContestEvent.amount_kopeks)), 0).label('total_amount'),
|
||||
)
|
||||
.join(User, User.id == ReferralContestEvent.referrer_id)
|
||||
.where(
|
||||
@@ -383,7 +400,7 @@ async def get_contest_payment_stats(
|
||||
|
||||
# Общая сумма (только за рефералов зарегистрированных в период конкурса)
|
||||
total_result = await db.execute(
|
||||
select(func.coalesce(func.sum(ReferralContestEvent.amount_kopeks), 0)).where(
|
||||
select(func.coalesce(func.sum(func.abs(ReferralContestEvent.amount_kopeks)), 0)).where(
|
||||
and_(
|
||||
ReferralContestEvent.contest_id == contest_id,
|
||||
ReferralContestEvent.occurred_at >= contest_start,
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
"""CRUD операции для платежей RioPay."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import RioPayPayment
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def create_riopay_payment(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int,
|
||||
order_id: str,
|
||||
amount_kopeks: int,
|
||||
currency: str = 'RUB',
|
||||
description: str | None = None,
|
||||
payment_url: str | None = None,
|
||||
payment_method: str | None = None,
|
||||
riopay_order_id: str | None = None,
|
||||
expires_at: datetime | None = None,
|
||||
metadata_json: dict | None = None,
|
||||
) -> RioPayPayment:
|
||||
"""Создает запись о платеже RioPay."""
|
||||
payment = RioPayPayment(
|
||||
user_id=user_id,
|
||||
order_id=order_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
currency=currency,
|
||||
description=description,
|
||||
payment_url=payment_url,
|
||||
payment_method=payment_method,
|
||||
riopay_order_id=riopay_order_id,
|
||||
expires_at=expires_at,
|
||||
metadata_json=metadata_json,
|
||||
status='pending',
|
||||
is_paid=False,
|
||||
)
|
||||
db.add(payment)
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
logger.info('Создан платеж RioPay', order_id=order_id, user_id=user_id)
|
||||
return payment
|
||||
|
||||
|
||||
async def get_riopay_payment_by_order_id(db: AsyncSession, order_id: str) -> RioPayPayment | None:
|
||||
"""Получает платеж по order_id (internal)."""
|
||||
result = await db.execute(select(RioPayPayment).where(RioPayPayment.order_id == order_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_riopay_payment_by_riopay_order_id(db: AsyncSession, riopay_order_id: str) -> RioPayPayment | None:
|
||||
"""Получает платеж по ID от RioPay (UUID)."""
|
||||
result = await db.execute(select(RioPayPayment).where(RioPayPayment.riopay_order_id == riopay_order_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_riopay_payment_by_id(db: AsyncSession, payment_id: int) -> RioPayPayment | None:
|
||||
"""Получает платеж по ID."""
|
||||
result = await db.execute(select(RioPayPayment).where(RioPayPayment.id == payment_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def update_riopay_payment_status(
|
||||
db: AsyncSession,
|
||||
payment: RioPayPayment,
|
||||
*,
|
||||
status: str,
|
||||
is_paid: bool | None = None,
|
||||
riopay_order_id: str | None = None,
|
||||
payment_method: str | None = None,
|
||||
callback_payload: dict | None = None,
|
||||
transaction_id: int | None = None,
|
||||
) -> RioPayPayment:
|
||||
"""Обновляет статус платежа."""
|
||||
payment.status = status
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
|
||||
if is_paid is not None:
|
||||
payment.is_paid = is_paid
|
||||
if is_paid:
|
||||
payment.paid_at = datetime.now(UTC)
|
||||
if riopay_order_id:
|
||||
payment.riopay_order_id = riopay_order_id
|
||||
if payment_method is not None:
|
||||
payment.payment_method = payment_method
|
||||
if callback_payload:
|
||||
payment.callback_payload = callback_payload
|
||||
if transaction_id:
|
||||
payment.transaction_id = transaction_id
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
logger.info(
|
||||
'Обновлен статус платежа RioPay',
|
||||
order_id=payment.order_id,
|
||||
status=status,
|
||||
is_paid=payment.is_paid,
|
||||
)
|
||||
return payment
|
||||
|
||||
|
||||
async def get_pending_riopay_payments(db: AsyncSession, user_id: int) -> list[RioPayPayment]:
|
||||
"""Получает незавершенные платежи пользователя."""
|
||||
result = await db.execute(
|
||||
select(RioPayPayment).where(
|
||||
RioPayPayment.user_id == user_id,
|
||||
RioPayPayment.status == 'pending',
|
||||
RioPayPayment.is_paid == False,
|
||||
)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_expired_pending_riopay_payments(
|
||||
db: AsyncSession,
|
||||
) -> list[RioPayPayment]:
|
||||
"""Получает просроченные платежи в статусе pending."""
|
||||
now = datetime.now(UTC)
|
||||
result = await db.execute(
|
||||
select(RioPayPayment).where(
|
||||
RioPayPayment.status == 'pending',
|
||||
RioPayPayment.is_paid == False,
|
||||
RioPayPayment.expires_at < now,
|
||||
)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
@@ -0,0 +1,193 @@
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import SavedPaymentMethod
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def create_saved_payment_method(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
yookassa_payment_method_id: str,
|
||||
method_type: str = 'bank_card',
|
||||
card_first6: str | None = None,
|
||||
card_last4: str | None = None,
|
||||
card_type: str | None = None,
|
||||
card_expiry_month: str | None = None,
|
||||
card_expiry_year: str | None = None,
|
||||
title: str | None = None,
|
||||
) -> SavedPaymentMethod | None:
|
||||
"""Создаёт или реактивирует сохранённый метод оплаты."""
|
||||
|
||||
# Проверяем, есть ли уже такой метод (включая деактивированные)
|
||||
result = await db.execute(
|
||||
update(SavedPaymentMethod)
|
||||
.where(
|
||||
SavedPaymentMethod.yookassa_payment_method_id == yookassa_payment_method_id,
|
||||
SavedPaymentMethod.user_id == user_id,
|
||||
)
|
||||
.values(
|
||||
is_active=True,
|
||||
method_type=method_type,
|
||||
card_first6=card_first6,
|
||||
card_last4=card_last4,
|
||||
card_type=card_type,
|
||||
card_expiry_month=card_expiry_month,
|
||||
card_expiry_year=card_expiry_year,
|
||||
title=title,
|
||||
updated_at=datetime.now(UTC),
|
||||
)
|
||||
.returning(SavedPaymentMethod)
|
||||
)
|
||||
reactivated = result.scalar_one_or_none()
|
||||
if reactivated:
|
||||
await db.commit()
|
||||
logger.info(
|
||||
'Реактивирован сохранённый метод оплаты',
|
||||
saved_method_id=reactivated.id,
|
||||
user_id=user_id,
|
||||
method_type=method_type,
|
||||
card_last4=card_last4,
|
||||
)
|
||||
return reactivated
|
||||
|
||||
method = SavedPaymentMethod(
|
||||
user_id=user_id,
|
||||
yookassa_payment_method_id=yookassa_payment_method_id,
|
||||
method_type=method_type,
|
||||
card_first6=card_first6,
|
||||
card_last4=card_last4,
|
||||
card_type=card_type,
|
||||
card_expiry_month=card_expiry_month,
|
||||
card_expiry_year=card_expiry_year,
|
||||
title=title,
|
||||
)
|
||||
|
||||
db.add(method)
|
||||
try:
|
||||
await db.commit()
|
||||
except IntegrityError as e:
|
||||
await db.rollback()
|
||||
logger.error(
|
||||
'Ошибка создания сохранённого метода оплаты',
|
||||
yookassa_payment_method_id=yookassa_payment_method_id,
|
||||
user_id=user_id,
|
||||
e=e,
|
||||
)
|
||||
return None
|
||||
await db.refresh(method)
|
||||
|
||||
logger.info(
|
||||
'Создан сохранённый метод оплаты',
|
||||
saved_method_id=method.id,
|
||||
user_id=user_id,
|
||||
method_type=method_type,
|
||||
card_last4=card_last4,
|
||||
)
|
||||
return method
|
||||
|
||||
|
||||
async def get_active_payment_methods_by_user(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
) -> list[SavedPaymentMethod]:
|
||||
"""Получить все активные сохранённые методы оплаты пользователя."""
|
||||
result = await db.execute(
|
||||
select(SavedPaymentMethod)
|
||||
.where(
|
||||
SavedPaymentMethod.user_id == user_id,
|
||||
SavedPaymentMethod.is_active == True,
|
||||
)
|
||||
.order_by(SavedPaymentMethod.created_at.desc())
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_user_ids_with_active_payment_methods(
|
||||
db: AsyncSession,
|
||||
user_ids: list[int],
|
||||
) -> set[int]:
|
||||
"""Вернуть подмножество user_ids, у которых есть хотя бы один активный метод оплаты."""
|
||||
if not user_ids:
|
||||
return set()
|
||||
result = await db.execute(
|
||||
select(SavedPaymentMethod.user_id)
|
||||
.where(
|
||||
SavedPaymentMethod.user_id.in_(user_ids),
|
||||
SavedPaymentMethod.is_active == True,
|
||||
)
|
||||
.distinct()
|
||||
)
|
||||
return set(result.scalars().all())
|
||||
|
||||
|
||||
async def get_payment_method_by_yookassa_id(
|
||||
db: AsyncSession,
|
||||
yookassa_payment_method_id: str,
|
||||
include_inactive: bool = False,
|
||||
) -> SavedPaymentMethod | None:
|
||||
"""Найти сохранённый метод по YooKassa payment_method.id."""
|
||||
query = select(SavedPaymentMethod).where(
|
||||
SavedPaymentMethod.yookassa_payment_method_id == yookassa_payment_method_id,
|
||||
)
|
||||
if not include_inactive:
|
||||
query = query.where(SavedPaymentMethod.is_active == True)
|
||||
result = await db.execute(query)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def deactivate_payment_method(
|
||||
db: AsyncSession,
|
||||
saved_method_id: int,
|
||||
user_id: int,
|
||||
) -> bool:
|
||||
"""Деактивировать (soft-delete) сохранённый метод оплаты."""
|
||||
result = await db.execute(
|
||||
update(SavedPaymentMethod)
|
||||
.where(
|
||||
SavedPaymentMethod.id == saved_method_id,
|
||||
SavedPaymentMethod.user_id == user_id,
|
||||
SavedPaymentMethod.is_active == True,
|
||||
)
|
||||
.values(is_active=False, updated_at=datetime.now(UTC))
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
if result.rowcount > 0:
|
||||
logger.info(
|
||||
'Метод оплаты деактивирован',
|
||||
saved_method_id=saved_method_id,
|
||||
user_id=user_id,
|
||||
)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
async def deactivate_all_user_payment_methods(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
) -> int:
|
||||
"""Деактивировать все методы оплаты пользователя. Возвращает количество деактивированных."""
|
||||
result = await db.execute(
|
||||
update(SavedPaymentMethod)
|
||||
.where(
|
||||
SavedPaymentMethod.user_id == user_id,
|
||||
SavedPaymentMethod.is_active == True,
|
||||
)
|
||||
.values(is_active=False, updated_at=datetime.now(UTC))
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
if result.rowcount > 0:
|
||||
logger.info(
|
||||
'Все методы оплаты пользователя деактивированы',
|
||||
user_id=user_id,
|
||||
count=result.rowcount,
|
||||
)
|
||||
return result.rowcount
|
||||
@@ -306,9 +306,8 @@ async def sync_with_remnawave(db: AsyncSession, remnawave_squads: list[dict]) ->
|
||||
await create_server_squad(
|
||||
db=db,
|
||||
squad_uuid=squad_uuid,
|
||||
display_name=_generate_display_name(original_name),
|
||||
display_name=original_name,
|
||||
original_name=original_name,
|
||||
country_code=_extract_country_code(original_name),
|
||||
price_kopeks=1000,
|
||||
is_available=False,
|
||||
)
|
||||
@@ -482,219 +481,6 @@ async def get_random_trial_squad_uuid(
|
||||
return None
|
||||
|
||||
|
||||
def _generate_display_name(original_name: str) -> str:
|
||||
"""Генерирует отображаемое название сервера на основе оригинального имени."""
|
||||
|
||||
country_names = {
|
||||
# Европа
|
||||
'NL': '🇳🇱 Нидерланды',
|
||||
'DE': '🇩🇪 Германия',
|
||||
'FR': '🇫🇷 Франция',
|
||||
'GB': '🇬🇧 Великобритания',
|
||||
'UK': '🇬🇧 Великобритания',
|
||||
'IT': '🇮🇹 Италия',
|
||||
'ES': '🇪🇸 Испания',
|
||||
'PT': '🇵🇹 Португалия',
|
||||
'PL': '🇵🇱 Польша',
|
||||
'CZ': '🇨🇿 Чехия',
|
||||
'AT': '🇦🇹 Австрия',
|
||||
'CH': '🇨🇭 Швейцария',
|
||||
'SE': '🇸🇪 Швеция',
|
||||
'NO': '🇳🇴 Норвегия',
|
||||
'FI': '🇫🇮 Финляндия',
|
||||
'DK': '🇩🇰 Дания',
|
||||
'BE': '🇧🇪 Бельгия',
|
||||
'IE': '🇮🇪 Ирландия',
|
||||
'RO': '🇷🇴 Румыния',
|
||||
'BG': '🇧🇬 Болгария',
|
||||
'HU': '🇭🇺 Венгрия',
|
||||
'GR': '🇬🇷 Греция',
|
||||
'LV': '🇱🇻 Латвия',
|
||||
'LT': '🇱🇹 Литва',
|
||||
'EE': '🇪🇪 Эстония',
|
||||
'SK': '🇸🇰 Словакия',
|
||||
'SI': '🇸🇮 Словения',
|
||||
'HR': '🇭🇷 Хорватия',
|
||||
'RS': '🇷🇸 Сербия',
|
||||
'UA': '🇺🇦 Украина',
|
||||
'MD': '🇲🇩 Молдова',
|
||||
'BY': '🇧🇾 Беларусь',
|
||||
'LU': '🇱🇺 Люксембург',
|
||||
# СНГ и Азия
|
||||
'RU': '🇷🇺 Россия',
|
||||
'KZ': '🇰🇿 Казахстан',
|
||||
'UZ': '🇺🇿 Узбекистан',
|
||||
'GE': '🇬🇪 Грузия',
|
||||
'AM': '🇦🇲 Армения',
|
||||
'AZ': '🇦🇿 Азербайджан',
|
||||
# Америка
|
||||
'US': '🇺🇸 США',
|
||||
'CA': '🇨🇦 Канада',
|
||||
'MX': '🇲🇽 Мексика',
|
||||
'BR': '🇧🇷 Бразилия',
|
||||
'AR': '🇦🇷 Аргентина',
|
||||
'CL': '🇨🇱 Чили',
|
||||
'CO': '🇨🇴 Колумбия',
|
||||
# Азия
|
||||
'JP': '🇯🇵 Япония',
|
||||
'KR': '🇰🇷 Южная Корея',
|
||||
'CN': '🇨🇳 Китай',
|
||||
'HK': '🇭🇰 Гонконг',
|
||||
'TW': '🇹🇼 Тайвань',
|
||||
'SG': '🇸🇬 Сингапур',
|
||||
'TH': '🇹🇭 Таиланд',
|
||||
'VN': '🇻🇳 Вьетнам',
|
||||
'MY': '🇲🇾 Малайзия',
|
||||
'ID': '🇮🇩 Индонезия',
|
||||
'PH': '🇵🇭 Филиппины',
|
||||
'IN': '🇮🇳 Индия',
|
||||
'PK': '🇵🇰 Пакистан',
|
||||
# Ближний Восток
|
||||
'IL': '🇮🇱 Израиль',
|
||||
'TR': '🇹🇷 Турция',
|
||||
'AE': '🇦🇪 ОАЭ',
|
||||
'SA': '🇸🇦 Саудовская Аравия',
|
||||
'QA': '🇶🇦 Катар',
|
||||
'BH': '🇧🇭 Бахрейн',
|
||||
'KW': '🇰🇼 Кувейт',
|
||||
# Океания
|
||||
'AU': '🇦🇺 Австралия',
|
||||
'NZ': '🇳🇿 Новая Зеландия',
|
||||
# Африка
|
||||
'ZA': '🇿🇦 ЮАР',
|
||||
'EG': '🇪🇬 Египет',
|
||||
'NG': '🇳🇬 Нигерия',
|
||||
'KE': '🇰🇪 Кения',
|
||||
}
|
||||
|
||||
name_upper = original_name.upper()
|
||||
|
||||
# Сначала ищем код как отдельный элемент (через - или _)
|
||||
for code, display_name in country_names.items():
|
||||
if f'-{code}' in name_upper or f'_{code}' in name_upper:
|
||||
return display_name
|
||||
if name_upper.startswith(code + '-') or name_upper.startswith(code + '_'):
|
||||
return display_name
|
||||
if name_upper.endswith('-' + code) or name_upper.endswith('_' + code):
|
||||
return display_name
|
||||
if name_upper == code:
|
||||
return display_name
|
||||
|
||||
# Потом ищем просто вхождение кода
|
||||
for code, display_name in country_names.items():
|
||||
if code in name_upper:
|
||||
return display_name
|
||||
|
||||
return f'🌍 {original_name}'
|
||||
|
||||
|
||||
def _extract_country_code(original_name: str) -> str | None:
|
||||
"""Извлекает код страны из оригинального названия."""
|
||||
|
||||
# Полный список кодов стран
|
||||
codes = [
|
||||
# Европа
|
||||
'NL',
|
||||
'DE',
|
||||
'FR',
|
||||
'GB',
|
||||
'UK',
|
||||
'IT',
|
||||
'ES',
|
||||
'PT',
|
||||
'PL',
|
||||
'CZ',
|
||||
'AT',
|
||||
'CH',
|
||||
'SE',
|
||||
'NO',
|
||||
'FI',
|
||||
'DK',
|
||||
'BE',
|
||||
'IE',
|
||||
'RO',
|
||||
'BG',
|
||||
'HU',
|
||||
'GR',
|
||||
'LV',
|
||||
'LT',
|
||||
'EE',
|
||||
'SK',
|
||||
'SI',
|
||||
'HR',
|
||||
'RS',
|
||||
'UA',
|
||||
'MD',
|
||||
'BY',
|
||||
'LU',
|
||||
# СНГ
|
||||
'RU',
|
||||
'KZ',
|
||||
'UZ',
|
||||
'GE',
|
||||
'AM',
|
||||
'AZ',
|
||||
# Америка
|
||||
'US',
|
||||
'CA',
|
||||
'MX',
|
||||
'BR',
|
||||
'AR',
|
||||
'CL',
|
||||
'CO',
|
||||
# Азия
|
||||
'JP',
|
||||
'KR',
|
||||
'CN',
|
||||
'HK',
|
||||
'TW',
|
||||
'SG',
|
||||
'TH',
|
||||
'VN',
|
||||
'MY',
|
||||
'ID',
|
||||
'PH',
|
||||
'IN',
|
||||
'PK',
|
||||
# Ближний Восток
|
||||
'IL',
|
||||
'TR',
|
||||
'AE',
|
||||
'SA',
|
||||
'QA',
|
||||
'BH',
|
||||
'KW',
|
||||
# Океания
|
||||
'AU',
|
||||
'NZ',
|
||||
# Африка
|
||||
'ZA',
|
||||
'EG',
|
||||
'NG',
|
||||
'KE',
|
||||
]
|
||||
|
||||
name_upper = original_name.upper()
|
||||
|
||||
# Сначала ищем код как отдельный элемент
|
||||
for code in codes:
|
||||
if f'-{code}' in name_upper or f'_{code}' in name_upper:
|
||||
return code
|
||||
if name_upper.startswith(code + '-') or name_upper.startswith(code + '_'):
|
||||
return code
|
||||
if name_upper.endswith('-' + code) or name_upper.endswith('_' + code):
|
||||
return code
|
||||
if name_upper == code:
|
||||
return code
|
||||
|
||||
# Потом просто ищем вхождение
|
||||
for code in codes:
|
||||
if code in name_upper:
|
||||
return code
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def get_server_statistics(db: AsyncSession) -> dict:
|
||||
total_result = await db.execute(select(func.count(ServerSquad.id)))
|
||||
total_servers = total_result.scalar()
|
||||
|
||||
+254
-286
@@ -15,11 +15,12 @@ from app.database.models import (
|
||||
Subscription,
|
||||
SubscriptionServer,
|
||||
SubscriptionStatus,
|
||||
Transaction,
|
||||
TransactionType,
|
||||
User,
|
||||
UserPromoGroup,
|
||||
UserStatus,
|
||||
)
|
||||
from app.utils.pricing_utils import calculate_months_from_days, get_remaining_months
|
||||
from app.utils.pricing_utils import calculate_months_from_days
|
||||
from app.utils.timezone import format_local_datetime
|
||||
|
||||
|
||||
@@ -36,6 +37,31 @@ def is_recently_updated_by_webhook(subscription: Subscription) -> bool:
|
||||
return elapsed < _WEBHOOK_GUARD_SECONDS
|
||||
|
||||
|
||||
def calc_device_limit_on_tariff_switch(
|
||||
current_device_limit: int | None,
|
||||
old_tariff_device_limit: int | None,
|
||||
new_tariff_device_limit: int | None,
|
||||
max_device_limit: int | None = None,
|
||||
) -> int:
|
||||
"""Calculate device_limit preserving extra purchased devices when switching tariffs.
|
||||
|
||||
Extra devices = current_device_limit - old_tariff_device_limit (clamped to 0).
|
||||
Result = new_tariff_device_limit + extra_devices, capped at max_device_limit.
|
||||
"""
|
||||
old_base = old_tariff_device_limit if old_tariff_device_limit is not None else 0
|
||||
current = current_device_limit if current_device_limit is not None else old_base
|
||||
extra = max(0, current - old_base)
|
||||
|
||||
new_base = new_tariff_device_limit if new_tariff_device_limit is not None else 1
|
||||
total = new_base + extra
|
||||
|
||||
effective_max = max_device_limit or (settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else None)
|
||||
if effective_max and total > effective_max:
|
||||
total = effective_max
|
||||
|
||||
return total
|
||||
|
||||
|
||||
def is_active_paid_subscription(subscription: Subscription | None) -> bool:
|
||||
"""Return True if subscription is active, paid (non-trial), and not expired."""
|
||||
if not subscription:
|
||||
@@ -188,6 +214,7 @@ async def create_paid_subscription(
|
||||
update_server_counters: bool = False,
|
||||
is_trial: bool = False,
|
||||
tariff_id: int | None = None,
|
||||
commit: bool = True,
|
||||
) -> Subscription:
|
||||
end_date = datetime.now(UTC) + timedelta(days=duration_days)
|
||||
|
||||
@@ -209,8 +236,11 @@ async def create_paid_subscription(
|
||||
)
|
||||
|
||||
db.add(subscription)
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
if commit:
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
else:
|
||||
await db.flush()
|
||||
|
||||
logger.info(
|
||||
'💎 Создана платная подписка для пользователя ID: статус',
|
||||
@@ -263,6 +293,7 @@ async def replace_subscription(
|
||||
autopay_enabled: bool | None = None,
|
||||
autopay_days_before: int | None = None,
|
||||
update_server_counters: bool = False,
|
||||
commit: bool = True,
|
||||
) -> Subscription:
|
||||
"""Перезаписывает параметры существующей подписки пользователя."""
|
||||
|
||||
@@ -279,6 +310,11 @@ async def replace_subscription(
|
||||
subscription.end_date = current_time + timedelta(days=duration_days)
|
||||
subscription.traffic_limit_gb = traffic_limit_gb
|
||||
subscription.traffic_used_gb = 0.0
|
||||
|
||||
# Удаляем записи TrafficPurchase перед сбросом purchased_traffic_gb
|
||||
from app.database.models import TrafficPurchase
|
||||
|
||||
await db.execute(delete(TrafficPurchase).where(TrafficPurchase.subscription_id == subscription.id))
|
||||
subscription.purchased_traffic_gb = 0 # Сбрасываем докупленный трафик при замене подписки
|
||||
subscription.traffic_reset_at = None # Сбрасываем дату сброса трафика
|
||||
subscription.device_limit = device_limit
|
||||
@@ -290,12 +326,15 @@ async def replace_subscription(
|
||||
subscription.autopay_days_before = new_autopay_days_before
|
||||
subscription.updated_at = current_time
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
if commit:
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
else:
|
||||
await db.flush()
|
||||
|
||||
# Очищаем старые записи об отправленных уведомлениях при замене подписки
|
||||
# (аналогично extend_subscription), чтобы новые уведомления отправлялись корректно
|
||||
await clear_notifications(db, subscription.id)
|
||||
await clear_notifications(db, subscription.id, commit=commit)
|
||||
|
||||
if update_server_counters:
|
||||
try:
|
||||
@@ -342,6 +381,7 @@ async def extend_subscription(
|
||||
traffic_limit_gb: int | None = None,
|
||||
device_limit: int | None = None,
|
||||
connected_squads: list[str] | None = None,
|
||||
commit: bool = True,
|
||||
) -> Subscription:
|
||||
"""Продлевает подписку на указанное количество дней.
|
||||
|
||||
@@ -354,6 +394,8 @@ async def extend_subscription(
|
||||
device_limit: Лимит устройств (опционально, для режима тарифов)
|
||||
connected_squads: Список UUID сквадов (опционально, для режима тарифов)
|
||||
"""
|
||||
from app.database.models import TrafficPurchase
|
||||
|
||||
current_time = datetime.now(UTC)
|
||||
|
||||
logger.info('🔄 Продление подписки на дней', subscription_id=subscription.id, days=days)
|
||||
@@ -368,6 +410,13 @@ async def extend_subscription(
|
||||
# Включает переход из классического режима (tariff_id=None) в тарифный
|
||||
is_tariff_change = tariff_id is not None and (subscription.tariff_id is None or tariff_id != subscription.tariff_id)
|
||||
|
||||
# Определяем, была ли подписка истёкшей ДО продления (статус меняется ниже)
|
||||
was_expired = subscription.status in (
|
||||
SubscriptionStatus.EXPIRED.value,
|
||||
SubscriptionStatus.DISABLED.value,
|
||||
SubscriptionStatus.LIMITED.value,
|
||||
) or (subscription.end_date is not None and subscription.end_date <= current_time)
|
||||
|
||||
if is_tariff_change:
|
||||
logger.info('🔄 Обнаружена СМЕНА тарифа: →', tariff_id=subscription.tariff_id, tariff_id_2=tariff_id)
|
||||
|
||||
@@ -422,12 +471,16 @@ async def extend_subscription(
|
||||
if days > 0 and subscription.status in (
|
||||
SubscriptionStatus.EXPIRED.value,
|
||||
SubscriptionStatus.DISABLED.value,
|
||||
SubscriptionStatus.LIMITED.value,
|
||||
):
|
||||
previous_status = subscription.status
|
||||
subscription.status = SubscriptionStatus.ACTIVE.value
|
||||
logger.info(
|
||||
'🔄 Статус подписки изменён с на ACTIVE', subscription_id=subscription.id, previous_status=previous_status
|
||||
)
|
||||
elif days > 0 and subscription.status == SubscriptionStatus.TRIAL.value:
|
||||
subscription.status = SubscriptionStatus.ACTIVE.value
|
||||
logger.info('🔄 Статус подписки изменён с trial на ACTIVE', subscription_id=subscription.id)
|
||||
elif days > 0 and subscription.status == SubscriptionStatus.PENDING.value:
|
||||
logger.warning('⚠️ Попытка продлить PENDING подписку , дни', subscription_id=subscription.id, days=days)
|
||||
|
||||
@@ -444,25 +497,28 @@ async def extend_subscription(
|
||||
|
||||
if traffic_limit_gb is not None:
|
||||
old_traffic = subscription.traffic_limit_gb
|
||||
subscription.traffic_used_gb = 0.0
|
||||
|
||||
# Сброс использованного трафика: при смене тарифа — по настройке, при продлении — всегда
|
||||
if is_tariff_change:
|
||||
# При СМЕНЕ тарифа сбрасываем все докупки трафика
|
||||
if settings.RESET_TRAFFIC_ON_TARIFF_SWITCH:
|
||||
subscription.traffic_used_gb = 0.0
|
||||
else:
|
||||
subscription.traffic_used_gb = 0.0
|
||||
|
||||
if is_tariff_change or was_expired:
|
||||
# При СМЕНЕ тарифа или ИСТЁКШЕЙ подписке — сбрасываем все докупки трафика
|
||||
subscription.traffic_limit_gb = traffic_limit_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))
|
||||
await db.execute(delete(TrafficPurchase).where(TrafficPurchase.subscription_id == subscription.id))
|
||||
subscription.purchased_traffic_gb = 0
|
||||
subscription.traffic_reset_at = None
|
||||
reason = 'смена тарифа' if is_tariff_change else 'подписка была истёкшей'
|
||||
logger.info(
|
||||
'📊 Обновлен лимит трафика: ГБ → ГБ (смена тарифа, докупки сброшены)',
|
||||
'📊 Обновлен лимит трафика: ГБ → ГБ (докупки сброшены)',
|
||||
old_traffic=old_traffic,
|
||||
traffic_limit_gb=traffic_limit_gb,
|
||||
reason=reason,
|
||||
)
|
||||
else:
|
||||
# При ПРОДЛЕНИИ того же тарифа — сохраняем докупленный трафик
|
||||
# Подписка активна, тот же тариф — сохраняем докупленный трафик
|
||||
purchased = subscription.purchased_traffic_gb or 0
|
||||
subscription.traffic_limit_gb = traffic_limit_gb + purchased
|
||||
logger.info(
|
||||
@@ -473,13 +529,18 @@ async def extend_subscription(
|
||||
)
|
||||
elif settings.RESET_TRAFFIC_ON_PAYMENT:
|
||||
subscription.traffic_used_gb = 0.0
|
||||
# В режиме тарифов сохраняем докупленный трафик при продлении
|
||||
if subscription.tariff_id is None:
|
||||
if subscription.tariff_id is None or was_expired:
|
||||
# Классический режим или истёкшая подписка — сбрасываем докупки
|
||||
await db.execute(delete(TrafficPurchase).where(TrafficPurchase.subscription_id == subscription.id))
|
||||
subscription.purchased_traffic_gb = 0
|
||||
subscription.traffic_reset_at = None # Сбрасываем дату сброса трафика
|
||||
logger.info('🔄 Сбрасываем использованный и докупленный трафик согласно настройке RESET_TRAFFIC_ON_PAYMENT')
|
||||
subscription.traffic_reset_at = None
|
||||
logger.info(
|
||||
'🔄 Сбрасываем использованный и докупленный трафик',
|
||||
was_expired=was_expired,
|
||||
tariff_id=subscription.tariff_id,
|
||||
)
|
||||
else:
|
||||
# При продлении в режиме тарифов - сохраняем purchased_traffic_gb и traffic_reset_at
|
||||
# Активная подписка в режиме тарифов — сохраняем purchased_traffic_gb и traffic_reset_at
|
||||
logger.info('🔄 Сбрасываем использованный трафик, докупленный сохранен (режим тарифов)')
|
||||
|
||||
if device_limit is not None:
|
||||
@@ -522,6 +583,7 @@ async def extend_subscription(
|
||||
old_limit = subscription.traffic_limit_gb
|
||||
if subscription.traffic_limit_gb != fixed_limit or (subscription.purchased_traffic_gb or 0) > 0:
|
||||
subscription.traffic_limit_gb = fixed_limit
|
||||
await db.execute(delete(TrafficPurchase).where(TrafficPurchase.subscription_id == subscription.id))
|
||||
subscription.purchased_traffic_gb = 0
|
||||
subscription.traffic_reset_at = None # Сбрасываем дату сброса трафика
|
||||
logger.info(
|
||||
@@ -532,9 +594,13 @@ async def extend_subscription(
|
||||
|
||||
subscription.updated_at = current_time
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
await clear_notifications(db, subscription.id)
|
||||
if commit:
|
||||
await db.commit()
|
||||
await db.refresh(subscription, ['tariff'])
|
||||
else:
|
||||
await db.flush()
|
||||
|
||||
await clear_notifications(db, subscription.id, commit=commit)
|
||||
|
||||
logger.info('✅ Подписка продлена до', end_date=subscription.end_date)
|
||||
logger.info('📊 Новые параметры: статус=, окончание', status=subscription.status, end_date=subscription.end_date)
|
||||
@@ -589,7 +655,29 @@ async def add_subscription_traffic(db: AsyncSession, subscription: Subscription,
|
||||
|
||||
|
||||
async def add_subscription_devices(db: AsyncSession, subscription: Subscription, devices: int) -> Subscription:
|
||||
subscription.device_limit += devices
|
||||
# Lock subscription to prevent concurrent modifications
|
||||
locked_result = await db.execute(
|
||||
select(Subscription)
|
||||
.where(Subscription.id == subscription.id)
|
||||
.with_for_update()
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
subscription = locked_result.scalar_one()
|
||||
|
||||
# Check max device limit
|
||||
max_devices = settings.MAX_DEVICES_LIMIT
|
||||
new_limit = (subscription.device_limit or 1) + devices
|
||||
if max_devices > 0 and new_limit > max_devices:
|
||||
logger.warning(
|
||||
'📱 Попытка превысить лимит устройств',
|
||||
user_id=subscription.user_id,
|
||||
current=subscription.device_limit,
|
||||
requested=devices,
|
||||
max_devices=max_devices,
|
||||
)
|
||||
new_limit = max_devices
|
||||
|
||||
subscription.device_limit = new_limit
|
||||
subscription.updated_at = datetime.now(UTC)
|
||||
|
||||
await db.commit()
|
||||
@@ -715,42 +803,63 @@ async def deactivate_subscription(db: AsyncSession, subscription: Subscription)
|
||||
|
||||
|
||||
async def reactivate_subscription(db: AsyncSession, subscription: Subscription) -> Subscription:
|
||||
"""Реактивация подписки (например, после повторной подписки на канал).
|
||||
"""Реактивация подписки (например, после повторной подписки на канал или докупки трафика).
|
||||
|
||||
Активирует только если подписка была DISABLED и ещё не истекла.
|
||||
Активирует если подписка была DISABLED или EXPIRED и ещё не истекла по времени.
|
||||
Не логирует если реактивация не требуется.
|
||||
"""
|
||||
now = datetime.now(UTC)
|
||||
|
||||
# Тихо выходим если реактивация не нужна
|
||||
if subscription.status != SubscriptionStatus.DISABLED.value:
|
||||
# Тихо выходим если реактивация не нужна (уже активна или другой статус)
|
||||
reactivatable_statuses = {
|
||||
SubscriptionStatus.DISABLED.value,
|
||||
SubscriptionStatus.EXPIRED.value,
|
||||
SubscriptionStatus.LIMITED.value,
|
||||
}
|
||||
if subscription.status not in reactivatable_statuses:
|
||||
return subscription
|
||||
|
||||
if subscription.end_date and subscription.end_date <= now:
|
||||
if not subscription.end_date or subscription.end_date <= now:
|
||||
return subscription
|
||||
|
||||
old_status = subscription.status
|
||||
subscription.status = SubscriptionStatus.ACTIVE.value
|
||||
subscription.updated_at = now
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
logger.info(
|
||||
'✅ Подписка реактивирована',
|
||||
subscription_id=subscription.id,
|
||||
user_id=subscription.user_id,
|
||||
old_status=old_status,
|
||||
)
|
||||
|
||||
return subscription
|
||||
|
||||
|
||||
async def get_expiring_subscriptions(db: AsyncSession, days_before: int = 3) -> list[Subscription]:
|
||||
from app.database.models import Tariff
|
||||
|
||||
threshold_date = datetime.now(UTC) + timedelta(days=days_before)
|
||||
|
||||
result = await db.execute(
|
||||
select(Subscription)
|
||||
.join(User, Subscription.user_id == User.id)
|
||||
.options(selectinload(Subscription.user))
|
||||
.outerjoin(Tariff, Subscription.tariff_id == Tariff.id)
|
||||
.options(selectinload(Subscription.user), selectinload(Subscription.tariff))
|
||||
.where(
|
||||
and_(
|
||||
Subscription.status == SubscriptionStatus.ACTIVE.value,
|
||||
User.status == UserStatus.ACTIVE.value,
|
||||
Subscription.end_date <= threshold_date,
|
||||
Subscription.end_date > datetime.now(UTC),
|
||||
# Не включаем активные суточные подписки — у них end_date всегда +24ч
|
||||
~and_(
|
||||
Tariff.is_daily.is_(True),
|
||||
Subscription.is_daily_paused.is_(False),
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -758,15 +867,23 @@ async def get_expiring_subscriptions(db: AsyncSession, days_before: int = 3) ->
|
||||
|
||||
|
||||
async def get_expired_subscriptions(db: AsyncSession) -> list[Subscription]:
|
||||
from app.database.models import Tariff
|
||||
|
||||
result = await db.execute(
|
||||
select(Subscription)
|
||||
.join(User, Subscription.user_id == User.id)
|
||||
.options(selectinload(Subscription.user))
|
||||
.outerjoin(Tariff, Subscription.tariff_id == Tariff.id)
|
||||
.options(selectinload(Subscription.user), selectinload(Subscription.tariff))
|
||||
.where(
|
||||
and_(
|
||||
Subscription.status == SubscriptionStatus.ACTIVE.value,
|
||||
User.status == UserStatus.ACTIVE.value,
|
||||
Subscription.end_date <= datetime.now(UTC),
|
||||
# Не трогаем активные суточные подписки — ими управляет DailySubscriptionService
|
||||
~and_(
|
||||
Tariff.is_daily.is_(True),
|
||||
Subscription.is_daily_paused.is_(False),
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -827,29 +944,43 @@ async def get_subscriptions_statistics(db: AsyncSession) -> dict:
|
||||
|
||||
paid_subscriptions = active_subscriptions - trial_subscriptions
|
||||
|
||||
today = datetime.now(UTC).date()
|
||||
now = datetime.now(UTC)
|
||||
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
week_ago = today_start - timedelta(days=7)
|
||||
month_ago = today_start - timedelta(days=30)
|
||||
|
||||
today_result = await db.execute(
|
||||
select(func.count(Subscription.id)).where(
|
||||
and_(Subscription.created_at >= today, Subscription.is_trial == False)
|
||||
select(func.count(Transaction.id)).where(
|
||||
and_(
|
||||
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
|
||||
Transaction.is_completed.is_(True),
|
||||
Transaction.created_at >= today_start,
|
||||
)
|
||||
)
|
||||
)
|
||||
purchased_today = today_result.scalar()
|
||||
purchased_today = today_result.scalar() or 0
|
||||
|
||||
week_ago = datetime.now(UTC) - timedelta(days=7)
|
||||
week_result = await db.execute(
|
||||
select(func.count(Subscription.id)).where(
|
||||
and_(Subscription.created_at >= week_ago, Subscription.is_trial == False)
|
||||
select(func.count(Transaction.id)).where(
|
||||
and_(
|
||||
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
|
||||
Transaction.is_completed.is_(True),
|
||||
Transaction.created_at >= week_ago,
|
||||
)
|
||||
)
|
||||
)
|
||||
purchased_week = week_result.scalar()
|
||||
purchased_week = week_result.scalar() or 0
|
||||
|
||||
month_ago = datetime.now(UTC) - timedelta(days=30)
|
||||
month_result = await db.execute(
|
||||
select(func.count(Subscription.id)).where(
|
||||
and_(Subscription.created_at >= month_ago, Subscription.is_trial == False)
|
||||
select(func.count(Transaction.id)).where(
|
||||
and_(
|
||||
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
|
||||
Transaction.is_completed.is_(True),
|
||||
Transaction.created_at >= month_ago,
|
||||
)
|
||||
)
|
||||
)
|
||||
purchased_month = month_result.scalar()
|
||||
purchased_month = month_result.scalar() or 0
|
||||
|
||||
try:
|
||||
from app.database.crud.subscription_conversion import get_conversion_statistics
|
||||
@@ -1002,7 +1133,7 @@ async def get_all_subscriptions(db: AsyncSession, page: int = 1, limit: int = 10
|
||||
|
||||
result = await db.execute(
|
||||
select(Subscription)
|
||||
.options(selectinload(Subscription.user))
|
||||
.options(selectinload(Subscription.user), selectinload(Subscription.tariff))
|
||||
.order_by(Subscription.created_at.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
@@ -1018,10 +1149,10 @@ async def get_subscriptions_batch(
|
||||
offset: int = 0,
|
||||
limit: int = 500,
|
||||
) -> list[Subscription]:
|
||||
"""Получает подписки пачками для синхронизации. Загружает связанных пользователей."""
|
||||
"""Получает подписки пачками для синхронизации. Загружает связанных пользователей и тарифы."""
|
||||
result = await db.execute(
|
||||
select(Subscription)
|
||||
.options(selectinload(Subscription.user))
|
||||
.options(selectinload(Subscription.user), selectinload(Subscription.tariff))
|
||||
.order_by(Subscription.id)
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
@@ -1035,7 +1166,8 @@ async def add_subscription_servers(
|
||||
await db.refresh(subscription)
|
||||
|
||||
if paid_prices is None:
|
||||
months_remaining = get_remaining_months(subscription.end_date)
|
||||
now = datetime.now(UTC)
|
||||
days_remaining = max(1, (subscription.end_date - now).days)
|
||||
paid_prices = []
|
||||
|
||||
from app.database.models import ServerSquad
|
||||
@@ -1043,7 +1175,7 @@ async def add_subscription_servers(
|
||||
for server_id in server_squad_ids:
|
||||
result = await db.execute(select(ServerSquad.price_kopeks).where(ServerSquad.id == server_id))
|
||||
server_price_per_month = result.scalar() or 0
|
||||
total_price_for_period = server_price_per_month * months_remaining
|
||||
total_price_for_period = int(server_price_per_month * days_remaining / 30)
|
||||
paid_prices.append(total_price_for_period)
|
||||
|
||||
for i, server_id in enumerate(server_squad_ids):
|
||||
@@ -1279,32 +1411,6 @@ async def get_subscription_server_ids(db: AsyncSession, subscription_id: int) ->
|
||||
return [row[0] for row in result.fetchall()]
|
||||
|
||||
|
||||
async def get_subscription_servers(db: AsyncSession, subscription_id: int) -> list[dict]:
|
||||
from app.database.models import ServerSquad
|
||||
|
||||
result = await db.execute(
|
||||
select(SubscriptionServer, ServerSquad)
|
||||
.join(ServerSquad, SubscriptionServer.server_squad_id == ServerSquad.id)
|
||||
.where(SubscriptionServer.subscription_id == subscription_id)
|
||||
)
|
||||
|
||||
servers_info = []
|
||||
for sub_server, server_squad in result.fetchall():
|
||||
servers_info.append(
|
||||
{
|
||||
'server_id': server_squad.id,
|
||||
'squad_uuid': server_squad.squad_uuid,
|
||||
'display_name': server_squad.display_name,
|
||||
'country_code': server_squad.country_code,
|
||||
'paid_price_kopeks': sub_server.paid_price_kopeks,
|
||||
'connected_at': sub_server.connected_at,
|
||||
'is_available': server_squad.is_available,
|
||||
}
|
||||
)
|
||||
|
||||
return servers_info
|
||||
|
||||
|
||||
async def remove_subscription_servers(db: AsyncSession, subscription_id: int, server_squad_ids: list[int]) -> bool:
|
||||
try:
|
||||
from sqlalchemy import delete
|
||||
@@ -1328,214 +1434,6 @@ async def remove_subscription_servers(db: AsyncSession, subscription_id: int, se
|
||||
return False
|
||||
|
||||
|
||||
async def get_subscription_renewal_cost(
|
||||
db: AsyncSession,
|
||||
subscription_id: int,
|
||||
period_days: int,
|
||||
*,
|
||||
user: User | None = None,
|
||||
promo_group: PromoGroup | None = None,
|
||||
) -> int:
|
||||
try:
|
||||
from app.config import PERIOD_PRICES
|
||||
|
||||
months_in_period = calculate_months_from_days(period_days)
|
||||
|
||||
base_price = PERIOD_PRICES.get(period_days, 0)
|
||||
|
||||
result = await db.execute(
|
||||
select(Subscription)
|
||||
.options(
|
||||
selectinload(Subscription.user)
|
||||
.selectinload(User.user_promo_groups)
|
||||
.selectinload(UserPromoGroup.promo_group),
|
||||
)
|
||||
.where(Subscription.id == subscription_id)
|
||||
)
|
||||
subscription = result.scalar_one_or_none()
|
||||
if not subscription:
|
||||
return base_price
|
||||
|
||||
if user is None:
|
||||
user = subscription.user
|
||||
promo_group = promo_group or (user.promo_group if user else None)
|
||||
|
||||
servers_info = await get_subscription_servers(db, subscription_id)
|
||||
servers_price_per_month = 0
|
||||
for server_info in servers_info:
|
||||
from app.database.models import ServerSquad
|
||||
|
||||
result = await db.execute(
|
||||
select(ServerSquad.price_kopeks).where(ServerSquad.id == server_info['server_id'])
|
||||
)
|
||||
current_server_price = result.scalar() or 0
|
||||
servers_price_per_month += current_server_price
|
||||
|
||||
servers_discount_percent = _get_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
'servers',
|
||||
period_days=period_days,
|
||||
)
|
||||
servers_discount_per_month = servers_price_per_month * servers_discount_percent // 100
|
||||
discounted_servers_per_month = servers_price_per_month - servers_discount_per_month
|
||||
total_servers_cost = discounted_servers_per_month * months_in_period
|
||||
total_servers_discount = servers_discount_per_month * months_in_period
|
||||
|
||||
# В режиме fixed_with_topup при продлении используем фиксированный лимит
|
||||
if settings.is_traffic_fixed():
|
||||
renewal_traffic_gb = settings.get_fixed_traffic_limit()
|
||||
else:
|
||||
renewal_traffic_gb = subscription.traffic_limit_gb
|
||||
traffic_price_per_month = settings.get_traffic_price(renewal_traffic_gb)
|
||||
traffic_discount_percent = _get_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
'traffic',
|
||||
period_days=period_days,
|
||||
)
|
||||
traffic_discount_per_month = traffic_price_per_month * traffic_discount_percent // 100
|
||||
discounted_traffic_per_month = traffic_price_per_month - traffic_discount_per_month
|
||||
total_traffic_cost = discounted_traffic_per_month * months_in_period
|
||||
total_traffic_discount = traffic_discount_per_month * months_in_period
|
||||
|
||||
additional_devices = max(0, subscription.device_limit - settings.DEFAULT_DEVICE_LIMIT)
|
||||
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
|
||||
devices_discount_percent = _get_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
'devices',
|
||||
period_days=period_days,
|
||||
)
|
||||
devices_discount_per_month = devices_price_per_month * devices_discount_percent // 100
|
||||
discounted_devices_per_month = devices_price_per_month - devices_discount_per_month
|
||||
total_devices_cost = discounted_devices_per_month * months_in_period
|
||||
total_devices_discount = devices_discount_per_month * months_in_period
|
||||
|
||||
total_cost = base_price + total_servers_cost + total_traffic_cost + total_devices_cost
|
||||
|
||||
logger.info(
|
||||
'💰 Расчет продления подписки на дней ( мес)',
|
||||
subscription_id=subscription_id,
|
||||
period_days=period_days,
|
||||
months_in_period=months_in_period,
|
||||
)
|
||||
logger.info('📅 Период: ₽', base_price=base_price / 100)
|
||||
if total_servers_cost > 0:
|
||||
message = f' 🌍 Серверы: {servers_price_per_month / 100}₽/мес × {months_in_period} = {total_servers_cost / 100}₽'
|
||||
if total_servers_discount > 0:
|
||||
message += f' (скидка {servers_discount_percent}%: -{total_servers_discount / 100}₽)'
|
||||
logger.info(message)
|
||||
if total_traffic_cost > 0:
|
||||
message = (
|
||||
f' 📊 Трафик: {traffic_price_per_month / 100}₽/мес × {months_in_period} = {total_traffic_cost / 100}₽'
|
||||
)
|
||||
if total_traffic_discount > 0:
|
||||
message += f' (скидка {traffic_discount_percent}%: -{total_traffic_discount / 100}₽)'
|
||||
logger.info(message)
|
||||
if total_devices_cost > 0:
|
||||
message = f' 📱 Устройства: {devices_price_per_month / 100}₽/мес × {months_in_period} = {total_devices_cost / 100}₽'
|
||||
if total_devices_discount > 0:
|
||||
message += f' (скидка {devices_discount_percent}%: -{total_devices_discount / 100}₽)'
|
||||
logger.info(message)
|
||||
logger.info('💎 ИТОГО: ₽', total_cost=total_cost / 100)
|
||||
|
||||
return total_cost
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Ошибка расчета стоимости продления', error=e)
|
||||
from app.config import PERIOD_PRICES
|
||||
|
||||
return PERIOD_PRICES.get(period_days, 0)
|
||||
|
||||
|
||||
async def calculate_addon_cost_for_remaining_period(
|
||||
db: AsyncSession,
|
||||
subscription: Subscription,
|
||||
additional_traffic_gb: int = 0,
|
||||
additional_devices: int = 0,
|
||||
additional_server_ids: list[int] = None,
|
||||
*,
|
||||
user: User | None = None,
|
||||
promo_group: PromoGroup | None = None,
|
||||
) -> int:
|
||||
if additional_server_ids is None:
|
||||
additional_server_ids = []
|
||||
|
||||
months_to_pay = get_remaining_months(subscription.end_date)
|
||||
period_hint_days = months_to_pay * 30 if months_to_pay > 0 else None
|
||||
|
||||
total_cost = 0
|
||||
|
||||
if user is None:
|
||||
user = getattr(subscription, 'user', None)
|
||||
promo_group = promo_group or (user.promo_group if user else None)
|
||||
|
||||
if additional_traffic_gb > 0:
|
||||
traffic_price_per_month = settings.get_traffic_price(additional_traffic_gb)
|
||||
traffic_discount_percent = _get_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
'traffic',
|
||||
period_days=period_hint_days,
|
||||
)
|
||||
traffic_discount_per_month = traffic_price_per_month * traffic_discount_percent // 100
|
||||
discounted_traffic_per_month = traffic_price_per_month - traffic_discount_per_month
|
||||
traffic_total_cost = discounted_traffic_per_month * months_to_pay
|
||||
total_cost += traffic_total_cost
|
||||
message = f'Трафик +{additional_traffic_gb}ГБ: {traffic_price_per_month / 100}₽/мес × {months_to_pay} = {traffic_total_cost / 100}₽'
|
||||
if traffic_discount_per_month > 0:
|
||||
message += f' (скидка {traffic_discount_percent}%: -{traffic_discount_per_month * months_to_pay / 100}₽)'
|
||||
logger.info(message)
|
||||
|
||||
if additional_devices > 0:
|
||||
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
|
||||
devices_discount_percent = _get_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
'devices',
|
||||
period_days=period_hint_days,
|
||||
)
|
||||
devices_discount_per_month = devices_price_per_month * devices_discount_percent // 100
|
||||
discounted_devices_per_month = devices_price_per_month - devices_discount_per_month
|
||||
devices_total_cost = discounted_devices_per_month * months_to_pay
|
||||
total_cost += devices_total_cost
|
||||
message = f'Устройства +{additional_devices}: {devices_price_per_month / 100}₽/мес × {months_to_pay} = {devices_total_cost / 100}₽'
|
||||
if devices_discount_per_month > 0:
|
||||
message += f' (скидка {devices_discount_percent}%: -{devices_discount_per_month * months_to_pay / 100}₽)'
|
||||
logger.info(message)
|
||||
|
||||
if additional_server_ids:
|
||||
from app.database.models import ServerSquad
|
||||
|
||||
for server_id in additional_server_ids:
|
||||
result = await db.execute(
|
||||
select(ServerSquad.price_kopeks, ServerSquad.display_name).where(ServerSquad.id == server_id)
|
||||
)
|
||||
server_data = result.first()
|
||||
if server_data:
|
||||
server_price_per_month, server_name = server_data
|
||||
servers_discount_percent = _get_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
'servers',
|
||||
period_days=period_hint_days,
|
||||
)
|
||||
server_discount_per_month = server_price_per_month * servers_discount_percent // 100
|
||||
discounted_server_per_month = server_price_per_month - server_discount_per_month
|
||||
server_total_cost = discounted_server_per_month * months_to_pay
|
||||
total_cost += server_total_cost
|
||||
message = f'Сервер {server_name}: {server_price_per_month / 100}₽/мес × {months_to_pay} = {server_total_cost / 100}₽'
|
||||
if server_discount_per_month > 0:
|
||||
message += (
|
||||
f' (скидка {servers_discount_percent}%: -{server_discount_per_month * months_to_pay / 100}₽)'
|
||||
)
|
||||
logger.info(message)
|
||||
|
||||
logger.info('💰 Итого доплата за мес: ₽', months_to_pay=months_to_pay, total_cost=total_cost / 100)
|
||||
return total_cost
|
||||
|
||||
|
||||
async def expire_subscription(db: AsyncSession, subscription: Subscription) -> Subscription:
|
||||
subscription.status = SubscriptionStatus.EXPIRED.value
|
||||
subscription.updated_at = datetime.now(UTC)
|
||||
@@ -1565,6 +1463,19 @@ async def check_and_update_subscription_status(db: AsyncSession, subscription: S
|
||||
logger.info('⏸️ Суточная подписка на паузе, пропускаем проверку истечения', subscription_id=subscription.id)
|
||||
return subscription
|
||||
|
||||
# Активные суточные подписки управляются DailySubscriptionService — не экспайрим их тут.
|
||||
# end_date у них всего +24ч, и между проверками (30 мин) она может формально истечь.
|
||||
# Используем getattr(subscription, 'tariff', None) вместо property is_daily_tariff,
|
||||
# т.к. property может вызвать MissingGreenlet при ленивой загрузке в async-контексте.
|
||||
tariff = getattr(subscription, 'tariff', None)
|
||||
is_active_daily = tariff is not None and getattr(tariff, 'is_daily', False) and not is_daily_paused
|
||||
if is_active_daily:
|
||||
logger.debug(
|
||||
'⏩ Активная суточная подписка — пропускаем проверку истечения (управляет DailySubscriptionService)',
|
||||
subscription_id=subscription.id,
|
||||
)
|
||||
return subscription
|
||||
|
||||
if subscription.status == SubscriptionStatus.ACTIVE.value and subscription.end_date <= current_time:
|
||||
# Детальное логирование для отладки проблемы с деактивацией
|
||||
time_diff = current_time - subscription.end_date
|
||||
@@ -1987,6 +1898,9 @@ async def get_disabled_daily_subscriptions_for_resume(
|
||||
Subscription.status == SubscriptionStatus.DISABLED.value,
|
||||
User.status == UserStatus.ACTIVE.value,
|
||||
Subscription.is_trial.is_(False),
|
||||
# Не возобновляем подписки, приостановленные пользователем вручную
|
||||
# is_(False) не ловит NULL, поэтому добавляем OR is_(None)
|
||||
(Subscription.is_daily_paused.is_(False) | Subscription.is_daily_paused.is_(None)),
|
||||
# Баланс пользователя >= суточной цены тарифа
|
||||
User.balance_kopeks >= Tariff.daily_price_kopeks,
|
||||
)
|
||||
@@ -2001,6 +1915,56 @@ async def get_disabled_daily_subscriptions_for_resume(
|
||||
return list(subscriptions)
|
||||
|
||||
|
||||
async def get_expired_daily_subscriptions_for_recovery(db: AsyncSession) -> list[Subscription]:
|
||||
"""
|
||||
Получает EXPIRED суточные подписки, которые были ошибочно экспайрены
|
||||
middleware или check_and_update_subscription_status.
|
||||
|
||||
Суточные подписки не должны экспайриться — ими управляет DailySubscriptionService.
|
||||
Если баланс пользователя достаточен, подписку нужно восстановить и списать.
|
||||
"""
|
||||
from app.database.models import Tariff
|
||||
|
||||
# Берём только недавно экспайренные (до 24ч) — старые не трогаем
|
||||
recovery_threshold = datetime.now(UTC) - timedelta(hours=24)
|
||||
|
||||
query = (
|
||||
select(Subscription)
|
||||
.join(Tariff, Subscription.tariff_id == Tariff.id)
|
||||
.join(User, Subscription.user_id == User.id)
|
||||
.options(
|
||||
selectinload(Subscription.user),
|
||||
selectinload(Subscription.tariff),
|
||||
)
|
||||
.where(
|
||||
and_(
|
||||
Tariff.is_daily.is_(True),
|
||||
Tariff.is_active.is_(True),
|
||||
Subscription.status == SubscriptionStatus.EXPIRED.value,
|
||||
User.status == UserStatus.ACTIVE.value,
|
||||
# is_(False) не ловит NULL, поэтому добавляем OR is_(None)
|
||||
(Subscription.is_daily_paused.is_(False) | Subscription.is_daily_paused.is_(None)),
|
||||
Subscription.is_trial.is_(False),
|
||||
# Только недавно экспайренные
|
||||
Subscription.updated_at >= recovery_threshold,
|
||||
# Баланс достаточен для списания
|
||||
User.balance_kopeks >= Tariff.daily_price_kopeks,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
result = await db.execute(query)
|
||||
subscriptions = result.scalars().all()
|
||||
|
||||
if subscriptions:
|
||||
logger.warning(
|
||||
'⚠️ Найдено EXPIRED суточных подписок для восстановления (ошибочно экспайрены)',
|
||||
subscriptions_count=len(subscriptions),
|
||||
)
|
||||
|
||||
return list(subscriptions)
|
||||
|
||||
|
||||
async def pause_daily_subscription(
|
||||
db: AsyncSession,
|
||||
subscription: Subscription,
|
||||
@@ -2034,8 +1998,12 @@ async def resume_daily_subscription(
|
||||
|
||||
subscription.is_daily_paused = False
|
||||
|
||||
# Восстанавливаем статус ACTIVE если подписка была DISABLED/EXPIRED
|
||||
if subscription.status in (SubscriptionStatus.DISABLED.value, SubscriptionStatus.EXPIRED.value):
|
||||
# Восстанавливаем статус ACTIVE если подписка была DISABLED/EXPIRED/LIMITED
|
||||
if subscription.status in (
|
||||
SubscriptionStatus.DISABLED.value,
|
||||
SubscriptionStatus.EXPIRED.value,
|
||||
SubscriptionStatus.LIMITED.value,
|
||||
):
|
||||
previous_status = subscription.status
|
||||
subscription.status = SubscriptionStatus.ACTIVE.value
|
||||
# Обновляем время последнего списания для корректного расчёта следующего
|
||||
|
||||
@@ -25,6 +25,13 @@ async def upsert_system_setting(
|
||||
return setting
|
||||
|
||||
|
||||
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 delete_system_setting(db: AsyncSession, key: str) -> None:
|
||||
result = await db.execute(select(SystemSetting).where(SystemSetting.key == key))
|
||||
setting = result.scalar_one_or_none()
|
||||
|
||||
@@ -185,8 +185,12 @@ async def create_tariff(
|
||||
traffic_price_per_gb_kopeks: int = 0,
|
||||
min_traffic_gb: int = 1,
|
||||
max_traffic_gb: int = 1000,
|
||||
# Видимость в разделе подарков
|
||||
show_in_gift: bool = True,
|
||||
# Режим сброса трафика
|
||||
traffic_reset_mode: str | None = None, # DAY, WEEK, MONTH, NO_RESET, None = глобальная настройка
|
||||
# Внешний сквад RemnaWave
|
||||
external_squad_uuid: str | None = None,
|
||||
) -> Tariff:
|
||||
"""Создает новый тариф."""
|
||||
normalized_prices = _normalize_period_prices(period_prices)
|
||||
@@ -221,8 +225,12 @@ async def create_tariff(
|
||||
traffic_price_per_gb_kopeks=max(0, traffic_price_per_gb_kopeks),
|
||||
min_traffic_gb=max(1, min_traffic_gb),
|
||||
max_traffic_gb=max(1, max_traffic_gb),
|
||||
# Видимость в разделе подарков
|
||||
show_in_gift=show_in_gift,
|
||||
# Режим сброса трафика
|
||||
traffic_reset_mode=traffic_reset_mode,
|
||||
# Внешний сквад
|
||||
external_squad_uuid=external_squad_uuid,
|
||||
)
|
||||
|
||||
db.add(tariff)
|
||||
@@ -286,8 +294,12 @@ async def update_tariff(
|
||||
traffic_price_per_gb_kopeks: int | None = None,
|
||||
min_traffic_gb: int | None = None,
|
||||
max_traffic_gb: int | None = None,
|
||||
# Видимость в разделе подарков
|
||||
show_in_gift: bool | None = None,
|
||||
# Режим сброса трафика
|
||||
traffic_reset_mode: str | None = ..., # ... = не передан, None = сбросить к глобальной настройке
|
||||
# Внешний сквад RemnaWave
|
||||
external_squad_uuid: str | None = ..., # ... = не передан, None = убрать внешний сквад
|
||||
) -> Tariff:
|
||||
"""Обновляет существующий тариф."""
|
||||
if name is not None:
|
||||
@@ -348,9 +360,15 @@ async def update_tariff(
|
||||
tariff.min_traffic_gb = max(1, min_traffic_gb)
|
||||
if max_traffic_gb is not None:
|
||||
tariff.max_traffic_gb = max(1, max_traffic_gb)
|
||||
# Видимость в разделе подарков
|
||||
if show_in_gift is not None:
|
||||
tariff.show_in_gift = show_in_gift
|
||||
# Режим сброса трафика
|
||||
if traffic_reset_mode is not ...:
|
||||
tariff.traffic_reset_mode = traffic_reset_mode
|
||||
# Внешний сквад
|
||||
if external_squad_uuid is not ...:
|
||||
tariff.external_squad_uuid = external_squad_uuid
|
||||
|
||||
# Обновляем промогруппы если указаны
|
||||
if promo_group_ids is not None:
|
||||
|
||||
@@ -38,11 +38,21 @@ async def create_transaction(
|
||||
external_id: str | None = None,
|
||||
is_completed: bool = True,
|
||||
created_at: datetime | None = None,
|
||||
*,
|
||||
commit: bool = True,
|
||||
) -> Transaction:
|
||||
# SUBSCRIPTION_PAYMENT / GIFT_PAYMENT — always store as negative (debit from user balance)
|
||||
# Keep original for downstream consumers (events, contests)
|
||||
stored_amount = (
|
||||
-amount_kopeks
|
||||
if type in (TransactionType.SUBSCRIPTION_PAYMENT, TransactionType.GIFT_PAYMENT) and amount_kopeks > 0
|
||||
else amount_kopeks
|
||||
)
|
||||
|
||||
transaction = Transaction(
|
||||
user_id=user_id,
|
||||
type=type.value,
|
||||
amount_kopeks=amount_kopeks,
|
||||
amount_kopeks=stored_amount,
|
||||
description=description,
|
||||
payment_method=payment_method.value if payment_method else None,
|
||||
external_id=external_id,
|
||||
@@ -52,17 +62,82 @@ async def create_transaction(
|
||||
)
|
||||
|
||||
db.add(transaction)
|
||||
await db.commit()
|
||||
if commit:
|
||||
await db.commit()
|
||||
else:
|
||||
await db.flush()
|
||||
await db.refresh(transaction)
|
||||
|
||||
logger.info(
|
||||
'💳 Создана транзакция: на ₽ для пользователя',
|
||||
type_value=type.value,
|
||||
amount_kopeks=amount_kopeks / 100,
|
||||
amount_kopeks=stored_amount / 100,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
# Отправляем событие о транзакции
|
||||
# Side-effects skipped when commit=False to preserve caller's transaction atomicity.
|
||||
# Callers using commit=False should call emit_transaction_side_effects() after their own db.commit().
|
||||
if commit:
|
||||
try:
|
||||
from app.services.event_emitter import event_emitter
|
||||
|
||||
await event_emitter.emit(
|
||||
'payment.completed' if type == TransactionType.DEPOSIT else 'transaction.created',
|
||||
{
|
||||
'transaction_id': transaction.id,
|
||||
'user_id': user_id,
|
||||
'type': type.value,
|
||||
'amount_kopeks': abs(amount_kopeks),
|
||||
'amount_rubles': abs(amount_kopeks) / 100,
|
||||
'payment_method': payment_method.value if payment_method else None,
|
||||
'external_id': external_id,
|
||||
'is_completed': is_completed,
|
||||
'description': description,
|
||||
},
|
||||
db=db,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.warning('Failed to emit transaction event', error=error)
|
||||
|
||||
try:
|
||||
from app.services.promo_group_assignment import (
|
||||
maybe_assign_promo_group_by_total_spent,
|
||||
)
|
||||
|
||||
await maybe_assign_promo_group_by_total_spent(db, user_id)
|
||||
except Exception as exc:
|
||||
logger.debug('Не удалось проверить автовыдачу промогруппы для пользователя', user_id=user_id, exc=exc)
|
||||
if type == TransactionType.SUBSCRIPTION_PAYMENT and is_completed:
|
||||
try:
|
||||
from app.services.referral_contest_service import referral_contest_service
|
||||
|
||||
await referral_contest_service.on_subscription_payment(
|
||||
db,
|
||||
user_id,
|
||||
abs(amount_kopeks),
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug('Не удалось записать событие конкурса для пользователя', user_id=user_id, exc=exc)
|
||||
|
||||
return transaction
|
||||
|
||||
|
||||
async def emit_transaction_side_effects(
|
||||
db: AsyncSession,
|
||||
transaction: Transaction,
|
||||
*,
|
||||
amount_kopeks: int,
|
||||
user_id: int,
|
||||
type: TransactionType,
|
||||
payment_method: PaymentMethod | None = None,
|
||||
external_id: str | None = None,
|
||||
is_completed: bool = True,
|
||||
description: str = '',
|
||||
) -> None:
|
||||
"""Fire side-effects that were deferred when create_transaction(commit=False) was used.
|
||||
|
||||
Call this AFTER db.commit() to emit events and run promo checks.
|
||||
"""
|
||||
try:
|
||||
from app.services.event_emitter import event_emitter
|
||||
|
||||
@@ -72,8 +147,8 @@ async def create_transaction(
|
||||
'transaction_id': transaction.id,
|
||||
'user_id': user_id,
|
||||
'type': type.value,
|
||||
'amount_kopeks': amount_kopeks,
|
||||
'amount_rubles': amount_kopeks / 100,
|
||||
'amount_kopeks': abs(amount_kopeks),
|
||||
'amount_rubles': abs(amount_kopeks) / 100,
|
||||
'payment_method': payment_method.value if payment_method else None,
|
||||
'external_id': external_id,
|
||||
'is_completed': is_completed,
|
||||
@@ -82,7 +157,7 @@ async def create_transaction(
|
||||
db=db,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.warning('Failed to emit transaction event', error=error)
|
||||
logger.warning('Failed to emit deferred transaction event', error=error)
|
||||
|
||||
try:
|
||||
from app.services.promo_group_assignment import (
|
||||
@@ -92,20 +167,19 @@ async def create_transaction(
|
||||
await maybe_assign_promo_group_by_total_spent(db, user_id)
|
||||
except Exception as exc:
|
||||
logger.debug('Не удалось проверить автовыдачу промогруппы для пользователя', user_id=user_id, exc=exc)
|
||||
if type == TransactionType.SUBSCRIPTION_PAYMENT:
|
||||
|
||||
if type == TransactionType.SUBSCRIPTION_PAYMENT and is_completed:
|
||||
try:
|
||||
from app.services.referral_contest_service import referral_contest_service
|
||||
|
||||
await referral_contest_service.on_subscription_payment(
|
||||
db,
|
||||
user_id,
|
||||
amount_kopeks,
|
||||
abs(amount_kopeks),
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug('Не удалось записать событие конкурса для пользователя', user_id=user_id, exc=exc)
|
||||
|
||||
return transaction
|
||||
|
||||
|
||||
async def get_transaction_by_id(db: AsyncSession, transaction_id: int) -> Transaction | None:
|
||||
result = await db.execute(
|
||||
@@ -217,7 +291,7 @@ async def get_transactions_statistics(
|
||||
total_income = income_result.scalar()
|
||||
|
||||
expenses_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.WITHDRAWAL.value,
|
||||
Transaction.is_completed == True,
|
||||
@@ -244,7 +318,7 @@ async def get_transactions_statistics(
|
||||
select(
|
||||
Transaction.type,
|
||||
func.count(Transaction.id).label('count'),
|
||||
func.coalesce(func.sum(Transaction.amount_kopeks), 0).label('total_amount'),
|
||||
func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0).label('total_amount'),
|
||||
)
|
||||
.where(
|
||||
and_(
|
||||
|
||||
+146
-64
@@ -54,7 +54,7 @@ def _build_spending_stats_select():
|
||||
case(
|
||||
(
|
||||
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
|
||||
Transaction.amount_kopeks,
|
||||
func.abs(Transaction.amount_kopeks),
|
||||
),
|
||||
else_=0,
|
||||
)
|
||||
@@ -122,6 +122,30 @@ async def get_user_by_telegram_id(db: AsyncSession, telegram_id: int) -> User |
|
||||
return user
|
||||
|
||||
|
||||
async def find_phantom_user_by_username(db: AsyncSession, username: str) -> User | None:
|
||||
"""Find a phantom user created by guest purchase (no telegram_id, auth_type=telegram).
|
||||
|
||||
Used during /start to reconcile phantom users with real Telegram accounts.
|
||||
"""
|
||||
if not username:
|
||||
return None
|
||||
|
||||
normalized = username.lower()
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.options(
|
||||
selectinload(User.subscription).selectinload(Subscription.tariff),
|
||||
)
|
||||
.where(
|
||||
User.telegram_id.is_(None),
|
||||
User.auth_type == 'telegram',
|
||||
func.lower(User.username) == normalized,
|
||||
)
|
||||
.with_for_update()
|
||||
)
|
||||
return result.scalars().first()
|
||||
|
||||
|
||||
async def get_user_by_username(db: AsyncSession, username: str) -> User | None:
|
||||
if not username:
|
||||
return None
|
||||
@@ -386,6 +410,28 @@ async def update_user(db: AsyncSession, user: User, **kwargs) -> User:
|
||||
return user
|
||||
|
||||
|
||||
async def lock_user_for_update(db: AsyncSession, user: User) -> User:
|
||||
"""Lock user row with SELECT FOR UPDATE to prevent concurrent balance modifications.
|
||||
|
||||
Returns the refreshed user object with current DB values.
|
||||
Must be called within an active transaction before modifying balance_kopeks.
|
||||
Eagerly loads key relationships to avoid MissingGreenlet in async context.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.where(User.id == user.id)
|
||||
.options(
|
||||
selectinload(User.subscription),
|
||||
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
|
||||
selectinload(User.promo_group),
|
||||
selectinload(User.referrer),
|
||||
)
|
||||
.with_for_update()
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
return result.scalar_one()
|
||||
|
||||
|
||||
async def add_user_balance(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
@@ -397,6 +443,22 @@ async def add_user_balance(
|
||||
payment_method: PaymentMethod | None = None,
|
||||
) -> bool:
|
||||
try:
|
||||
# Lock the user row to prevent concurrent balance race conditions
|
||||
# Eagerly load key relationships to avoid MissingGreenlet in async context
|
||||
locked_result = await db.execute(
|
||||
select(User)
|
||||
.where(User.id == user.id)
|
||||
.options(
|
||||
selectinload(User.subscription),
|
||||
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
|
||||
selectinload(User.promo_group),
|
||||
selectinload(User.referrer),
|
||||
)
|
||||
.with_for_update()
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
user = locked_result.scalar_one()
|
||||
|
||||
old_balance = user.balance_kopeks
|
||||
user.balance_kopeks += amount_kopeks
|
||||
user.updated_at = datetime.now(UTC)
|
||||
@@ -425,40 +487,10 @@ async def add_user_balance(
|
||||
amount_kopeks=amount_kopeks,
|
||||
)
|
||||
|
||||
# Автоматическое возобновление приостановленной суточной подписки
|
||||
try:
|
||||
from app.database.crud.subscription import get_subscription_by_user_id, resume_daily_subscription
|
||||
from app.database.crud.tariff import get_tariff_by_id
|
||||
from app.database.models import SubscriptionStatus
|
||||
|
||||
# Загружаем подписку явно, чтобы избежать lazy loading
|
||||
subscription = await get_subscription_by_user_id(db, user.id)
|
||||
if subscription and subscription.status == SubscriptionStatus.DISABLED.value:
|
||||
# Проверяем что это суточный тариф
|
||||
is_daily = getattr(subscription, 'is_daily_tariff', False)
|
||||
if is_daily and subscription.tariff_id:
|
||||
# Загружаем тариф явно
|
||||
tariff = await get_tariff_by_id(db, subscription.tariff_id)
|
||||
if tariff:
|
||||
daily_price = getattr(tariff, 'daily_price_kopeks', 0)
|
||||
# Если баланс достаточный для суточной оплаты - возобновляем
|
||||
if daily_price > 0 and user.balance_kopeks >= daily_price:
|
||||
await resume_daily_subscription(db, subscription)
|
||||
logger.info(
|
||||
'✅ Автоматически возобновлена суточная подписка после пополнения баланса (user_id=)',
|
||||
subscription_id=subscription.id,
|
||||
user_id=user.id,
|
||||
)
|
||||
# Синхронизируем с RemnaWave
|
||||
try:
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.update_remnawave_user(db, subscription)
|
||||
except Exception as sync_err:
|
||||
logger.warning('Не удалось синхронизировать с RemnaWave', sync_err=sync_err)
|
||||
except Exception as resume_err:
|
||||
logger.warning('Ошибка при попытке возобновить суточную подписку', resume_err=resume_err)
|
||||
# Авто-возобновление суточной подписки НЕ делаем здесь —
|
||||
# это обязанность try_resume_disabled_daily_after_topup (через send_cart_notification_after_topup)
|
||||
# и DailySubscriptionService.process_auto_resume (30-минутный цикл).
|
||||
# Они корректно списывают суточную плату при возобновлении.
|
||||
|
||||
return True
|
||||
|
||||
@@ -504,17 +536,37 @@ async def subtract_user_balance(
|
||||
create_transaction: bool = False,
|
||||
payment_method: PaymentMethod | None = None,
|
||||
*,
|
||||
transaction_type: TransactionType = TransactionType.WITHDRAWAL,
|
||||
consume_promo_offer: bool = False,
|
||||
mark_as_paid_subscription: bool = False,
|
||||
commit: bool = True,
|
||||
) -> bool:
|
||||
user_id_display = user.telegram_id or user.email or f'#{user.id}'
|
||||
logger.info('💸 ОТЛАДКА subtract_user_balance:')
|
||||
logger.info('👤 User ID: (ID: )', user_id=user.id, user_id_display=user_id_display)
|
||||
logger.info('💰 Баланс до списания: копеек', balance_kopeks=user.balance_kopeks)
|
||||
logger.info('💸 Сумма к списанию: копеек', amount_kopeks=amount_kopeks)
|
||||
logger.info('📝 Описание', description=description)
|
||||
if amount_kopeks < 0:
|
||||
logger.error('subtract_user_balance called with negative amount', amount_kopeks=amount_kopeks, user_id=user.id)
|
||||
return False
|
||||
|
||||
logger.debug(
|
||||
'subtract_user_balance called',
|
||||
user_id=user.id,
|
||||
balance_kopeks=user.balance_kopeks,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=description,
|
||||
)
|
||||
|
||||
# Lock the user row to prevent concurrent balance race conditions
|
||||
locked_result = await db.execute(select(User).where(User.id == user.id).with_for_update())
|
||||
# Eagerly load key relationships to avoid MissingGreenlet in async context
|
||||
locked_result = await db.execute(
|
||||
select(User)
|
||||
.where(User.id == user.id)
|
||||
.options(
|
||||
selectinload(User.subscription),
|
||||
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
|
||||
selectinload(User.promo_group),
|
||||
selectinload(User.referrer),
|
||||
)
|
||||
.with_for_update()
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
user = locked_result.scalar_one()
|
||||
|
||||
log_context: dict[str, object] | None = None
|
||||
@@ -564,6 +616,9 @@ async def subtract_user_balance(
|
||||
user.promo_offer_discount_source = None
|
||||
user.promo_offer_discount_expires_at = None
|
||||
|
||||
if mark_as_paid_subscription:
|
||||
user.has_had_paid_subscription = True
|
||||
|
||||
user.updated_at = datetime.now(UTC)
|
||||
|
||||
if create_transaction:
|
||||
@@ -571,20 +626,22 @@ async def subtract_user_balance(
|
||||
create_transaction as create_trans,
|
||||
)
|
||||
|
||||
# create_trans commits the session, atomically persisting
|
||||
# both the balance change and the transaction record
|
||||
await create_trans(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
type=TransactionType.WITHDRAWAL,
|
||||
type=transaction_type,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=description,
|
||||
payment_method=payment_method,
|
||||
commit=commit,
|
||||
)
|
||||
else:
|
||||
elif commit:
|
||||
await db.commit()
|
||||
else:
|
||||
await db.flush()
|
||||
|
||||
await db.refresh(user)
|
||||
if commit:
|
||||
await db.refresh(user)
|
||||
|
||||
if consume_promo_offer and log_context:
|
||||
try:
|
||||
@@ -597,26 +654,30 @@ async def subtract_user_balance(
|
||||
percent=log_context.get('percent'),
|
||||
effect_type=log_context.get('effect_type'),
|
||||
details=log_context.get('details'),
|
||||
commit=commit,
|
||||
)
|
||||
except Exception as log_error: # pragma: no cover - defensive logging
|
||||
logger.warning(
|
||||
'Failed to record promo offer consumption log for user', user_id=user.id, log_error=log_error
|
||||
)
|
||||
try:
|
||||
await db.rollback()
|
||||
except Exception as rollback_error: # pragma: no cover - defensive logging
|
||||
logger.warning(
|
||||
'Failed to rollback session after promo offer consumption log failure',
|
||||
rollback_error=rollback_error,
|
||||
)
|
||||
if commit:
|
||||
try:
|
||||
await db.rollback()
|
||||
except Exception as rollback_error: # pragma: no cover - defensive logging
|
||||
logger.warning(
|
||||
'Failed to rollback session after promo offer consumption log failure',
|
||||
rollback_error=rollback_error,
|
||||
)
|
||||
|
||||
logger.info('✅ Средства списаны: →', old_balance=old_balance, balance_kopeks=user.balance_kopeks)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error('❌ ОШИБКА СПИСАНИЯ', error=e)
|
||||
await db.rollback()
|
||||
return False
|
||||
if commit:
|
||||
await db.rollback()
|
||||
return False
|
||||
raise
|
||||
|
||||
|
||||
async def cleanup_expired_promo_offer_discounts(db: AsyncSession) -> int:
|
||||
@@ -1130,8 +1191,11 @@ async def create_user_by_email(
|
||||
|
||||
|
||||
async def get_user_by_email(db: AsyncSession, email: str) -> User | None:
|
||||
"""Get user by email address."""
|
||||
result = await db.execute(select(User).where(User.email == email))
|
||||
"""Get user by email address (case-insensitive)."""
|
||||
if not email or not email.strip():
|
||||
return None
|
||||
email_lower = email.strip().lower()
|
||||
result = await db.execute(select(User).where(func.lower(User.email) == email_lower))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
@@ -1147,7 +1211,10 @@ async def is_email_taken(db: AsyncSession, email: str, exclude_user_id: int | No
|
||||
Returns:
|
||||
True if email is taken, False otherwise
|
||||
"""
|
||||
query = select(User.id).where(User.email == email)
|
||||
if not email or not email.strip():
|
||||
return False
|
||||
email_lower = email.strip().lower()
|
||||
query = select(User.id).where(func.lower(User.email) == email_lower)
|
||||
if exclude_user_id:
|
||||
query = query.where(User.id != exclude_user_id)
|
||||
result = await db.execute(query)
|
||||
@@ -1259,7 +1326,9 @@ async def clear_email_change_pending(db: AsyncSession, user: User) -> None:
|
||||
|
||||
# --- OAuth provider functions ---
|
||||
|
||||
_OAUTH_PROVIDER_COLUMNS = {
|
||||
# Single source of truth: provider name → User model column name.
|
||||
# Imported by account_linking.py and account_merge_service.py.
|
||||
OAUTH_PROVIDER_COLUMNS: dict[str, str] = {
|
||||
'google': 'google_id',
|
||||
'yandex': 'yandex_id',
|
||||
'discord': 'discord_id',
|
||||
@@ -1269,8 +1338,9 @@ _OAUTH_PROVIDER_COLUMNS = {
|
||||
|
||||
async def get_user_by_oauth_provider(db: AsyncSession, provider: str, provider_id: str) -> User | None:
|
||||
"""Find a user by OAuth provider ID."""
|
||||
column_name = _OAUTH_PROVIDER_COLUMNS.get(provider)
|
||||
column_name = OAUTH_PROVIDER_COLUMNS.get(provider)
|
||||
if not column_name:
|
||||
logger.warning('Unknown OAuth provider in lookup', provider=provider)
|
||||
return None
|
||||
column = getattr(User, column_name)
|
||||
# VK uses BigInteger, so convert
|
||||
@@ -1281,13 +1351,25 @@ async def get_user_by_oauth_provider(db: AsyncSession, provider: str, provider_i
|
||||
|
||||
async def set_user_oauth_provider_id(db: AsyncSession, user: User, provider: str, provider_id: str) -> None:
|
||||
"""Link an OAuth provider ID to an existing user."""
|
||||
column_name = _OAUTH_PROVIDER_COLUMNS.get(provider)
|
||||
column_name = OAUTH_PROVIDER_COLUMNS.get(provider)
|
||||
if not column_name:
|
||||
logger.warning('Unknown OAuth provider in set', provider=provider, user_id=user.id)
|
||||
return
|
||||
value: str | int = int(provider_id) if provider == 'vk' else provider_id
|
||||
setattr(user, column_name, value)
|
||||
user.updated_at = datetime.now(UTC)
|
||||
logger.info('Linked (id=) to user', provider=provider, provider_id=provider_id, user_id=user.id)
|
||||
logger.info('OAuth provider linked to user', provider=provider, provider_id=provider_id, user_id=user.id)
|
||||
|
||||
|
||||
async def clear_user_oauth_provider_id(db: AsyncSession, user: User, provider: str) -> None:
|
||||
"""Unlink an OAuth provider from an existing user (set column to None)."""
|
||||
column_name = OAUTH_PROVIDER_COLUMNS.get(provider)
|
||||
if not column_name:
|
||||
logger.warning('Unknown OAuth provider in clear', provider=provider, user_id=user.id)
|
||||
return
|
||||
setattr(user, column_name, None)
|
||||
user.updated_at = datetime.now(UTC)
|
||||
logger.info('Unlinked OAuth provider from user', provider=provider, user_id=user.id)
|
||||
|
||||
|
||||
async def create_user_by_oauth(
|
||||
@@ -1307,7 +1389,7 @@ async def create_user_by_oauth(
|
||||
normalized_language = _normalize_language_code(language)
|
||||
default_group = await _get_or_create_default_promo_group(db)
|
||||
|
||||
column_name = _OAUTH_PROVIDER_COLUMNS.get(provider)
|
||||
column_name = OAUTH_PROVIDER_COLUMNS.get(provider)
|
||||
provider_value: str | int = int(provider_id) if provider == 'vk' else provider_id
|
||||
|
||||
user = User(
|
||||
|
||||
@@ -40,6 +40,7 @@ async def _sync_user_primary_promo_group(
|
||||
|
||||
except Exception as error:
|
||||
logger.error('Ошибка синхронизации primary промогруппы пользователя', user_id=user_id, error=error)
|
||||
raise
|
||||
|
||||
|
||||
async def sync_user_primary_promo_group(
|
||||
@@ -187,7 +188,7 @@ async def get_primary_user_promo_group(db: AsyncSession, user_id: int) -> PromoG
|
||||
return None
|
||||
|
||||
# Первая в списке имеет максимальный приоритет (список уже отсортирован)
|
||||
return user_promo_groups[0].promo_group if user_promo_groups[0].promo_group else None
|
||||
return user_promo_groups[0].promo_group or None
|
||||
|
||||
except Exception as error:
|
||||
logger.error('Ошибка получения primary промогруппы пользователя', user_id=user_id, error=error)
|
||||
|
||||
@@ -16,7 +16,7 @@ logger = structlog.get_logger(__name__)
|
||||
async def create_wata_payment(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int,
|
||||
user_id: int | None,
|
||||
payment_link_id: str,
|
||||
amount_kopeks: int,
|
||||
currency: str,
|
||||
@@ -71,6 +71,11 @@ async def get_wata_payment_by_id(
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_wata_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> WataPayment | None:
|
||||
result = await db.execute(select(WataPayment).where(WataPayment.id == payment_id).with_for_update())
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_wata_payment_by_link_id(
|
||||
db: AsyncSession,
|
||||
payment_link_id: str,
|
||||
@@ -143,7 +148,7 @@ async def link_wata_payment_to_transaction(
|
||||
transaction_id: int,
|
||||
) -> WataPayment:
|
||||
await db.execute(update(WataPayment).where(WataPayment.id == payment.id).values(transaction_id=transaction_id))
|
||||
await db.commit()
|
||||
await db.flush()
|
||||
await db.refresh(payment)
|
||||
|
||||
logger.info(
|
||||
|
||||
@@ -14,7 +14,7 @@ logger = structlog.get_logger(__name__)
|
||||
|
||||
async def create_yookassa_payment(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
user_id: int | None,
|
||||
yookassa_payment_id: str,
|
||||
amount_kopeks: int,
|
||||
currency: str,
|
||||
@@ -127,7 +127,7 @@ async def link_yookassa_payment_to_transaction(
|
||||
.where(YooKassaPayment.yookassa_payment_id == yookassa_payment_id)
|
||||
.values(transaction_id=transaction_id, updated_at=datetime.now(UTC))
|
||||
)
|
||||
await db.commit()
|
||||
await db.flush()
|
||||
|
||||
result = await db.execute(
|
||||
select(YooKassaPayment)
|
||||
|
||||
@@ -23,26 +23,58 @@ def _get_alembic_config() -> Config:
|
||||
return cfg
|
||||
|
||||
|
||||
async def _needs_auto_stamp() -> bool:
|
||||
"""Check if DB has existing tables but no alembic_version (transition from universal_migration)."""
|
||||
async def _detect_db_state() -> str:
|
||||
"""Detect database state: 'fresh', 'legacy', or 'managed'.
|
||||
|
||||
- fresh: no tables at all — brand new database
|
||||
- legacy: has tables but no alembic_version (transition from universal_migration)
|
||||
- managed: has alembic_version — already managed by Alembic
|
||||
"""
|
||||
from app.database.database import engine
|
||||
|
||||
async with engine.connect() as conn:
|
||||
has_alembic = await conn.run_sync(lambda sync_conn: inspect(sync_conn).has_table('alembic_version'))
|
||||
if has_alembic:
|
||||
return False
|
||||
return 'managed'
|
||||
has_users = await conn.run_sync(lambda sync_conn: inspect(sync_conn).has_table('users'))
|
||||
return has_users
|
||||
return 'legacy' if has_users else 'fresh'
|
||||
|
||||
|
||||
_INITIAL_REVISION = '0001'
|
||||
|
||||
|
||||
async def _bootstrap_fresh_db() -> None:
|
||||
"""Bootstrap a fresh database: create all tables from models and stamp at head.
|
||||
|
||||
On a fresh DB, running all migrations sequentially would fail because
|
||||
migration 0001 uses Base.metadata.create_all() which creates ALL tables
|
||||
from the current models.py (including columns/constraints/indexes added
|
||||
by later migrations), and then those later migrations try to re-create
|
||||
the same objects. Instead, we create the full schema directly and stamp
|
||||
the migration history at HEAD so Alembic considers all migrations applied.
|
||||
"""
|
||||
from app.database.database import engine
|
||||
from app.database.models import Base
|
||||
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
logger.info('Свежая БД: все таблицы созданы из моделей')
|
||||
|
||||
|
||||
async def run_alembic_upgrade() -> None:
|
||||
"""Run ``alembic upgrade head``, auto-stamping existing databases first."""
|
||||
"""Run ``alembic upgrade head``, handling fresh and legacy databases."""
|
||||
import asyncio
|
||||
|
||||
if await _needs_auto_stamp():
|
||||
db_state = await _detect_db_state()
|
||||
|
||||
if db_state == 'fresh':
|
||||
logger.warning('Обнаружена пустая БД — создание схемы из моделей + stamp head')
|
||||
await _bootstrap_fresh_db()
|
||||
await _stamp_alembic_revision('head')
|
||||
return
|
||||
|
||||
if db_state == 'legacy':
|
||||
logger.warning(
|
||||
'Обнаружена существующая БД без alembic_version — автоматический stamp 0001 (переход с universal_migration)'
|
||||
)
|
||||
|
||||
+296
-34
@@ -8,12 +8,13 @@ def _aware(dt: datetime | None) -> datetime | None:
|
||||
return dt
|
||||
|
||||
|
||||
from enum import Enum
|
||||
from enum import Enum, StrEnum
|
||||
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
BigInteger,
|
||||
Boolean,
|
||||
CheckConstraint,
|
||||
Column,
|
||||
Date,
|
||||
DateTime,
|
||||
@@ -122,6 +123,7 @@ class SubscriptionStatus(Enum):
|
||||
ACTIVE = 'active'
|
||||
EXPIRED = 'expired'
|
||||
DISABLED = 'disabled'
|
||||
LIMITED = 'limited'
|
||||
PENDING = 'pending'
|
||||
|
||||
|
||||
@@ -132,6 +134,7 @@ class TransactionType(Enum):
|
||||
REFUND = 'refund'
|
||||
REFERRAL_REWARD = 'referral_reward'
|
||||
POLL_REWARD = 'poll_reward'
|
||||
GIFT_PAYMENT = 'gift_payment'
|
||||
|
||||
|
||||
class PromoCodeType(Enum):
|
||||
@@ -155,6 +158,7 @@ class PaymentMethod(Enum):
|
||||
CLOUDPAYMENTS = 'cloudpayments'
|
||||
FREEKASSA = 'freekassa'
|
||||
KASSA_AI = 'kassa_ai'
|
||||
RIOPAY = 'riopay'
|
||||
MANUAL = 'manual'
|
||||
BALANCE = 'balance'
|
||||
|
||||
@@ -191,7 +195,7 @@ class YooKassaPayment(Base):
|
||||
__tablename__ = 'yookassa_payments'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey('users.id'), nullable=False)
|
||||
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=True)
|
||||
yookassa_payment_id = Column(String(255), unique=True, nullable=False, index=True)
|
||||
amount_kopeks = Column(Integer, nullable=False)
|
||||
currency = Column(String(3), default='RUB', nullable=False)
|
||||
@@ -236,11 +240,43 @@ class YooKassaPayment(Base):
|
||||
return f'<YooKassaPayment(id={self.id}, yookassa_id={self.yookassa_payment_id}, amount={self.amount_rubles}₽, status={self.status})>'
|
||||
|
||||
|
||||
class SavedPaymentMethod(Base):
|
||||
__tablename__ = 'saved_payment_methods'
|
||||
__table_args__ = (Index('ix_saved_payment_methods_user_active', 'user_id', 'is_active'),)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey('users.id'), nullable=False, index=True)
|
||||
|
||||
# YooKassa payment_method.id — ключ для рекуррентных списаний
|
||||
yookassa_payment_method_id = Column(String(255), unique=True, nullable=False, index=True)
|
||||
|
||||
# Тип метода: bank_card, yoo_money, sberbank, tinkoff_bank, sbp, mir_pay
|
||||
method_type = Column(String(50), nullable=False, default='bank_card')
|
||||
|
||||
# Отображаемые данные карты (маскированные)
|
||||
card_first6 = Column(String(6), nullable=True)
|
||||
card_last4 = Column(String(4), nullable=True)
|
||||
card_type = Column(String(50), nullable=True) # Visa, MasterCard, Mir
|
||||
card_expiry_month = Column(String(2), nullable=True)
|
||||
card_expiry_year = Column(String(4), nullable=True)
|
||||
title = Column(String(255), nullable=True) # "Bank card *4444"
|
||||
|
||||
is_active = Column(Boolean, default=True)
|
||||
|
||||
created_at = Column(AwareDateTime(), nullable=False, server_default=func.now())
|
||||
updated_at = Column(AwareDateTime(), nullable=False, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
user = relationship('User', backref='saved_payment_methods')
|
||||
|
||||
def __repr__(self):
|
||||
return f'<SavedPaymentMethod(id={self.id}, user_id={self.user_id}, type={self.method_type}, last4={self.card_last4})>'
|
||||
|
||||
|
||||
class CryptoBotPayment(Base):
|
||||
__tablename__ = 'cryptobot_payments'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey('users.id'), nullable=False)
|
||||
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=True)
|
||||
|
||||
invoice_id = Column(String(255), unique=True, nullable=False, index=True)
|
||||
amount = Column(String(50), nullable=False)
|
||||
@@ -290,7 +326,7 @@ class HeleketPayment(Base):
|
||||
__tablename__ = 'heleket_payments'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey('users.id'), nullable=False)
|
||||
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=True)
|
||||
|
||||
uuid = Column(String(255), unique=True, nullable=False, index=True)
|
||||
order_id = Column(String(128), unique=True, nullable=False, index=True)
|
||||
@@ -349,7 +385,7 @@ class MulenPayPayment(Base):
|
||||
__tablename__ = 'mulenpay_payments'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey('users.id'), nullable=False)
|
||||
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=True)
|
||||
|
||||
mulen_payment_id = Column(Integer, nullable=True, index=True)
|
||||
uuid = Column(String(255), unique=True, nullable=False, index=True)
|
||||
@@ -385,7 +421,7 @@ class Pal24Payment(Base):
|
||||
__tablename__ = 'pal24_payments'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey('users.id'), nullable=False)
|
||||
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=True)
|
||||
|
||||
bill_id = Column(String(255), unique=True, nullable=False, index=True)
|
||||
order_id = Column(String(255), nullable=True, index=True)
|
||||
@@ -442,7 +478,7 @@ class WataPayment(Base):
|
||||
__tablename__ = 'wata_payments'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey('users.id'), nullable=False)
|
||||
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=True)
|
||||
|
||||
payment_link_id = Column(String(64), unique=True, nullable=False, index=True)
|
||||
order_id = Column(String(255), nullable=True, index=True)
|
||||
@@ -485,7 +521,7 @@ class PlategaPayment(Base):
|
||||
__tablename__ = 'platega_payments'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey('users.id'), nullable=False)
|
||||
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=True)
|
||||
|
||||
platega_transaction_id = Column(String(255), unique=True, nullable=True, index=True)
|
||||
correlation_id = Column(String(64), unique=True, nullable=False, index=True)
|
||||
@@ -527,7 +563,7 @@ class CloudPaymentsPayment(Base):
|
||||
__tablename__ = 'cloudpayments_payments'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey('users.id'), nullable=False)
|
||||
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=True)
|
||||
|
||||
# CloudPayments идентификаторы
|
||||
transaction_id_cp = Column(BigInteger, unique=True, nullable=True, index=True) # TransactionId от CloudPayments
|
||||
@@ -596,7 +632,7 @@ class FreekassaPayment(Base):
|
||||
__tablename__ = 'freekassa_payments'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey('users.id'), nullable=False)
|
||||
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=True)
|
||||
|
||||
# Идентификаторы
|
||||
order_id = Column(String(64), unique=True, nullable=False, index=True) # Наш ID заказа
|
||||
@@ -658,7 +694,7 @@ class KassaAiPayment(Base):
|
||||
__tablename__ = 'kassa_ai_payments'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey('users.id'), nullable=False)
|
||||
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=True)
|
||||
|
||||
# Идентификаторы
|
||||
order_id = Column(String(64), unique=True, nullable=False, index=True) # Наш ID заказа
|
||||
@@ -714,6 +750,68 @@ class KassaAiPayment(Base):
|
||||
return f'<KassaAiPayment(id={self.id}, order_id={self.order_id}, amount={self.amount_rubles}₽, status={self.status})>'
|
||||
|
||||
|
||||
class RioPayPayment(Base):
|
||||
"""Платежи через RioPay (api.riopay.online)."""
|
||||
|
||||
__tablename__ = 'riopay_payments'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey('users.id'), nullable=False, index=True)
|
||||
|
||||
# Идентификаторы
|
||||
order_id = Column(String(64), unique=True, nullable=False, index=True) # Наш internal ID
|
||||
riopay_order_id = Column(String(64), unique=True, nullable=True, index=True) # UUID от RioPay
|
||||
|
||||
# Суммы
|
||||
amount_kopeks = Column(Integer, nullable=False)
|
||||
currency = Column(String(10), nullable=False, default='RUB')
|
||||
description = Column(Text, nullable=True)
|
||||
|
||||
# Статусы
|
||||
status = Column(String(32), nullable=False, default='pending') # pending, success, failed, expired, canceled
|
||||
is_paid = Column(Boolean, default=False)
|
||||
|
||||
# Данные платежа
|
||||
payment_url = Column(Text, nullable=True)
|
||||
payment_method = Column(String(32), nullable=True) # CARD, SBP
|
||||
|
||||
# Метаданные
|
||||
metadata_json = Column(JSON, nullable=True)
|
||||
callback_payload = Column(JSON, nullable=True)
|
||||
|
||||
# Временные метки
|
||||
paid_at = Column(AwareDateTime(), nullable=True)
|
||||
expires_at = Column(AwareDateTime(), nullable=True)
|
||||
created_at = Column(AwareDateTime(), default=func.now())
|
||||
updated_at = Column(AwareDateTime(), default=func.now(), onupdate=func.now())
|
||||
|
||||
# Связь с транзакцией
|
||||
transaction_id = Column(Integer, ForeignKey('transactions.id'), nullable=True)
|
||||
|
||||
# Relationships
|
||||
user = relationship('User', backref='riopay_payments')
|
||||
transaction = relationship('Transaction', backref='riopay_payment')
|
||||
|
||||
@property
|
||||
def amount_rubles(self) -> float:
|
||||
return self.amount_kopeks / 100
|
||||
|
||||
@property
|
||||
def is_pending(self) -> bool:
|
||||
return self.status == 'pending'
|
||||
|
||||
@property
|
||||
def is_success(self) -> bool:
|
||||
return self.status == 'success' and self.is_paid
|
||||
|
||||
@property
|
||||
def is_failed(self) -> bool:
|
||||
return self.status in ['failed', 'expired', 'canceled']
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover - debug helper
|
||||
return f'<RioPayPayment(id={self.id}, order_id={self.order_id}, amount={self.amount_rubles}₽, status={self.status})>'
|
||||
|
||||
|
||||
class PromoGroup(Base):
|
||||
__tablename__ = 'promo_groups'
|
||||
|
||||
@@ -877,9 +975,15 @@ class Tariff(Base):
|
||||
min_traffic_gb = Column(Integer, default=1, nullable=False) # Минимальный трафик в ГБ
|
||||
max_traffic_gb = Column(Integer, default=1000, nullable=False) # Максимальный трафик в ГБ
|
||||
|
||||
# Видимость в разделе подарков
|
||||
show_in_gift = Column(Boolean, default=True, server_default='true', nullable=False)
|
||||
|
||||
# Режим сброса трафика: DAY, WEEK, MONTH, NO_RESET (по умолчанию берётся из конфига)
|
||||
traffic_reset_mode = Column(String(20), nullable=True, default=None) # None = использовать глобальную настройку
|
||||
|
||||
# Внешний сквад RemnaWave (UUID) — назначается пользователю при создании подписки
|
||||
external_squad_uuid = Column(String(255), nullable=True, default=None)
|
||||
|
||||
created_at = Column(AwareDateTime(), default=func.now())
|
||||
updated_at = Column(AwareDateTime(), default=func.now(), onupdate=func.now())
|
||||
|
||||
@@ -908,6 +1012,11 @@ class Tariff(Base):
|
||||
prices = self.period_prices or {}
|
||||
return sorted([int(p) for p in prices.keys()])
|
||||
|
||||
def get_shortest_period(self) -> int | None:
|
||||
"""Возвращает минимальный доступный период в днях (для автопродления)."""
|
||||
periods = self.get_available_periods()
|
||||
return periods[0] if periods else None
|
||||
|
||||
def get_price_rubles(self, period_days: int) -> float | None:
|
||||
"""Возвращает цену в рублях для указанного периода."""
|
||||
price_kopeks = self.get_price_for_period(period_days)
|
||||
@@ -1012,7 +1121,7 @@ class User(Base):
|
||||
balance_kopeks = Column(Integer, default=0)
|
||||
used_promocodes = Column(Integer, default=0)
|
||||
has_had_paid_subscription = Column(Boolean, default=False, nullable=False)
|
||||
referred_by_id = Column(Integer, ForeignKey('users.id'), nullable=True, index=True)
|
||||
referred_by_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True, index=True)
|
||||
referral_code = Column(String(20), unique=True, nullable=True)
|
||||
created_at = Column(AwareDateTime(), default=func.now())
|
||||
updated_at = Column(AwareDateTime(), default=func.now(), onupdate=func.now())
|
||||
@@ -1039,7 +1148,9 @@ class User(Base):
|
||||
discord_id = Column(String(255), unique=True, nullable=True, index=True)
|
||||
vk_id = Column(BigInteger, unique=True, nullable=True, index=True)
|
||||
broadcasts = relationship('BroadcastHistory', back_populates='admin')
|
||||
referrals = relationship('User', backref='referrer', remote_side=[id], foreign_keys='User.referred_by_id')
|
||||
referrals = relationship(
|
||||
'User', backref='referrer', remote_side=[id], foreign_keys='User.referred_by_id', post_update=True
|
||||
)
|
||||
subscription = relationship('Subscription', back_populates='user', uselist=False)
|
||||
transactions = relationship('Transaction', back_populates='user')
|
||||
referral_earnings = relationship('ReferralEarning', foreign_keys='ReferralEarning.user_id', back_populates='user')
|
||||
@@ -1114,10 +1225,10 @@ class User(Base):
|
||||
|
||||
def get_primary_promo_group(self):
|
||||
"""Возвращает промогруппу с максимальным приоритетом."""
|
||||
if not self.user_promo_groups:
|
||||
return getattr(self, 'promo_group', None)
|
||||
|
||||
try:
|
||||
if not self.user_promo_groups:
|
||||
return getattr(self, 'promo_group', None)
|
||||
|
||||
# Сортируем по приоритету группы (убывание), затем по ID группы
|
||||
# Используем getattr для защиты от ленивой загрузки
|
||||
sorted_groups = sorted(
|
||||
@@ -1129,7 +1240,7 @@ class User(Base):
|
||||
if sorted_groups and sorted_groups[0].promo_group:
|
||||
return sorted_groups[0].promo_group
|
||||
except Exception:
|
||||
# Если возникла ошибка (например, ленивая загрузка), fallback на старую связь
|
||||
# Если возникла ошибка (например, ленивая загрузка в async), fallback на старую связь
|
||||
pass
|
||||
|
||||
# Fallback на старую связь если новая пустая или возникла ошибка
|
||||
@@ -1153,9 +1264,13 @@ class User(Base):
|
||||
|
||||
class Subscription(Base):
|
||||
__tablename__ = 'subscriptions'
|
||||
__table_args__ = (
|
||||
Index('ix_subscriptions_status_trial', 'status', 'is_trial'),
|
||||
Index('ix_subscriptions_trial_created', 'is_trial', 'created_at'),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey('users.id'), nullable=False, unique=True)
|
||||
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False, unique=True)
|
||||
|
||||
status = Column(String(20), default=SubscriptionStatus.TRIAL.value)
|
||||
is_trial = Column(Boolean, default=True)
|
||||
@@ -1236,6 +1351,9 @@ class Subscription(Base):
|
||||
if self.status == SubscriptionStatus.DISABLED.value:
|
||||
return 'disabled'
|
||||
|
||||
if self.status == SubscriptionStatus.LIMITED.value:
|
||||
return 'limited'
|
||||
|
||||
if self.status == SubscriptionStatus.ACTIVE.value:
|
||||
if end is None or end <= current_time:
|
||||
return 'expired'
|
||||
@@ -1260,6 +1378,8 @@ class Subscription(Base):
|
||||
return '🟢 Активна'
|
||||
if actual_status == 'disabled':
|
||||
return '⚫ Отключена'
|
||||
if actual_status == 'limited':
|
||||
return '⚠️ Трафик исчерпан'
|
||||
if actual_status == 'trial':
|
||||
return '🎯 Тестовая'
|
||||
|
||||
@@ -1277,6 +1397,8 @@ class Subscription(Base):
|
||||
return '💎'
|
||||
if actual_status == 'disabled':
|
||||
return '⚫'
|
||||
if actual_status == 'limited':
|
||||
return '⚠️'
|
||||
if actual_status == 'trial':
|
||||
return '🎁'
|
||||
|
||||
@@ -1325,7 +1447,7 @@ class Subscription(Base):
|
||||
else:
|
||||
self.end_date = datetime.now(UTC) + timedelta(days=days)
|
||||
|
||||
if self.status == SubscriptionStatus.EXPIRED.value:
|
||||
if self.status in (SubscriptionStatus.EXPIRED.value, SubscriptionStatus.LIMITED.value):
|
||||
self.status = SubscriptionStatus.ACTIVE.value
|
||||
|
||||
def add_traffic(self, gb: int):
|
||||
@@ -1363,6 +1485,7 @@ class TrafficPurchase(Base):
|
||||
"""Докупка трафика с индивидуальной датой истечения."""
|
||||
|
||||
__tablename__ = 'traffic_purchases'
|
||||
__table_args__ = (Index('ix_traffic_purchases_created_at', 'created_at'),)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
subscription_id = Column(Integer, ForeignKey('subscriptions.id', ondelete='CASCADE'), nullable=False, index=True)
|
||||
@@ -1382,9 +1505,15 @@ class TrafficPurchase(Base):
|
||||
|
||||
class Transaction(Base):
|
||||
__tablename__ = 'transactions'
|
||||
__table_args__ = (
|
||||
UniqueConstraint('external_id', 'payment_method', name='uq_transaction_external_id_method'),
|
||||
Index('ix_transactions_type_created_completed', 'type', 'created_at', 'is_completed'),
|
||||
Index('ix_transactions_user_created', 'user_id', 'created_at'),
|
||||
Index('ix_transactions_type_method_created', 'type', 'payment_method', 'created_at'),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey('users.id'), nullable=False)
|
||||
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False)
|
||||
|
||||
type = Column(String(50), nullable=False)
|
||||
amount_kopeks = Column(Integer, nullable=False)
|
||||
@@ -1411,9 +1540,13 @@ class Transaction(Base):
|
||||
|
||||
class SubscriptionConversion(Base):
|
||||
__tablename__ = 'subscription_conversions'
|
||||
__table_args__ = (
|
||||
Index('ix_sub_conversions_converted_at', 'converted_at'),
|
||||
Index('ix_sub_conversions_user_id', 'user_id'),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey('users.id'), nullable=False)
|
||||
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False)
|
||||
|
||||
converted_at = Column(AwareDateTime(), default=func.now())
|
||||
|
||||
@@ -1457,7 +1590,7 @@ class PromoCode(Base):
|
||||
is_active = Column(Boolean, default=True)
|
||||
first_purchase_only = Column(Boolean, default=False) # Только для первой покупки
|
||||
|
||||
created_by = Column(Integer, ForeignKey('users.id'), nullable=True)
|
||||
created_by = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
|
||||
promo_group_id = Column(Integer, ForeignKey('promo_groups.id', ondelete='SET NULL'), nullable=True, index=True)
|
||||
|
||||
created_at = Column(AwareDateTime(), default=func.now())
|
||||
@@ -1483,10 +1616,11 @@ class PromoCode(Base):
|
||||
|
||||
class PromoCodeUse(Base):
|
||||
__tablename__ = 'promocode_uses'
|
||||
__table_args__ = (UniqueConstraint('user_id', 'promocode_id', name='uq_promocode_uses_user_promo'),)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
promocode_id = Column(Integer, ForeignKey('promocodes.id'), nullable=False)
|
||||
user_id = Column(Integer, ForeignKey('users.id'), nullable=False)
|
||||
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False)
|
||||
|
||||
used_at = Column(AwareDateTime(), default=func.now())
|
||||
|
||||
@@ -1498,8 +1632,8 @@ class ReferralEarning(Base):
|
||||
__tablename__ = 'referral_earnings'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey('users.id'), nullable=False, index=True)
|
||||
referral_id = Column(Integer, ForeignKey('users.id'), nullable=False, index=True)
|
||||
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False, index=True)
|
||||
referral_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False, index=True)
|
||||
|
||||
amount_kopeks = Column(Integer, nullable=False)
|
||||
reason = Column(String(100), nullable=False)
|
||||
@@ -1537,7 +1671,7 @@ class WithdrawalRequest(Base):
|
||||
__tablename__ = 'withdrawal_requests'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey('users.id'), nullable=False, index=True)
|
||||
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False, index=True)
|
||||
|
||||
amount_kopeks = Column(Integer, nullable=False) # Сумма к выводу
|
||||
status = Column(String(50), default=WithdrawalRequestStatus.PENDING.value, nullable=False, index=True)
|
||||
@@ -1550,7 +1684,7 @@ class WithdrawalRequest(Base):
|
||||
risk_analysis = Column(Text, nullable=True) # JSON с деталями анализа
|
||||
|
||||
# Обработка админом
|
||||
processed_by = Column(Integer, ForeignKey('users.id'), nullable=True)
|
||||
processed_by = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
|
||||
processed_at = Column(AwareDateTime(), nullable=True)
|
||||
admin_comment = Column(Text, nullable=True)
|
||||
|
||||
@@ -1578,6 +1712,7 @@ class PartnerApplication(Base):
|
||||
telegram_channel = Column(String(255), nullable=True)
|
||||
description = Column(Text, nullable=True)
|
||||
expected_monthly_referrals = Column(Integer, nullable=True)
|
||||
desired_commission_percent = Column(Integer, nullable=True)
|
||||
|
||||
status = Column(String(20), default=PartnerStatus.PENDING.value, nullable=False)
|
||||
|
||||
@@ -1835,6 +1970,25 @@ class SystemSetting(Base):
|
||||
updated_at = Column(AwareDateTime(), default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class EmailTemplate(Base):
|
||||
"""Custom email template overrides (accessed via raw SQL in cabinet services)."""
|
||||
|
||||
__tablename__ = 'email_templates'
|
||||
__table_args__ = (
|
||||
UniqueConstraint('notification_type', 'language', name='uq_email_templates_type_lang'),
|
||||
Index('ix_email_templates_notification_type', 'notification_type'),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
notification_type = Column(String(100), nullable=False)
|
||||
language = Column(String(10), nullable=False)
|
||||
subject = Column(String(500), nullable=False)
|
||||
body_html = Column(Text, nullable=False)
|
||||
is_active = Column(Boolean, nullable=False, server_default='true')
|
||||
created_at = Column(AwareDateTime(), nullable=False, server_default=func.now())
|
||||
updated_at = Column(AwareDateTime(), nullable=False, server_default=func.now())
|
||||
|
||||
|
||||
class MonitoringLog(Base):
|
||||
__tablename__ = 'monitoring_logs'
|
||||
|
||||
@@ -1979,7 +2133,7 @@ class BroadcastHistory(Base):
|
||||
failed_count = Column(Integer, default=0)
|
||||
blocked_count = Column(Integer, default=0)
|
||||
status = Column(String(50), default='in_progress')
|
||||
admin_id = Column(Integer, ForeignKey('users.id'))
|
||||
admin_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
|
||||
admin_name = Column(String(255))
|
||||
created_at = Column(AwareDateTime(), server_default=func.now())
|
||||
completed_at = Column(AwareDateTime(), nullable=True)
|
||||
@@ -2195,7 +2349,7 @@ class WelcomeText(Base):
|
||||
text_content = Column(Text, nullable=False)
|
||||
is_active = Column(Boolean, default=True)
|
||||
is_enabled = Column(Boolean, default=True)
|
||||
created_by = Column(Integer, ForeignKey('users.id'), nullable=True)
|
||||
created_by = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
|
||||
created_at = Column(AwareDateTime(), default=func.now())
|
||||
updated_at = Column(AwareDateTime(), default=func.now(), onupdate=func.now())
|
||||
|
||||
@@ -2243,7 +2397,7 @@ class AdvertisingCampaign(Base):
|
||||
# Привязка к партнёру
|
||||
partner_user_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True, index=True)
|
||||
|
||||
created_by = Column(Integer, ForeignKey('users.id'), nullable=True)
|
||||
created_by = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
|
||||
created_at = Column(AwareDateTime(), default=func.now())
|
||||
updated_at = Column(AwareDateTime(), default=func.now(), onupdate=func.now())
|
||||
|
||||
@@ -2878,7 +3032,7 @@ class AdminRole(Base):
|
||||
icon = Column(String(50), nullable=True)
|
||||
is_system = Column(Boolean, default=False, nullable=False)
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
created_by = Column(Integer, ForeignKey('users.id'), nullable=True)
|
||||
created_by = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
|
||||
created_at = Column(AwareDateTime(), server_default=func.now())
|
||||
updated_at = Column(AwareDateTime(), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
@@ -2897,7 +3051,7 @@ class UserRole(Base):
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False)
|
||||
role_id = Column(Integer, ForeignKey('admin_roles.id', ondelete='CASCADE'), nullable=False)
|
||||
assigned_by = Column(Integer, ForeignKey('users.id'), nullable=True)
|
||||
assigned_by = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
|
||||
assigned_at = Column(AwareDateTime(), server_default=func.now())
|
||||
expires_at = Column(AwareDateTime(), nullable=True)
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
@@ -2927,7 +3081,7 @@ class AccessPolicy(Base):
|
||||
resource = Column(String(100), nullable=False)
|
||||
actions = Column(JSONB, default=list, nullable=False)
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
created_by = Column(Integer, ForeignKey('users.id'), nullable=True)
|
||||
created_by = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
|
||||
created_at = Column(AwareDateTime(), server_default=func.now())
|
||||
updated_at = Column(AwareDateTime(), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
@@ -2944,7 +3098,7 @@ class AdminAuditLog(Base):
|
||||
__tablename__ = 'admin_audit_log'
|
||||
|
||||
id = Column(BigInteger, primary_key=True, autoincrement=True)
|
||||
user_id = Column(Integer, ForeignKey('users.id'), nullable=False)
|
||||
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False)
|
||||
action = Column(String(100), nullable=False)
|
||||
resource_type = Column(String(50), nullable=True)
|
||||
resource_id = Column(String(100), nullable=True)
|
||||
@@ -2966,3 +3120,111 @@ class AdminAuditLog(Base):
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f'<AdminAuditLog id={self.id} action={self.action!r} status={self.status!r}>'
|
||||
|
||||
|
||||
class LandingPage(Base):
|
||||
"""Public quick-purchase landing page configuration."""
|
||||
|
||||
__tablename__ = 'landing_pages'
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
'discount_percent IS NULL OR (discount_percent >= 1 AND discount_percent <= 99)',
|
||||
name='chk_landing_discount_percent_range',
|
||||
),
|
||||
CheckConstraint(
|
||||
'discount_starts_at IS NULL OR discount_ends_at IS NULL OR discount_starts_at < discount_ends_at',
|
||||
name='chk_landing_discount_dates_order',
|
||||
),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
slug = Column(String(100), unique=True, nullable=False, index=True)
|
||||
is_active = Column(Boolean, nullable=False, default=True)
|
||||
title = Column(JSON, nullable=False, default=dict)
|
||||
subtitle = Column(JSON, nullable=True)
|
||||
features = Column(JSON, nullable=False, default=list)
|
||||
footer_text = Column(JSON, nullable=True)
|
||||
allowed_tariff_ids = Column(JSON, nullable=False, default=list)
|
||||
allowed_periods = Column(JSON, nullable=False, default=dict)
|
||||
payment_methods = Column(JSON, nullable=False, default=list)
|
||||
gift_enabled = Column(Boolean, nullable=False, default=True)
|
||||
custom_css = Column(Text, nullable=True)
|
||||
meta_title = Column(JSON, nullable=True)
|
||||
meta_description = Column(JSON, nullable=True)
|
||||
display_order = Column(Integer, nullable=False, default=0)
|
||||
discount_percent = Column(Integer, nullable=True) # 1-99, global discount for all tariffs
|
||||
discount_overrides = Column(JSON, nullable=True) # {"tariff_id": percent} per-tariff override
|
||||
discount_starts_at = Column(AwareDateTime(), nullable=True)
|
||||
discount_ends_at = Column(AwareDateTime(), nullable=True)
|
||||
discount_badge_text = Column(JSON, nullable=True) # LocaleDict {"ru": "...", "en": "..."}
|
||||
background_config = Column(
|
||||
JSON, nullable=True
|
||||
) # AnimationConfig: {enabled, type, settings, opacity, blur, reducedOnMobile}
|
||||
created_at = Column(AwareDateTime(), server_default=func.now())
|
||||
updated_at = Column(AwareDateTime(), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
guest_purchases = relationship('GuestPurchase', back_populates='landing', lazy='noload')
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<LandingPage slug='{self.slug}' active={self.is_active}>"
|
||||
|
||||
|
||||
class GuestPurchaseStatus(StrEnum):
|
||||
PENDING = 'pending'
|
||||
PAID = 'paid'
|
||||
DELIVERED = 'delivered'
|
||||
PENDING_ACTIVATION = 'pending_activation'
|
||||
FAILED = 'failed'
|
||||
EXPIRED = 'expired'
|
||||
|
||||
|
||||
class GuestPurchase(Base):
|
||||
"""Guest (unauthenticated) purchase record."""
|
||||
|
||||
__tablename__ = 'guest_purchases'
|
||||
__table_args__ = (
|
||||
Index('ix_guest_purchases_status', 'status'),
|
||||
Index('ix_guest_purchases_contact', 'contact_type', 'contact_value'),
|
||||
Index('ix_guest_purchases_landing_status_paid', 'landing_id', 'status', 'paid_at'),
|
||||
Index('ix_guest_purchases_source', 'source'),
|
||||
Index('ix_guest_purchases_user_gift_status', 'user_id', 'is_gift', 'status'),
|
||||
Index('ix_guest_purchases_status_paid_at', 'status', 'paid_at'),
|
||||
Index('ix_guest_purchases_buyer_user_id', 'buyer_user_id'),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
token = Column(String(64), unique=True, nullable=False, index=True)
|
||||
landing_id = Column(Integer, ForeignKey('landing_pages.id', ondelete='SET NULL'), nullable=True)
|
||||
contact_type = Column(String(20), nullable=False) # 'email' or 'telegram'
|
||||
contact_value = Column(String(255), nullable=False)
|
||||
is_gift = Column(Boolean, nullable=False, default=False)
|
||||
source = Column(String(20), nullable=False, default='landing', server_default='landing') # 'landing' or 'cabinet'
|
||||
buyer_user_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
|
||||
gift_recipient_type = Column(String(20), nullable=True)
|
||||
gift_recipient_value = Column(String(255), nullable=True)
|
||||
gift_message = Column(Text, nullable=True)
|
||||
tariff_id = Column(Integer, ForeignKey('tariffs.id', ondelete='SET NULL'), nullable=True)
|
||||
period_days = Column(Integer, nullable=False)
|
||||
amount_kopeks = Column(Integer, nullable=False)
|
||||
currency = Column(String(3), nullable=False, default='RUB')
|
||||
payment_method = Column(String(50), nullable=True)
|
||||
payment_id = Column(String(255), nullable=True)
|
||||
status = Column(String(20), nullable=False, default=GuestPurchaseStatus.PENDING.value)
|
||||
subscription_url = Column(Text, nullable=True)
|
||||
subscription_crypto_link = Column(Text, nullable=True)
|
||||
user_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
|
||||
created_at = Column(AwareDateTime(), server_default=func.now())
|
||||
paid_at = Column(AwareDateTime(), nullable=True)
|
||||
delivered_at = Column(AwareDateTime(), nullable=True)
|
||||
cabinet_password = Column(Text, nullable=True)
|
||||
auto_login_token = Column(Text, nullable=True)
|
||||
recipient_warning = Column(String(50), nullable=True)
|
||||
|
||||
landing = relationship('LandingPage', back_populates='guest_purchases', lazy='selectin')
|
||||
tariff = relationship('Tariff', lazy='selectin')
|
||||
user = relationship('User', foreign_keys=[user_id], lazy='selectin')
|
||||
buyer = relationship('User', foreign_keys=[buyer_user_id], lazy='selectin')
|
||||
|
||||
def __repr__(self) -> str:
|
||||
token_prefix = self.token[:5] if self.token else '?'
|
||||
return f"<GuestPurchase token='{token_prefix}...' status='{self.status}'>"
|
||||
|
||||
Vendored
+23
-2
@@ -400,7 +400,12 @@ class RemnaWaveAPI:
|
||||
|
||||
if response.status >= 400:
|
||||
error_message = response_data.get('message', f'HTTP {response.status}')
|
||||
log = logger.warning if response.status in (502, 503, 504) else logger.error
|
||||
# Downgrade known-harmless 400s to warning (caller handles them as success)
|
||||
error_lower = str(error_message).lower()
|
||||
is_harmless = response.status == 400 and (
|
||||
'already enabled' in error_lower or 'already disabled' in error_lower
|
||||
)
|
||||
log = logger.warning if response.status in (502, 503, 504) or is_harmless else logger.error
|
||||
log('API Error %s: %s', response.status, error_message)
|
||||
log('Response: %s', response_text[:500])
|
||||
raise RemnaWaveAPIError(error_message, response.status, response_data)
|
||||
@@ -439,6 +444,7 @@ class RemnaWaveAPI:
|
||||
description: str | None = None,
|
||||
tag: str | None = None,
|
||||
active_internal_squads: list[str] | None = None,
|
||||
external_squad_uuid: str | None = None,
|
||||
) -> RemnaWaveUser:
|
||||
data = {
|
||||
'username': username,
|
||||
@@ -460,10 +466,22 @@ class RemnaWaveAPI:
|
||||
data['tag'] = tag
|
||||
if active_internal_squads:
|
||||
data['activeInternalSquads'] = active_internal_squads
|
||||
if external_squad_uuid is not None:
|
||||
data['externalSquadUuid'] = external_squad_uuid
|
||||
|
||||
logger.debug('Создание пользователя в панели', data=data)
|
||||
logger.info(
|
||||
'POST /api/users payload',
|
||||
username=data.get('username'),
|
||||
hwidDeviceLimit=data.get('hwidDeviceLimit'),
|
||||
status=data.get('status'),
|
||||
)
|
||||
response = await self._make_request('POST', '/api/users', data)
|
||||
user = self._parse_user(response['response'])
|
||||
logger.info(
|
||||
'POST /api/users response',
|
||||
uuid=user.uuid,
|
||||
response_hwidDeviceLimit=user.hwid_device_limit,
|
||||
)
|
||||
return await self.enrich_user_with_happ_link(user)
|
||||
|
||||
async def get_user_by_uuid(self, uuid: str) -> RemnaWaveUser | None:
|
||||
@@ -529,6 +547,7 @@ class RemnaWaveAPI:
|
||||
description: str | None = None,
|
||||
tag: str | None = None,
|
||||
active_internal_squads: list[str] | None = None,
|
||||
external_squad_uuid: str | None | type(...) = ...,
|
||||
) -> RemnaWaveUser:
|
||||
data = {'uuid': uuid}
|
||||
|
||||
@@ -552,6 +571,8 @@ class RemnaWaveAPI:
|
||||
data['tag'] = tag
|
||||
if active_internal_squads is not None:
|
||||
data['activeInternalSquads'] = active_internal_squads
|
||||
if external_squad_uuid is not ...:
|
||||
data['externalSquadUuid'] = external_squad_uuid
|
||||
|
||||
logger.info(
|
||||
'PATCH /api/users payload',
|
||||
|
||||
Vendored
+1
-2
@@ -207,7 +207,6 @@ class YooKassaWebhookHandler:
|
||||
async def handle_webhook(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
logger.info('📥 Получен YooKassa webhook', method=request.method, path=request.path)
|
||||
logger.info('📋 Headers', value=dict(request.headers))
|
||||
|
||||
header_ip_candidates = collect_yookassa_ip_candidates(
|
||||
request.headers.get('X-Forwarded-For'),
|
||||
@@ -242,7 +241,7 @@ class YooKassaWebhookHandler:
|
||||
logger.warning('⚠️ Получен пустой webhook от YooKassa')
|
||||
return web.Response(status=400, text='Empty body')
|
||||
|
||||
logger.info('📄 Body', body=body)
|
||||
logger.debug('📄 Body received', length=len(body))
|
||||
|
||||
signature = request.headers.get('Signature') or request.headers.get('X-YooKassa-Signature')
|
||||
if signature:
|
||||
|
||||
@@ -29,7 +29,7 @@ async def show_blacklist_settings(callback: types.CallbackQuery, db_user: User,
|
||||
blacklist_count = len(await blacklist_service.get_all_blacklisted_users())
|
||||
|
||||
status_text = '✅ Включена' if is_enabled else '❌ Отключена'
|
||||
url_text = github_url if github_url else 'Не задан'
|
||||
url_text = github_url or 'Не задан'
|
||||
|
||||
text = f"""
|
||||
🔐 <b>Настройки черного списка</b>
|
||||
|
||||
@@ -5,6 +5,7 @@ import time
|
||||
from collections.abc import Iterable
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from aiogram import Dispatcher, F, types
|
||||
from aiogram.filters import BaseFilter, StateFilter
|
||||
from aiogram.fsm.context import FSMContext
|
||||
@@ -32,6 +33,8 @@ from app.utils.currency_converter import currency_converter
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
CATEGORY_PAGE_SIZE = 10
|
||||
SETTINGS_PAGE_SIZE = 8
|
||||
SIMPLE_SUBSCRIPTION_SQUADS_PAGE_SIZE = 6
|
||||
@@ -60,7 +63,7 @@ CATEGORY_GROUP_METADATA: dict[str, dict[str, object]] = {
|
||||
},
|
||||
'payments': {
|
||||
'title': '💳 Платежные системы',
|
||||
'description': 'YooKassa, CryptoBot, Heleket, CloudPayments, Freekassa, MulenPay, PAL24, Wata, Platega, Tribute, Kassa AI и Telegram Stars.',
|
||||
'description': 'YooKassa, CryptoBot, Heleket, CloudPayments, Freekassa, MulenPay, PAL24, Wata, Platega, Tribute, Kassa AI, RioPay и Telegram Stars.',
|
||||
'icon': '💳',
|
||||
'categories': (
|
||||
'PAYMENT',
|
||||
@@ -71,6 +74,7 @@ CATEGORY_GROUP_METADATA: dict[str, dict[str, object]] = {
|
||||
'CLOUDPAYMENTS',
|
||||
'FREEKASSA',
|
||||
'KASSA_AI',
|
||||
'RIOPAY',
|
||||
'MULENPAY',
|
||||
'PAL24',
|
||||
'WATA',
|
||||
@@ -260,6 +264,7 @@ def _get_group_status(group_key: str) -> tuple[str, str]:
|
||||
'CloudPayments': settings.is_cloudpayments_enabled(),
|
||||
'Freekassa': settings.is_freekassa_enabled(),
|
||||
'Kassa AI': settings.is_kassa_ai_enabled(),
|
||||
'RioPay': settings.is_riopay_enabled(),
|
||||
'MulenPay': settings.is_mulenpay_enabled(),
|
||||
'PAL24': settings.is_pal24_enabled(),
|
||||
'Tribute': settings.TRIBUTE_ENABLED,
|
||||
@@ -1248,6 +1253,9 @@ def _build_settings_keyboard(
|
||||
elif category_key == 'KASSA_AI':
|
||||
label = texts.t('PAYMENT_KASSA_AI', f'💳 {settings.get_kassa_ai_display_name()}')
|
||||
test_payment_buttons.append([_test_button(f'{label} · тест', 'kassa_ai')])
|
||||
elif category_key == 'RIOPAY':
|
||||
label = texts.t('PAYMENT_RIOPAY', f'💳 {settings.get_riopay_display_name()}')
|
||||
test_payment_buttons.append([_test_button(f'{label} · тест', 'riopay')])
|
||||
|
||||
if test_payment_buttons:
|
||||
rows.extend(test_payment_buttons)
|
||||
@@ -2322,6 +2330,48 @@ async def test_payment_provider(
|
||||
await _refresh_markup()
|
||||
return
|
||||
|
||||
if method == 'riopay':
|
||||
if not settings.is_riopay_enabled():
|
||||
await callback.answer('❌ RioPay отключена', show_alert=True)
|
||||
return
|
||||
|
||||
amount_kopeks = settings.RIOPAY_MIN_AMOUNT_KOPEKS
|
||||
payment_result = await payment_service.create_riopay_payment(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description='Тестовый платеж RioPay (админ)',
|
||||
email=getattr(db_user, 'email', None),
|
||||
language=db_user.language or settings.DEFAULT_LANGUAGE,
|
||||
)
|
||||
|
||||
if not payment_result or not payment_result.get('payment_url'):
|
||||
await callback.answer('❌ Не удалось создать тестовый платеж RioPay', show_alert=True)
|
||||
await _refresh_markup()
|
||||
return
|
||||
|
||||
payment_url = payment_result['payment_url']
|
||||
display_name = settings.get_riopay_display_name()
|
||||
message_text = (
|
||||
f'🧪 <b>Тестовый платеж {display_name}</b>\n\n'
|
||||
f'💰 Сумма: {texts.format_price(amount_kopeks)}\n'
|
||||
f'🆔 Order ID: {payment_result["order_id"]}'
|
||||
)
|
||||
reply_markup = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text='💳 Перейти к оплате',
|
||||
url=payment_url,
|
||||
)
|
||||
]
|
||||
]
|
||||
)
|
||||
await callback.message.answer(message_text, reply_markup=reply_markup, parse_mode='HTML')
|
||||
await callback.answer(f'✅ Ссылка на платеж {display_name} отправлена', show_alert=True)
|
||||
await _refresh_markup()
|
||||
return
|
||||
|
||||
await callback.answer('❌ Неизвестный способ тестирования платежа', show_alert=True)
|
||||
await _refresh_markup()
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import re
|
||||
import structlog
|
||||
from aiogram import Bot, Dispatcher, F, types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy import inspect as sa_inspect
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
@@ -237,8 +238,10 @@ async def show_campaigns_list(
|
||||
text_lines = ['📋 <b>Список кампаний</b>\n']
|
||||
|
||||
for campaign in campaigns:
|
||||
registrations = len(campaign.registrations or [])
|
||||
total_balance = sum(r.balance_bonus_kopeks or 0 for r in campaign.registrations or [])
|
||||
# Access from instance dict to avoid MissingGreenlet on lazy load
|
||||
regs = sa_inspect(campaign).dict.get('registrations', []) or []
|
||||
registrations = len(regs)
|
||||
total_balance = sum(r.balance_bonus_kopeks or 0 for r in regs)
|
||||
status = '🟢' if campaign.is_active else '⚪'
|
||||
line = (
|
||||
f'{status} <b>{campaign.name}</b> — <code>{campaign.start_parameter}</code>\n'
|
||||
|
||||
@@ -1242,7 +1242,7 @@ async def process_mass_virtual_count(
|
||||
'❌ Введите число от 1 до 50:',
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text='❌ Отмена', callback_data='admin_contests_ref')],
|
||||
[types.InlineKeyboardButton(text='❌ Отмена', callback_data='admin_contests_referral')],
|
||||
]
|
||||
),
|
||||
)
|
||||
@@ -1252,7 +1252,7 @@ async def process_mass_virtual_count(
|
||||
'❌ Введите корректное число от 1 до 50:',
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text='❌ Отмена', callback_data='admin_contests_ref')],
|
||||
[types.InlineKeyboardButton(text='❌ Отмена', callback_data='admin_contests_referral')],
|
||||
]
|
||||
),
|
||||
)
|
||||
@@ -1427,13 +1427,15 @@ def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(prompt_edit_summary_times, F.data.startswith('admin_contest_edit_times_'))
|
||||
dp.callback_query.register(delete_contest, F.data.startswith('admin_contest_delete_'))
|
||||
dp.callback_query.register(show_leaderboard, F.data.startswith('admin_contest_leaderboard_'))
|
||||
dp.callback_query.register(show_detailed_stats, F.data.startswith('admin_contest_detailed_stats_'))
|
||||
dp.callback_query.register(show_detailed_stats_page, F.data.startswith('admin_contest_detailed_stats_page_'))
|
||||
dp.callback_query.register(show_detailed_stats, F.data.startswith('admin_contest_detailed_stats_'))
|
||||
dp.callback_query.register(sync_contest, F.data.startswith('admin_contest_sync_'))
|
||||
dp.callback_query.register(debug_contest_transactions, F.data.startswith('admin_contest_debug_'))
|
||||
dp.callback_query.register(start_contest_creation, F.data == 'admin_contests_create')
|
||||
dp.callback_query.register(
|
||||
select_contest_mode, F.data.in_(['admin_contest_mode_paid', 'admin_contest_mode_registered'])
|
||||
select_contest_mode,
|
||||
F.data.in_(['admin_contest_mode_paid', 'admin_contest_mode_registered']),
|
||||
AdminStates.creating_referral_contest_mode,
|
||||
)
|
||||
|
||||
dp.message.register(process_title, AdminStates.creating_referral_contest_title)
|
||||
|
||||
@@ -613,7 +613,9 @@ async def show_messages_history(callback: types.CallbackQuery, db_user: User, db
|
||||
)
|
||||
|
||||
message_preview = (
|
||||
broadcast.message_text[:100] + '...' if len(broadcast.message_text) > 100 else broadcast.message_text
|
||||
broadcast.message_text[:100] + '...'
|
||||
if broadcast.message_text and len(broadcast.message_text) > 100
|
||||
else (broadcast.message_text or '📊 Опрос')
|
||||
)
|
||||
|
||||
import html
|
||||
@@ -1555,7 +1557,11 @@ async def get_target_users_count(db: AsyncSession, target: str) -> int:
|
||||
if target == 'expired':
|
||||
# Истекшие подписки
|
||||
now = datetime.now(UTC)
|
||||
expired_statuses = [SubscriptionStatus.EXPIRED.value, SubscriptionStatus.DISABLED.value]
|
||||
expired_statuses = [
|
||||
SubscriptionStatus.EXPIRED.value,
|
||||
SubscriptionStatus.DISABLED.value,
|
||||
SubscriptionStatus.LIMITED.value,
|
||||
]
|
||||
query = (
|
||||
select(sql_func.count(distinct(User.id)))
|
||||
.outerjoin(Subscription, User.id == Subscription.user_id)
|
||||
@@ -1574,7 +1580,11 @@ async def get_target_users_count(db: AsyncSession, target: str) -> int:
|
||||
if target == 'expired_subscribers':
|
||||
# То же что и expired
|
||||
now = datetime.now(UTC)
|
||||
expired_statuses = [SubscriptionStatus.EXPIRED.value, SubscriptionStatus.DISABLED.value]
|
||||
expired_statuses = [
|
||||
SubscriptionStatus.EXPIRED.value,
|
||||
SubscriptionStatus.DISABLED.value,
|
||||
SubscriptionStatus.LIMITED.value,
|
||||
]
|
||||
query = (
|
||||
select(sql_func.count(distinct(User.id)))
|
||||
.outerjoin(Subscription, User.id == Subscription.user_id)
|
||||
|
||||
@@ -1350,12 +1350,12 @@ async def _do_reconcile_logs(callback: CallbackQuery):
|
||||
await callback.answer('🔄 Анализирую логи платежей...', show_alert=False)
|
||||
|
||||
# Путь к файлу логов платежей (logs/current/)
|
||||
log_file_path = Path(settings.LOG_FILE).resolve()
|
||||
log_file_path = await asyncio.to_thread(Path(settings.LOG_FILE).resolve)
|
||||
log_dir = log_file_path.parent
|
||||
current_dir = log_dir / 'current'
|
||||
payments_log = current_dir / settings.LOG_PAYMENTS_FILE
|
||||
|
||||
if not payments_log.exists():
|
||||
if not await asyncio.to_thread(payments_log.exists):
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
'❌ <b>Файл логов не найден</b>\n\n'
|
||||
@@ -1491,12 +1491,12 @@ async def receipts_reconcile_logs_details_callback(callback: CallbackQuery):
|
||||
await callback.answer('🔄 Загружаю детали...', show_alert=False)
|
||||
|
||||
# Путь к логам (logs/current/)
|
||||
log_file_path = Path(settings.LOG_FILE).resolve()
|
||||
log_file_path = await asyncio.to_thread(Path(settings.LOG_FILE).resolve)
|
||||
log_dir = log_file_path.parent
|
||||
current_dir = log_dir / 'current'
|
||||
payments_log = current_dir / settings.LOG_PAYMENTS_FILE
|
||||
|
||||
if not payments_log.exists():
|
||||
if not await asyncio.to_thread(payments_log.exists):
|
||||
await callback.answer('❌ Файл логов не найден', show_alert=True)
|
||||
return
|
||||
|
||||
|
||||
@@ -48,6 +48,8 @@ def _method_display(method: PaymentMethod) -> str:
|
||||
return 'Telegram Stars'
|
||||
if method == PaymentMethod.KASSA_AI:
|
||||
return settings.get_kassa_ai_display_name()
|
||||
if method == PaymentMethod.RIOPAY:
|
||||
return settings.get_riopay_display_name()
|
||||
if method == PaymentMethod.FREEKASSA:
|
||||
return settings.get_freekassa_display_name()
|
||||
return method.value
|
||||
@@ -186,7 +188,7 @@ 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.FREEKASSA:
|
||||
return status in {'pending', 'created', ''}
|
||||
if record.method == PaymentMethod.KASSA_AI:
|
||||
@@ -378,7 +380,7 @@ def _build_payment_details_text(record: PendingPayment, *, texts, language: str)
|
||||
amount = f'{crypto_amount} {crypto_asset}'
|
||||
created = format_datetime(record.created_at)
|
||||
age = format_time_ago(record.created_at, language)
|
||||
raw_identifier = record.identifier if record.identifier else record.local_id
|
||||
raw_identifier = record.identifier or record.local_id
|
||||
identifier = html.escape(str(raw_identifier)) if raw_identifier is not None else '—'
|
||||
lines = [
|
||||
texts.t('ADMIN_PAYMENT_DETAILS_TITLE', '💳 <b>Payment details</b>'),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user