Compare commits
289 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0879b8b218 | |||
| 1d91382b8e | |||
| 3768b18a39 | |||
| 1eeeb39779 | |||
| 570af82dfd | |||
| bc3893b934 | |||
| 16d91638bc | |||
| eb18b3a0f9 | |||
| a8e2b62f4b | |||
| fb8d2b3ee4 | |||
| 2321667ecb | |||
| 113304b212 | |||
| 0300044b00 | |||
| 931abfe7a5 | |||
| 1d96f80f60 | |||
| 4e50419171 | |||
| 7208a52c94 | |||
| 63fdfe4a42 | |||
| e0e2edf816 | |||
| 522a8779d6 | |||
| be32010d63 | |||
| 7e920fa30f | |||
| 1b376baeca | |||
| 91a756a33e | |||
| 970dc549df | |||
| 65120f0bad | |||
| 9cb559ff39 | |||
| 8f1882f24c | |||
| 8542a39305 | |||
| 646ac4cfa1 | |||
| abdf296767 | |||
| a1b6d9bb61 | |||
| cf19e4e1f7 | |||
| 35412e9f21 | |||
| 6aed7d355b | |||
| 9c08ce6948 | |||
| 862352139e | |||
| d465ccb3ac | |||
| b57f185258 | |||
| ffbb3fb8be | |||
| f01dbff000 | |||
| 31adcfded4 | |||
| 78f963bf5e | |||
| 357d94d1b0 | |||
| 0fb4a2c235 | |||
| 2f7184627a | |||
| d55e9db62a | |||
| 57adfaf4f3 | |||
| 4165eaea7a | |||
| 3b5d5a18a1 | |||
| eef41c4bca | |||
| 987c3c93c2 | |||
| 7d24e8d704 | |||
| 819f09a68e | |||
| 2f9d00343b | |||
| 9b7ac47f16 | |||
| 0d5638f778 | |||
| 7836720db3 | |||
| dcb90d6139 | |||
| 96c420e917 | |||
| 9d63635502 | |||
| c4c5d330be | |||
| 977950b97f | |||
| 6f6b9fa039 | |||
| 6713921887 | |||
| 2d42152f54 | |||
| 08ca947b2b | |||
| b04157c913 | |||
| f284351c51 | |||
| b607993854 | |||
| 246659032d | |||
| 3dc72b00e7 | |||
| 033d0da5e0 | |||
| 991f0b43e1 | |||
| c9524cb703 | |||
| 76d4a2124c | |||
| 4fa230c07f | |||
| d580a78403 | |||
| 312cc728a9 | |||
| b1820c651d | |||
| 0c284b9e99 | |||
| 72170b35f5 | |||
| 9058a9c5d3 | |||
| be0934c5e5 | |||
| cab1946382 | |||
| fd247bc4f4 | |||
| adb39c6ef4 | |||
| 48eaa6b072 | |||
| 972614511f | |||
| 34aec0323b | |||
| 931eeb3568 | |||
| 2628012097 | |||
| 7f899a7e41 | |||
| 6dbbe5950e | |||
| f93c51a677 | |||
| da11ec6f94 | |||
| 23d1830644 | |||
| e3d8d21b66 | |||
| 2c12a4773c | |||
| 7f60196033 | |||
| b59c581e91 | |||
| 81505c8c1d | |||
| d6a49e8331 | |||
| 7ecd95aec0 | |||
| 1471320606 | |||
| 3d0b874cb4 | |||
| 5d173c806a | |||
| 6d167d2922 | |||
| 173cc374bb | |||
| 960aa44b00 | |||
| 54a19a9c50 | |||
| f6f330db4a | |||
| aa36549bb3 | |||
| 78209c8623 | |||
| cddb8d6332 | |||
| 092b9f63b2 | |||
| 7dd67e36b3 | |||
| 565c08366b | |||
| 34b5a9ab3a | |||
| 181ef1501b | |||
| cd6913cb84 | |||
| 84357a1e87 | |||
| 3bec6620b6 | |||
| 3bbcc1b560 | |||
| b8662b8bf6 | |||
| 63e4296197 | |||
| 3cbe09ddd5 | |||
| 31c67d1565 | |||
| 95ba739958 | |||
| e39c358d5c | |||
| a12ffb1d6c | |||
| 6d9bd9915c | |||
| d97c8531a3 | |||
| 94ed282381 | |||
| fec374edba | |||
| 58d899aab8 | |||
| 1bc2581669 | |||
| b0273dc8ae | |||
| 948e4791f4 | |||
| a49e52cc92 | |||
| 6dc5879ffa | |||
| aa7e461c44 | |||
| 49db5f5eed | |||
| c6bedc6a06 | |||
| 4259ba1cb5 | |||
| 40d2ec6718 | |||
| 5724906517 | |||
| 1099c5224c | |||
| bd46b4cf6d | |||
| a232d21edd | |||
| dbe247ba6f | |||
| c3c2b8137b | |||
| 87bf65c809 | |||
| 4e12ab3458 | |||
| 25b853d629 | |||
| e42bddb868 | |||
| 9644135dd7 | |||
| 59d4b353a6 | |||
| f925efbfb4 | |||
| 319941d33a | |||
| a39e3554d8 | |||
| 382e29d3dd | |||
| 05d1ae0560 | |||
| 9a27e6db31 | |||
| a1623d94b1 | |||
| f83ff26332 | |||
| fe03b587db | |||
| afd7b6d7ec | |||
| f89e326a19 | |||
| 0866c2ea4b | |||
| 147ef6b22b | |||
| 76ba19da17 | |||
| 90fb0a21e2 | |||
| 684f286fcd | |||
| d2bbeb8624 | |||
| 72d5bae531 | |||
| 6d468e9ada | |||
| 355fef846e | |||
| 424fff4ac2 | |||
| d04f2fc718 | |||
| 56fffc2415 | |||
| c27f144b76 | |||
| 34bb87c7ba | |||
| 24edfb6c3f | |||
| 824d54b7dc | |||
| 71082f436c | |||
| 4dd81702ce | |||
| 4f76f53d55 | |||
| 048d208bc1 | |||
| 344852b852 | |||
| 78a7eafcb6 | |||
| 5a7b3d5962 | |||
| cefdfc54cc | |||
| b6cf361737 | |||
| 4f20e0e4cb | |||
| d87fb47e88 | |||
| 82958801b5 | |||
| d071269b8c | |||
| 18f31c565c | |||
| 2f88b07f05 | |||
| c8ecec47a0 | |||
| 06feb3fff5 | |||
| f7f8ea87cf | |||
| e99f3d9a37 | |||
| 07ebc435cf | |||
| 335be66980 | |||
| c805cfd6d8 | |||
| 004dac5b7e | |||
| 7e9cc530e7 | |||
| 69b824572a | |||
| ab4785f8df | |||
| 1642be8bd6 | |||
| 8175bc8bfe | |||
| 4660ca5756 | |||
| 6bf41a72d0 | |||
| 6d0b003591 | |||
| 334db53868 | |||
| 76b1f9b036 | |||
| f0cdd5dc90 | |||
| d9cda3a6d6 | |||
| 51392d1918 | |||
| 89bfdc8ed6 | |||
| 0225fa155b | |||
| fd410096ea | |||
| b5853ec3b6 | |||
| 2f19c76357 | |||
| 6658af6268 | |||
| fad77f8c80 | |||
| 172924df0e | |||
| 7ff73e8492 | |||
| ce554cb2a8 | |||
| 165d25ef5f | |||
| 5ed3780f83 | |||
| a0d40ad432 | |||
| 3e69efe589 | |||
| 015c2da297 | |||
| 2b91808b0c | |||
| b93240393f | |||
| 89341baa62 | |||
| cbe630cab0 | |||
| 9de34900a2 | |||
| aec04f0085 | |||
| 0fe3c217f7 | |||
| 958ec489a2 | |||
| df9985802b | |||
| c66849db10 | |||
| 8a1da85f3e | |||
| 0335f40b47 | |||
| 8ac1183670 | |||
| bcc761f9d3 | |||
| 1eb4e18c17 | |||
| 056c13bc23 | |||
| 2bdb7643f8 | |||
| 8b8f1b91f3 | |||
| 5ed2f0c958 | |||
| 454dc9321b | |||
| de91d3282f | |||
| e0bedc8e78 | |||
| 4a48818bc3 | |||
| ff7388766c | |||
| 6b36dc4df1 | |||
| fe847e35f0 | |||
| 4c2cb63cf9 | |||
| d3c994083e | |||
| cce3b0c13b | |||
| 6c208581d9 | |||
| c307278231 | |||
| 9eab802000 | |||
| 13ea3768b5 | |||
| ed5a92ab96 | |||
| 48265f1cd4 | |||
| 3b9568fcc1 | |||
| 79cfcbcece | |||
| 6d5aceb4ca | |||
| 905dbcc779 | |||
| f33dfdf031 | |||
| 59cd74d307 | |||
| 90209ebef1 | |||
| ab43e74ab7 | |||
| 4244962337 | |||
| ba79d03e38 | |||
| 8e4e2ddd1a | |||
| 38853cdd5a | |||
| f837c0c244 | |||
| 8a7b9cc651 | |||
| 3bf31055e7 | |||
| 3c5bf4fa22 | |||
| 4990ddf9e4 | |||
| de00612965 |
+2
-1
@@ -522,6 +522,7 @@ NALOGO_STORAGE_PATH=./nalogo_tokens.json # Путь к файлу с токен
|
||||
NALOGO_QUEUE_CHECK_INTERVAL=300 # Интервал проверки очереди чеков (секунды)
|
||||
NALOGO_QUEUE_RECEIPT_DELAY=3 # Задержка между отправкой чеков (секунды)
|
||||
NALOGO_QUEUE_MAX_ATTEMPTS=10 # Максимум попыток отправки одного чека
|
||||
# NALOGO_PROXY_URL=socks5://127.0.0.1:1080 # SOCKS прокси для nalog.ru (если не задан — используется PROXY_URL)
|
||||
|
||||
# ===== НАСТРОЙКИ ОПИСАНИЙ ПЛАТЕЖЕЙ =====
|
||||
# Эти настройки позволяют изменить описания платежей,
|
||||
@@ -613,7 +614,7 @@ PLATEGA_RETURN_URL=
|
||||
PLATEGA_FAILED_URL=
|
||||
PLATEGA_CURRENCY=RUB
|
||||
# Список ID активных методов из кабинета Platega (через запятую)
|
||||
PLATEGA_ACTIVE_METHODS=2,10,11,12,13
|
||||
PLATEGA_ACTIVE_METHODS=2,11,12,13
|
||||
PLATEGA_MIN_AMOUNT_KOPEKS=100
|
||||
PLATEGA_MAX_AMOUNT_KOPEKS=100000000
|
||||
PLATEGA_WEBHOOK_PATH=/platega-webhook
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 76 KiB |
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "3.37.0"
|
||||
".": "3.46.0"
|
||||
}
|
||||
|
||||
+355
@@ -1,5 +1,360 @@
|
||||
# Changelog
|
||||
|
||||
## [3.46.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.45.2...v3.46.0) (2026-04-13)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* add broadcast category (system/news/promo) + filter recipients by user prefs ([931abfe](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/931abfe7a5a7fb70e9638fbf6b566fa8d1a837e4))
|
||||
* add category field to broadcast API schemas and routes ([0300044](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0300044b009f3e4b3aa3928652dfaf261a387dbc))
|
||||
* add RemnaWave retry queue for failed API calls (BUG-2, BUG-10) ([abdf296](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/abdf2967675975e90f0c4d834f129281c1c28e7b))
|
||||
* add remnawave_resync_service for identity-change sync ([b57f185](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b57f185258be050d945cec5989ad6dc710980a6a))
|
||||
* add traffic % warning check using user's threshold preference ([1d96f80](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1d96f80f60ca445eb5108e7bc54e00d022a4cc9e))
|
||||
* add user notification preferences helper utility ([e0e2edf](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e0e2edf81659fbeea361046d1bb2718c2149d884))
|
||||
* implement low balance alert + respect user notification preferences ([4e50419](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4e50419171176ee452371ff095bdfead3879e554))
|
||||
* respect user subscription_expiry notification preferences ([63fdfe4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/63fdfe4a421942b26caca35d8bd9b1d65f1fe7e2))
|
||||
* respect user traffic_warning notification preference in webhook handler ([7208a52](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7208a52c9424d39757187fc86eec2c3460a2cdbb))
|
||||
* save campaign_slug during standalone email registration ([a8e2b62](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a8e2b62f4bb0833ca32446b34ad4e0c5615fcd2a))
|
||||
* start RemnaWave retry queue on app startup ([8f1882f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8f1882f24c7d066e2d0fc756f38ce23bf082687e))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add retry queue to all remaining RemnaWave error handlers ([7e920fa](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7e920fa30fc8e61ed2dd31e7a09151d57b7361ca))
|
||||
* add retry queue to cabinet subscription operation RemnaWave errors ([1b376ba](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1b376baeca120970b1ffcd406b1bbf7d9d43cee0))
|
||||
* add retry queue to classic mode bot purchase handler ([970dc54](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/970dc549dfa06ba9945d5bd861374058acdca86b))
|
||||
* add retry queue to daily subscription service RemnaWave errors ([65120f0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/65120f0badc9a4581ba0a127acdae8a0b23e8501))
|
||||
* add retry queue to payment webhook and renewal service RemnaWave errors ([91a756a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/91a756a33ed4ce685bdf485cdb4e91c3e08799dd))
|
||||
* add TRAFFIC_WARNING_ALERT and LOW_BALANCE_ALERT localization keys to all locales ([2321667](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2321667ecbe76bfe8dd37213e0bb4e45104e0fc5))
|
||||
* always sync squads in auto-purchase renewal (BUG-4) ([8542a39](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8542a393055a93d320d5c8c6d3aa7cc291cf8def))
|
||||
* default sync_squads=True in update_remnawave_user (BUG-4) ([6aed7d3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6aed7d355bc47c4dbd2aa761d78a5e5421c32edf))
|
||||
* enforce max_attempts limit in NaloGO receipt queue ([16d9163](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/16d91638bc149c5eee8f4cdd266cbd195c411030))
|
||||
* enqueue retry on RemnaWave API failure in all purchase flows (BUG-2, BUG-10) ([9cb559f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9cb559ff3994c0f1c6ba48a4ec09dec9b391e48b))
|
||||
* exclude users with active subscriptions from expired broadcast ([1eeeb39](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1eeeb39779982ebb2700cb7523760631229407da))
|
||||
* handle TelegramBadRequest when deleting old ticket notifications ([eb18b3a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/eb18b3a0f9ac3a617a1b21d3293e1619980a2a71))
|
||||
* match tariff_id when creating subscriptions from panel sync (BUG-11) ([646ac4c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/646ac4cfa18f738040fbc6498c5d86c1546e2b9a))
|
||||
* protect OAuth users with remnawave_uuid from sync deactivation (BUG-6) ([cf19e4e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/cf19e4e1f7148b21d9a8072f7a0b4ae97fd04e8a))
|
||||
* raise MAX_BUTTONS_PER_ROW to 8 and allow tg:// deep links in menu editor ([570af82](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/570af82dfdec980f42be96c8f816e1677f896f81))
|
||||
* resync RemnaWave after account merge (BUG-7) ([9c08ce6](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9c08ce69485b78f8fe818500e05ab6995115166a))
|
||||
* resync RemnaWave after Telegram account linking (BUG-1) ([d465ccb](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d465ccb3ac3a86add6313d6bb61d53d0fe143d5e))
|
||||
* sync connected_squads from panel during sync (BUG-5) ([35412e9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/35412e9f215680c0fdf1c55b5bf23496f662935c))
|
||||
* trial activation fallback to trial-eligible servers when tariff has no squads (BUG-12) + fix misleading button text ([be32010](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/be32010d63966498bc6216823a75d328346d9a37))
|
||||
* upsert refresh tokens (ON CONFLICT) + periodic cleanup of expired/revoked tokens ([fb8d2b3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fb8d2b3ee4566823840100b96fe2f3bc7d41edb7))
|
||||
* use 'is not None' for telegram_id in create_user API (BUG-9) ([8623521](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/862352139e8a3545e144b0618fed5011470a9a67))
|
||||
* use MAX_DEVICES_LIMIT instead of hardcoded 10 for device buttons ([bc3893b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bc3893b934f0d4e5059eedbd03cdfbc628852ac1))
|
||||
* use update_remnawave_user when UUID exists in tariff_purchase (BUG-3) ([a1b6d9b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a1b6d9bb619ec3de038647fe6f2e5d38979298a8))
|
||||
|
||||
## [3.45.2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.45.1...v3.45.2) (2026-04-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* batch bug fixes from user complaints ([31adcfd](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/31adcfded4b161bf515d4d6b25b4395e543208f4))
|
||||
* batch bug fixes from user complaints ([78f963b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/78f963bf5e7b3439d7614584c4041f88be5beb4a))
|
||||
* исправление парсинга черного списка (поддержка '#' и извлечение username) ([357d94d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/357d94d1b0d7fc8036b00cbb6b75175c29821751))
|
||||
* исправление парсинга черного списка (поддержка '#' и извлечение username) ([2f71846](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2f7184627a0fb598a8c0208905cdecf0e4bb04a7))
|
||||
|
||||
## [3.45.1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.45.0...v3.45.1) (2026-04-03)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add missing WEBHOOK_TORRENT_DETECTED mapping + dedup before uniq… ([4165eae](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4165eaea7adfdaf683b1ece16c8c93a9c4ed216d))
|
||||
* add missing WEBHOOK_TORRENT_DETECTED mapping + dedup before unique index in migration 0053 ([3b5d5a1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3b5d5a18a1122ef50868fd038a09109d17795a74))
|
||||
|
||||
## [3.45.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.44.0...v3.45.0) (2026-04-03)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* send torrent blocker notification to user (not just admin) ([2f9d003](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2f9d00343bee2980cc89bd24361259073b97127a))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* resolve multiple subscription bugs — LIMITED status, trial tariff blocking, traffic reset strategy, classic mode pricing, 100% discount support ([9b7ac47](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9b7ac47f16076e546da62062ff7ce18d7c308988))
|
||||
* restore missing import + rewrite user.deleted webhook to properly deactivate all subscriptions ([819f09a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/819f09a68ec95237294bae97f31c644044a3623f))
|
||||
* subscription system bugfixes + torrent notifications + user deletion cleanup ([7d24e8d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7d24e8d7047c7a3a1c417e655a6fbccbe5ae577d))
|
||||
|
||||
## [3.44.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.43.1...v3.44.0) (2026-04-02)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* add SberPay as KassaAI sub-method (payment_system_id=43) ([9d63635](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9d636355026ad1e50d045e78ffa21e76cfef0774))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* address review issues in PR [#2829](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/issues/2829) webhook intentional deletion guard ([977950b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/977950b97f07eecf089152d3f4e678fda373e1e6))
|
||||
* autopay failure notifications ignoring 6h cooldown ([991f0b4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/991f0b43e1e73446690a4fbec7c5c5642ac8c406))
|
||||
* middleware disables panel VPN for all subs ignoring per-channel settings ([f284351](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f284351c51a6843db0771a92338ec770d5f0d8d2))
|
||||
* NameError in SeverPay guest payment flow ([2d42152](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2d42152f5491b14cc45388e0ffccf8a61848a2f6))
|
||||
* notification sent for non-deactivated subs + webhook race condition ([b04157c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b04157c91327d9031e9f603a6ad33c708e27d753))
|
||||
* Pal24 card/sbp option not passed to API in cabinet balance topup ([6713921](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/67139218878dca3e75974eb5b5a2ce91d5b1438e))
|
||||
* prevent nested state saves and None state loss in promo handler ([b607993](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b607993854d1374e7d7c2afbb7fe5cc8824732f5))
|
||||
* promo code activation destroys balance input FSM state ([2466590](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/246659032de812f1d4502029ab139f3104237d5c))
|
||||
* remove non-existent Platega method code 10, rename 11 to Карты (RUB) ([033d0da](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/033d0da5e0033a2310431586a291b529e3ccb89a))
|
||||
* send telegram_id@telegram.org as email to Kassa AI ([3dc72b0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3dc72b00e751a69966d2d5830492c82e055b72e6))
|
||||
* send telegram_id@telegram.org as email to SeverPay ([08ca947](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/08ca947b2b2bb29782c86e7b5d6bea71e2811751))
|
||||
|
||||
## [3.43.1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.43.0...v3.43.1) (2026-03-31)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* prevent MissingGreenlet on subscription.tariff lazy load in webhook handlers ([72170b3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/72170b35f5d2af56aa7dcb579a70ecf6af2da3f6))
|
||||
* use subscription-level remnawave_uuid in multi-tariff mode for sync and detail pages ([0c284b9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0c284b9e9941b516170fc68a3f63551096cb5a7b))
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* add Platega partnership to README, highlight partner payment providers ([312cc72](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/312cc728a9321fe9ac90cf1f5201e38465f67f16))
|
||||
|
||||
## [3.43.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.42.0...v3.43.0) (2026-03-29)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* add Remnawave panel 2.7.0 API support ([565c083](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/565c08366bdc1d3dcaba89a8522360e1e4d8c2d8))
|
||||
* add SeverPay support to cabinet balance top-up ([092b9f6](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/092b9f63b24e8129a8e6f9ac0040f19a35514295))
|
||||
* add subscription_id to admin sync endpoints for multi-tariff ([54a19a9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/54a19a9c50d58affd1cd8bc897e360318744e28d))
|
||||
* add tariff identification to all notifications for multi-tariff mode ([7dd67e3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7dd67e36b3a8a408239ec53d0ae2cc230dfd726c))
|
||||
* add tariff_id to promo codes for trial subscription type ([63e4296](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/63e4296197d7943914267f503782e46befc151d4))
|
||||
* **api:** expose email field in UserResponse ([23d1830](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/23d1830644be6ab71da629465604cbb296fa7c02))
|
||||
* DELETE /subscriptions/:id for expired/disabled subscriptions ([c27f144](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c27f144b76ad392c5d6909f8f62cb2b065515eed))
|
||||
* expose MULTI_TARIFF_ENABLED and MAX_ACTIVE_SUBSCRIPTIONS in admin settings ([2628012](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2628012097893ec93cfcb2b8fe190183cfe53a3b))
|
||||
* expose per-inbound traffic breakdown in nodes realtime API ([5d173c8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5d173c806af1e3c31854ae92b194016984cfd80e))
|
||||
* include countryEmoji and providerName in realtime metrics ([b59c581](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b59c581e916e4f6511514463e149a03cc4fb6f8f))
|
||||
* multi-subscription support ([1099c52](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1099c5224c07b4ddc17397b0200f64f22fbf8520))
|
||||
* multi-subscription support (1 user = N subscriptions) ([335be66](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/335be66980bfefae9d39e8f549fb4df1780ce3d0))
|
||||
* return is_daily and is_daily_paused in subscription list API ([4dd8170](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4dd81702ceb5ee9ff46af16249d0dd145da2e3b6))
|
||||
* support email/OAuth users in referral editing and add remove endpoints ([7f60196](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7f60196033fa48f093edd06da8f2162936c76162))
|
||||
* trial lifecycle + purchase-options filter for multi-tariff ([048d208](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/048d208bc1aa188c16fc4e01de836e9f1553b561))
|
||||
* wheel subscription picker for multi-tariff mode ([24edfb6](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/24edfb6c3f83726aff0a7b4566da5762ceb10d72))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* accept subscription_id from query param in renew endpoint (consistent with other endpoints) ([824d54b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/824d54b7dc97a7a4df8a7f34e078e3ff018df442))
|
||||
* account linking broken in multi-tariff mode (MULTI_TARIFF_ENABLED=true) ([2c12a47](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2c12a4773c8b0614a06acaf5bcef2784cc211a5a))
|
||||
* account merge no longer nulls transferred subscriptions' remnawave_uuid ([b0273dc](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b0273dc8aeecbbdd3a72c41c885492026f0aea58))
|
||||
* add missing ADMIN_PAYMENTS localization keys for ru and en ([4f76f53](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4f76f53d55588387251b80de6ac878589299817e))
|
||||
* add period_days validation and zero-price guard to tariff purchase ([cefdfc5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/cefdfc54cc8baea3b4e36740d6913b74a8072b6a))
|
||||
* add redirect_slashes=False to prevent HTTP 307 redirects on subscription endpoints ([f7f8ea8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f7f8ea87cfd5b182e7e395a9aa95c37bd40e165c))
|
||||
* add selectinload for GuestPurchase.user/tariff in gift activation ([84357a1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/84357a1e8715cb176191eace7d019544175822f4))
|
||||
* add tariff identification to remaining notification gaps ([cddb8d6](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/cddb8d63326e2849d674de64b2da508103d5922f))
|
||||
* add_traffic handler passes FSM state to resolve_subscription for multi-tariff context ([684f286](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/684f286fcd9d2c29b1c02d5fc7c9ebf5631f4878))
|
||||
* address remaining review issues in device limit patch ([9726145](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/972614511fb10d2cad91e0b8a52cac4c634fc6d7))
|
||||
* address review issues in device limit patch ([34aec03](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/34aec0323bd78ff159c9e84bdcf584051b3b0fd4))
|
||||
* admin handlers use _resolve_admin_subscription + per-subscription UUID ([147ef6b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/147ef6b22b9518fc806939aba435f81121828641))
|
||||
* admin panel per-subscription UUID in multi-tariff mode ([56fffc2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/56fffc241572ec1d59370bfdb01945877e34e5a0))
|
||||
* admin server/devices/traffic buttons pass subscription_id in multi-tariff ([9a27e6d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9a27e6db3110e171a9cac02728a21cd4a002e9b1))
|
||||
* admin tariff purchase now creates separate RemnaWave user per tariff ([95ba739](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/95ba73995820ac5c154226a370979437809d3f8c))
|
||||
* assign promo group from tariff on guest purchase ([da11ec6](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/da11ec6f946e6a439111c31c6e5387dab4167303))
|
||||
* async tariff loading in promocode serialization ([3bec662](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3bec6620b67f0dfe7926a030ee98ef0f08e5673e))
|
||||
* auto-purchase processes each autopay subscription independently ([f89e326](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f89e326a19f37ec2189fe01814cd3a4cf69c4d02))
|
||||
* back button in subscriptions list uses correct callback ([382e29d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/382e29d3dd20cca8f231e444181703bf72c45c86))
|
||||
* back buttons in devices/traffic return to subscription detail in multi-tariff ([319941d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/319941d33a43552c09ba5fb95e1a165dd1603999))
|
||||
* block classic subscription renewal/autopay when tariff mode enabled ([cd6913c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/cd6913cb849ccefe2b36de786068890c8117b1d8))
|
||||
* block legacy subscription renewal bypass in tariff mode ([78209c8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/78209c862320ef030134ac7e77c57229b5701cfd))
|
||||
* cabinet admin create subscription now creates new RemnaWave user in multi-tariff mode ([31c67d1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/31c67d15657d88df24ba490e0d47a9c3104883af))
|
||||
* cabinet purchase_tariff — handle IntegrityError with compensating refund ([1bc2581](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1bc2581669ecc3b47b12283c7fb6988bbe34cb28))
|
||||
* cabinet routes use smart subscription fallback + per-subscription UUID ([f83ff26](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f83ff26332c8057f5cbb1725f1d5db74e23af9bf))
|
||||
* centralize trial cleanup in CRUD + shared subscription resolver for bot ([355fef8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/355fef846e786b638fdcf8823fc077ace89e8615))
|
||||
* comprehensive multi-subscription audit fixes across routes, handlers, and services ([d071269](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d071269b8c4df08f91b3a94e09d3a37686082f8a))
|
||||
* comprehensive tariff switch/extend/back button fixes for multi-tariff ([e42bddb](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e42bddb868414aec8df55c071adab63a88b30696))
|
||||
* contest prize applies to best non-daily subscription in multi-tariff ([d2bbeb8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d2bbeb8624ca1320b38a300de0417f64747a6286))
|
||||
* daily tariff switch uses _resolve_subscription instead of searching by new tariff_id ([4e12ab3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4e12ab3458c1c73ed4ffe62ca8688ff917a64e4c))
|
||||
* delete subscription from RemnaWave panel + prevent phantom webhook notifications ([a12ffb1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a12ffb1d6c30bc2d73888f5c352c12fc3d0fd95c))
|
||||
* device limit decrease, HWID pagination, tariff max enforcement ([931eeb3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/931eeb35689532a892e14bc76a05fea1a8f2cec3))
|
||||
* devices button shows menu with buy + manage options in multi-tariff ([f925efb](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f925efbfb47de8dc8e5932eab629ee6beb7c61e2))
|
||||
* disable redirect_slashes globally to prevent HTTP 307 on subscription endpoints ([06feb3f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/06feb3fff589616b6cf78a2cdf6ba5c33cca7794))
|
||||
* distinguish cabinet gift notifications from landing page ([48eaa6b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/48eaa6b0724a3f881d144abc723fa8373ee9a67f))
|
||||
* eligibility and display use best non-daily subscription in multi-tariff ([76ba19d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/76ba19da175553597599fcbcc8513221078e01f6))
|
||||
* fix MiniApp renewal options 500 error for legacy subscriptions ([aa36549](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/aa36549bb3bc1e04362750b7eecee23f9ba99d88))
|
||||
* gift code activation and multi-tariff subscription sync ([f93c51a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f93c51a6773e706a6fc6f9b6016868e181400762))
|
||||
* harden node info display against injection and type errors ([6d167d2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6d167d292266a660f0d6fd906db3cdea84e8c9cf))
|
||||
* harden webhook signature verification across all payment providers ([8295880](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/82958801b5179ff5703e6d11b3b0b30f74581eeb))
|
||||
* import Subscription in wheel_service to fix NameError ([34bb87c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/34bb87c7baf34b4d9c0c09a6b3fe2b76378d9a66))
|
||||
* improve UX for legacy users migrating to tariff mode ([f6f330d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f6f330db4a2e6aa5d14a616b9fd2365577f928a1))
|
||||
* load buyer relationship before gift notification, clean up recipient logic ([adb39c6](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/adb39c6ef46507966482cdceed8ba03e861285ee))
|
||||
* multi-subscription support for promocodes, contests, phantom merge ([6d468e9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6d468e9adacc8c111cdb5145dc048f7eea6e93e4))
|
||||
* multi-subscription UUID resolution and ownership validation ([d87fb47](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d87fb47e886eebb3a1e75b1dae1982230f7bac72))
|
||||
* multi-tariff code review — 13 critical/high bugs fixed across 14 files ([5724906](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/57249065178900a8626181556119464c7d55fd74))
|
||||
* multi-tariff MEDIUM/LOW batch — 20 issues across 17 files ([94ed282](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/94ed282381fcc71efdef8b55c2e566b2fcd92c2c))
|
||||
* multi-tariff Stage 2 critical fixes — panel sync, guest purchase, cart isolation ([4259ba1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4259ba1cb57020314f478d7efc78c915fce9e9b6))
|
||||
* multi-tariff Stage 2 HIGH fixes — 18 issues across 12 files ([c6bedc6](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c6bedc6a061cd6ff2010f1345d6bae42e4282fbd))
|
||||
* multi-tariff Stage 3 critical fixes — panel sync UUID, admin grant, wheel ([49db5f5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/49db5f5eedb452aac7b12739ce735abb8c3b99d2))
|
||||
* multi-tariff Stage 3 HIGH fixes — phantom, cart, yookassa, auto-extend ([aa7e461](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/aa7e461c4451fc477ac9a1714c8e2243fe12ba49))
|
||||
* multi-tariff Stage 4 critical fixes — keyboards, guest purchase, monitoring, tariff deletion ([a49e52c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a49e52cc92819b10d1d4afc823cf98b534a84a0f))
|
||||
* multi-tariff Stage 5 fixes — auth sync, notifications, cart, race guard ([948e479](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/948e4791f49a0626100af8ada29da8b605392566))
|
||||
* multi-tariff sync auto-links legacy user-level UUIDs to subscriptions ([dbe247b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/dbe247ba6f8cea2ccb845ceb1cfd87d76d47dc52))
|
||||
* notifications include tariff name for multi-subscription clarity ([05d1ae0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/05d1ae0560f63627c17b629d476088ce21398863))
|
||||
* parse_bytes now handles IEC units (GiB, MiB, KiB) from API ([1471320](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/147132060659e828b005160003d5bff1a565e280))
|
||||
* pass FSM state to _resolve_subscription across all subscription handlers ([90fb0a2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/90fb0a21e227d46ed5624051d8185a1e026b0806))
|
||||
* pass sub_id to show_devices_page to fix NameError in multi-tariff ([59d4b35](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/59d4b353a6456fc40682239df441b23bc8d63f6d))
|
||||
* persist referral to Redis on /start to prevent loss when user opens miniapp ([6d9bd99](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6d9bd9915cda01d819b4c3db02115777c8cb7500))
|
||||
* post-payment keyboard checks all subscriptions instead of LIMIT 1 ([25b853d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/25b853d629f9581a9f7871142751dcb2b1e73d3a))
|
||||
* prevent sync from overwriting wrong subscription traffic in multi-tariff mode ([78a7eaf](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/78a7eafcb65a175dd8f5ded304138ebd8c47acd8))
|
||||
* prevent sync/from-panel cross-subscription data mismatch ([960aa44](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/960aa44b007b454f94403cedf98033c9a2c60dec))
|
||||
* promocode system broken in multi-tariff mode ([3cbe09d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3cbe09ddd54bec6568b49fbd2ed961649160d21f))
|
||||
* re-fetch subscription after lock_user_for_pricing to prevent selectinload reset ([71082f4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/71082f436c6a49409b84e84a367e6059abea850e))
|
||||
* remnawave service uses per-subscription UUID throughout multi-tariff ([afd7b6d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/afd7b6d7ec777fd380b43c77da55d3114ff837d7))
|
||||
* RemnaWave sync finds user by Subscription.remnawave_uuid in multi-tariff ([c3c2b81](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c3c2b8137b5b8e656b0368742e55a99c4d6a3ec3))
|
||||
* remove unused imports and variables after rebase ([b6cf361](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b6cf361737653f7601515126b2e2d073a8a5ca0f))
|
||||
* remove user.subscription setter - use local variable instead ([2f88b07](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2f88b07f05cb568c6a6f0bde9c7b7e91d4309d66))
|
||||
* remove UUID fallback override in admin_tariffs + restore promo on IntegrityError ([a232d21](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a232d21edd9714082a7943f9e23db4241f188d66))
|
||||
* rename refresh('subscription') to refresh('subscriptions') in all files ([e99f3d9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e99f3d9a374b1ab32bbefd8be2036aa185e50f5c))
|
||||
* renewal handlers use _resolve_subscription + store subscription_id in FSM ([87bf65c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/87bf65c8097d270a139402ab5e976b6215735c99))
|
||||
* renewal status check, int() safety, daily charge atomicity ([58d899a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/58d899aab89ff41ef86a2c578613a59720fdab31))
|
||||
* renumber multi-subscription migrations to avoid conflicts with dev ([5a7b3d5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5a7b3d59627f61b3afc7d23ec08d00d9c07c7f10))
|
||||
* resolve MissingGreenlet error on article detail view ([004dac5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/004dac5b7ef76b2b1e98e0ab0f62d9fe7fcbd12b))
|
||||
* services use smart subscription selection + per-subscription UUID ([0866c2e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0866c2ea4b3183dc63e5ea88391d83dd03f30428))
|
||||
* set is_daily_paused=True when admin cancels/disables daily subscription to prevent auto-resume ([d04f2fc](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d04f2fc718d11a4766627fda83bdee3abf7f15ea))
|
||||
* show all subscriptions in main menu for multi-tariff mode ([e39c358](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e39c358d5caf00674c8d148757b66d46b15485c2))
|
||||
* show subscription picker for traffic/connect buttons with multiple subs ([a39e355](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a39e3554d8b91eac676cf4e3b4cfe6a0dd9caa82))
|
||||
* suppress empty reward alerts and clean up referral notifications ([e3d8d21](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e3d8d21b662761dca1ffe5938e3ce4d72275be84))
|
||||
* tariff purchase shows purchased tariffs and blocks re-buying in multi-tariff ([9644135](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9644135dd7a1c1e951274a73a56ec75fd715fe62))
|
||||
* tariff_purchase next() fallbacks use None instead of active_subs[0] in multi-tariff ([72d5bae](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/72d5bae531ae04a36fbc44a6803de7f17020f846))
|
||||
* test access promo applies to all active subscriptions in multi-tariff ([181ef15](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/181ef1501b31837585958f89631c1aa36b1872a2))
|
||||
* transliterate Cyrillic slugs instead of stripping to 'untitled' ([c805cfd](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c805cfd6d8335f147991458e42fa8a19121a5126))
|
||||
* trial promo extends existing subscription with same tariff ([b8662b8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b8662b8bf6c341f391a25940dea928a043396f04))
|
||||
* trial reset in multi-tariff only deletes trial subscriptions, keeps paid ([424fff4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/424fff4ac29e697268eeb57d5b5bacd498c691e8))
|
||||
* trial subscription lifecycle — autopay, cleanup on purchase, bonus days ([344852b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/344852b85223b49fc52f1401d63b198e1a8412c5))
|
||||
* use '/' instead of empty path in subscription sub-routers ([07ebc43](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/07ebc435cf2544c31e0c55bb543775ffcd252e01))
|
||||
* use empty path instead of '/' for multi-tariff list endpoint to avoid 404 ([c8ecec4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c8ecec47a0af59911407d8d5a9d6df3f2afc3fb8))
|
||||
* UUID check in servers/tariff_switch, start.py refresh, delegation state passing ([bd46b4c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bd46b4cf6dcd7cd65a42456d741b957a22aa7863))
|
||||
* UUID warnings, phantom merge, yookassa validation, contest prize notification ([fe03b58](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fe03b587db96eb365a718e21a0177dd6e6e6a480))
|
||||
* validate_and_clean_subscription uses per-subscription UUID in multi-tariff mode, not user-level UUID ([18f31c5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/18f31c565c7c0217ebd2348389466c16dc17fc3f))
|
||||
* web API routes use multi-subscription resolution for operations ([a1623d9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a1623d94b119025f59263749eaa727bc910dc467))
|
||||
|
||||
|
||||
### Refactoring
|
||||
|
||||
* remove dead multi-tariff check in guest purchase activation ([34b5a9a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/34b5a9ab3a0eab8561ddd6930b59b817bc2ef9b4))
|
||||
* update remnawave API integration for v2.7.0 ([173cc37](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/173cc374bb8391359d8f54569565f18a82fc28eb))
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* add Stage 3+4 audit results to multi-tariff review ([6dc5879](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6dc5879ffa7edb77166291fbda9e03fbe532bc2d))
|
||||
* update multi-tariff review with Stage 2 full audit results ([40d2ec6](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/40d2ec67189064b41774292029a397fde2a1c863))
|
||||
|
||||
## [3.42.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.41.0...v3.42.0) (2026-03-23)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* add managed news categories and tags with DB-backed CRUD ([51392d1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/51392d1918d8e2e94645acfb3a11b8e16776a5d5))
|
||||
* add media upload/delete API for news articles ([a0d40ad](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a0d40ad432d858ebfe75485a9597d32e847d5746))
|
||||
* add news articles module with admin CRUD and public API ([b932403](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b93240393f739f1243bbbfdd4298b90974b8fa87))
|
||||
* enforce single featured news article — unfeature others on toggle/create/update ([b5853ec](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b5853ec3b6769655f8d19914e51fd078ec9edccc))
|
||||
* show Platega payment methods inline on main screen ([#2720](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/issues/2720)) ([334db53](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/334db53868ae2f9206fdde97fa575e953a83cbcf))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add explicit File(...) to UploadFile param to fix 422 on media upload ([89bfdc8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/89bfdc8ed6bfba48cfcb61083240d5e6e870f49b))
|
||||
* add Literal type to SavedMedia and close orphaned PIL Image objects ([ce554cb](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ce554cb2a8e606b6060dbf63463e64087a3a7539))
|
||||
* add user ID to payment descriptions for all providers and fix tuple bug ([2f19c76](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2f19c76357fe7de1636d65968cb13f784ee47c31))
|
||||
* catch DecompressionBombError, hoist MP4 brands to module level ([172924d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/172924df0e1248b27d3f18f6acf23cf115040941))
|
||||
* comprehensive html.escape() for all user/admin data in Telegram HTML messages ([9de3490](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9de34900a2a9047ab275c0bbf16fd314eb49a3ec))
|
||||
* comprehensive security hardening across payment and API layers ([8175bc8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8175bc8bfe56dc564a0783c45226451b695bdbd6))
|
||||
* correctly price unlimited traffic (0 GB) in classic subscription mode ([aec04f0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/aec04f0085bd9c566bd033b8bb628389ff22bdf6))
|
||||
* create uploads subdirectories in Dockerfile for correct permissions ([5ed3780](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5ed3780f830023f7b1940dc96256fd38e3c86f4b))
|
||||
* media upload security hardening from 6-agent review ([165d25e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/165d25ef5fc02eecf7c6d703072b6adbe88bfd98))
|
||||
* news module security hardening, perf optimizations, bug fixes ([2b91808](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2b91808b0c72381cfb2f4e36eaff69e9102a32ee))
|
||||
* phantom user merge on claim failure, referral assignment, account merge hardening ([fad77f8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fad77f8c80a8fdecb0512f0ff91e5ae6d78ec8f3))
|
||||
* register categories/tags/media routers before news to avoid route conflict ([d9cda3a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d9cda3a6d67c6249397e3778c1aefc6bdb6d9e4f))
|
||||
* reject HEIC as MP4, close UploadFile, narrow exception handling ([7ff73e8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7ff73e8492343be5ed77fad5eea7e730621eac76))
|
||||
* remove future annotations breaking UploadFile, harden media URL generation ([0225fa1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0225fa155b7d4f3d1c6572a68651c206b1820db0))
|
||||
* replace asyncio.gather with sequential queries on shared session ([3e69efe](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3e69efe5891bbe8869a7feb2320185e14d83dd73))
|
||||
* respect per-channel disable_on_leave settings in monitoring service ([958ec48](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/958ec489a2a1d01d19cb6e60f52fb2be56295104))
|
||||
* respect X-Forwarded-Proto in media URL generation to prevent mixed content ([fd41009](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fd410096ea7e541d8443ec4336bfd72de63d03d9))
|
||||
* restore connected_squads and admin notification on daily subscription resume ([89341ba](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/89341baa6243496d3b24c501e227ed156676c052))
|
||||
* simplify 0046 migration downgrade to just drop_table ([015c2da](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/015c2da297e4250ebfa50e32b3c1468c9a2143f3))
|
||||
* suppress harmless TelegramBadRequest errors and fix discount promo display ([0fe3c21](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0fe3c217f752ced2e3c3f56b4ab5b4c898ba2d8e))
|
||||
* use IF EXISTS in downgrade for FK indexes ([76b1f9b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/76b1f9b036e276646546aa4c89a3fe1d2ee58a40))
|
||||
* validate FK existence, add FK indexes, expand video brand whitelist ([f0cdd5d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f0cdd5dc904926b55be390798a71a5427a20949b))
|
||||
* validate period_days against tariff in purchase-tariff and auto-purchase ([4660ca5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4660ca5756f5f1ef8033f9202d68612d570ddb1a))
|
||||
|
||||
|
||||
### Refactoring
|
||||
|
||||
* extract phantom service, replace lightweight merge with execute_merge ([6658af6](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6658af6268c10db170e3eaabc75f857e5c664c3c))
|
||||
* simplify referral invite text to single template ([cbe630c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/cbe630cab0973d7d71dd72603b114807b237ac84))
|
||||
|
||||
## [3.41.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.40.0...v3.41.0) (2026-03-22)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* add subscription status to referral network graph nodes ([de91d32](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/de91d3282ffa15c0cec60c0d62871d39e7ee4c05))
|
||||
* add total subscription revenue to referral network stats ([2bdb764](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2bdb7643f8fd142e99caee0fe989348161377348))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add abs() to all remaining subscription payment sum queries ([1eb4e18](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1eb4e18c1776b2265a48e0b923a0ca4ee057d912))
|
||||
* add missing total_subscription_revenue_kopeks in scoped graph early return ([bcc761f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bcc761f9d3f673bd2b404adf817762058d8e0df4))
|
||||
* consider subscription status field in network graph ([454dc93](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/454dc9321bb9405c5ff0ff559ae4ced15533f3af))
|
||||
* superadmin role managed exclusively via env config ([e0bedc8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e0bedc8e780a2f91509517110639773e90bb6125))
|
||||
* treat expired and limited subscription statuses as inactive in referral network graph ([5ed2f0c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5ed2f0c95842a43ab57220dc05ca346748bd6adb))
|
||||
* use abs() for subscription payment amounts in referral network ([056c13b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/056c13bc23e6737f44bbcb802a66b643349f75a9))
|
||||
|
||||
|
||||
### Refactoring
|
||||
|
||||
* extract _compute_subscription_status shared helper ([8b8f1b9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8b8f1b91f37f829528f785a40e3a9cb98c85e043))
|
||||
|
||||
## [3.40.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.39.0...v3.40.0) (2026-03-22)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* allow inactive tariffs for trial subscription activation ([cce3b0c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/cce3b0c13bcbf0b567bd4dcf2670973382e7cab0))
|
||||
* custom broadcast buttons and fix home button to use bot menu ([13ea376](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/13ea3768b516337c4e0320120bc60a9acb27a16b))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* accept stale Telegram initData to prevent MiniApp auth failures ([4c2cb63](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4c2cb63cf9f71fb392c3723a99e88ca3d02b127d))
|
||||
* daily subscription pause not persisting in cabinet and miniapp ([d3c9940](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d3c994083e3b054d02d4911172968c914724d051))
|
||||
* handle spurious user.deleted webhooks — preserve active subscriptions and prevent orphaned panel users ([9eab802](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9eab80200006e576967204b52f90bf9866875917))
|
||||
* prevent MESSAGE_TOO_LONG in promo groups list ([c307278](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c30727823169159b4b6b61f54897b209ced8dfd2))
|
||||
* referral system — self-referral protection, race condition fix, deleted user re-registration ([ed5a92a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ed5a92ab966dac54c15217050eae87f4b05eed62))
|
||||
* sanitize email dots in RemnaWave username generation ([6c20858](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6c208581d936f5ab7d6b978baafd50881b8ce9f1))
|
||||
* send DISABLED instead of EXPIRED status to RemnaWave API ([79cfcbc](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/79cfcbcece3938f2daa83206f96ec1bffd0857e0))
|
||||
|
||||
## [3.39.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.38.0...v3.39.0) (2026-03-21)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* add NaloGO fiscal receipts for code-only gift purchases ([90209eb](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/90209ebef1a872665e622124a1898d52eff398e7))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add NaloGO fiscal receipt creation for landing page purchases ([4244962](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/424496233773b4cee4e389a1172e95208b3afeaf))
|
||||
* manual admin top-ups missing from sales statistics ([ab43e74](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ab43e74ab7484f8d3517f91e366ea395e1944b99))
|
||||
* skip non-JSON payload rows in cryptobot payment index and query ([ba79d03](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ba79d03e389afed972296fe2bc05104aa6b883f3))
|
||||
|
||||
## [3.38.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.37.0...v3.38.0) (2026-03-21)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* add SOCKS proxy support for nalogo (tax service) module ([3c5bf4f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3c5bf4fa22d1cdf144269f4e6ab32a4523c8f1f3))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add diagnostic payload logging in create_user error path ([4990ddf](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4990ddf9e46495b65fc3638ea8d6bed0cbe6b857))
|
||||
* retry Remnawave API calls without externalSquadUuid on A039 FK violation ([de00612](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/de006129657ce3dac2b1f2fc0ab1b91e23e44241))
|
||||
* sanitize proxy credentials in all nalogo error paths ([3bf3105](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3bf31055e71ff64e6a6d94486bb7f7775ac7dc91))
|
||||
|
||||
## [3.37.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.36.1...v3.37.0) (2026-03-21)
|
||||
|
||||
|
||||
|
||||
+3
-2
@@ -19,7 +19,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
|
||||
FROM python:3.13-slim
|
||||
|
||||
ARG VERSION="v3.37.0" # x-release-please-version
|
||||
ARG VERSION="v3.46.0" # x-release-please-version
|
||||
ARG BUILD_DATE
|
||||
ARG VCS_REF
|
||||
|
||||
@@ -33,7 +33,8 @@ WORKDIR /app
|
||||
|
||||
COPY --chown=app:app . .
|
||||
|
||||
RUN mkdir -p logs data && chown app:app logs data
|
||||
RUN mkdir -p logs data uploads/images uploads/videos uploads/thumbnails && \
|
||||
chown -R app:app logs data uploads
|
||||
|
||||
USER app
|
||||
|
||||
|
||||
@@ -41,21 +41,21 @@ Bedolaga — полнофункциональная платформа для п
|
||||
|
||||
### 📦 Подписки и тарифы
|
||||
|
||||
- 🎯 Гибкие тарифные планы (от 14 дней до года)
|
||||
- 📊 Трафик: безлимит, фиксированный лимит или пакеты
|
||||
- 📱 Управление устройствами (1–20 на подписку)
|
||||
- 🌍 Автовыбор сервера или ручной выбор
|
||||
- 🆓 Пробный период с конвертацией в платный
|
||||
- 🎯 Гибкие тарифные планы (от X дней до X дней)
|
||||
- 📊 Трафик: безлимит, фиксированный лимит или пакеты с возможностью докупки
|
||||
- 📱 Управление устройствами (1–20 на подписку) или отключение лимитов
|
||||
- 🌍 Автовыбор сервера(Тарифы) или ручной выбор(Конфигуратор подписки - с возможностью докупки)
|
||||
- 🆓 Пробный период(Возможен платный) с конвертацией в платный
|
||||
- 🛒 Умная корзина — сохраняет выбор при недостатке баланса
|
||||
- 🔄 Автопродление за 3 дня до окончания
|
||||
- 🎁 Подарочные подписки
|
||||
- 🎁 Подарочные подписки и конфигурируемые лендинги для быстрой продажи в вебе без авторизации
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
### 💳 Платежи
|
||||
|
||||
- 🏦 **14 платёжных провайдеров** одновременно
|
||||
- 🏦 **15 платёжных провайдеров** одновременно
|
||||
- 💰 Единый баланс: пополнение любым способом → покупка с баланса
|
||||
- ⚡ Автопокупка подписки после пополнения
|
||||
- 💾 Рекуррентные платежи (сохранённые карты)
|
||||
@@ -72,11 +72,13 @@ Bedolaga — полнофункциональная платформа для п
|
||||
|
||||
- 🏷 Промокоды (деньги, дни подписки, триалы)
|
||||
- 👥 Реферальная программа с выводом средств
|
||||
- 👥 Партнерская система
|
||||
- 📨 Рассылки по сегментам пользователей
|
||||
- 🌐 Кастомные лендинги с аналитикой
|
||||
- 🎮 Конкурсы и ежедневные игры с призами
|
||||
- 🎯 Персональные предложения и скидки
|
||||
- 📈 Маркетинговые кампании с трекингом
|
||||
- 🌐 Обязательная мультиподписка на каналы с возможностью автоотключения подписки - при отписки от канала
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
@@ -91,6 +93,9 @@ Bedolaga — полнофункциональная платформа для п
|
||||
- 📡 Мониторинг трафика и аномалий
|
||||
- 🤝 Партнёрская программа
|
||||
- 🔐 RBAC: роли и гранулярные права доступа
|
||||
- 📈 Детальная отчетность с возможностью визуализации Реф сети
|
||||
- 🔐 Блокировка юзеров из общего черного списка
|
||||
И многое др...
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
@@ -113,8 +118,8 @@ Bedolaga — полнофункциональная платформа для п
|
||||
| 💳 | **Freekassa** | NSPK СБП, карты | RUB |
|
||||
| 💳 | **Kassa AI** | СБП, карты, SberPay | RUB |
|
||||
| 💳 | **PayPalych (Pal24)** | Карты, СБП | RUB |
|
||||
| 💳 | **Platega** | Карты, СБП, крипто | RUB |
|
||||
| 💳 | **WATA** | СБП, Карты | RUB |
|
||||
| 🤝 | **[Platega](https://t.me/ArstanPlatega)** 🔸 | Карты, СБП, крипто | RUB |
|
||||
| 🤝 | **[WATA](https://t.me/wyrz_wata)** 🔸 | СБП, Карты | RUB |
|
||||
| 💳 | **MulenPay** | Карты | RUB |
|
||||
| 💳 | **RioPay** | Карты | RUB |
|
||||
| 💳 | **SeverPay** | СБП, карты | RUB |
|
||||
@@ -122,6 +127,8 @@ Bedolaga — полнофункциональная платформа для п
|
||||
|
||||
</div>
|
||||
|
||||
> 🔸 — официальный партнёр Bedolaga (особые условия по кодовому слову **`bedolaga`**)
|
||||
>
|
||||
> Все провайдеры работают параллельно через единый веб-сервер на порту 8080. Подробная настройка — в [документации](https://docs.bedolagam.ru/bot/payments).
|
||||
|
||||
<div align="center">
|
||||
@@ -129,6 +136,18 @@ Bedolaga — полнофункциональная платформа для п
|
||||
<tr>
|
||||
<td align="center">
|
||||
|
||||
<img src=".github/assets/platega-logo.jpg" alt="Platega" width="60" />
|
||||
|
||||
**🤝 Официальный партнёр Platega**
|
||||
|
||||
Bedolaga — официальный партнёр платёжной системы **Platega**.<br>
|
||||
Пользователи бота получают **особые условия** при подключении по кодовому слову **`bedolaga`**
|
||||
|
||||
📩 По вопросам: [@ArstanPlatega](https://t.me/ArstanPlatega)
|
||||
|
||||
</td>
|
||||
<td align="center">
|
||||
|
||||
<img src=".github/assets/wata-logo.jpg" alt="WATA" width="60" />
|
||||
|
||||
**🤝 Официальный партнёр WATA**
|
||||
|
||||
+32
-7
@@ -67,6 +67,7 @@ from app.middlewares.blacklist import BlacklistMiddleware
|
||||
from app.middlewares.button_stats import ButtonStatsMiddleware
|
||||
from app.middlewares.chat_type_filter import ChatTypeFilterMiddleware
|
||||
from app.middlewares.context_binding import ContextVarsMiddleware
|
||||
from app.middlewares.display_name_restriction import DisplayNameRestrictionMiddleware
|
||||
from app.middlewares.global_error import GlobalErrorMiddleware
|
||||
from app.middlewares.logging import LoggingMiddleware
|
||||
from app.middlewares.maintenance import MaintenanceMiddleware
|
||||
@@ -101,12 +102,16 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
bot = create_bot()
|
||||
|
||||
proxy_url = settings.get_proxy_url()
|
||||
if proxy_url:
|
||||
from urllib.parse import urlparse
|
||||
nalogo_proxy_url = settings.get_nalogo_proxy_url()
|
||||
|
||||
parsed = urlparse(proxy_url)
|
||||
masked = f'{parsed.scheme}://***@{parsed.hostname}:{parsed.port}' if parsed.username else proxy_url
|
||||
logger.info('Proxy configured', proxy_url=masked)
|
||||
if proxy_url or nalogo_proxy_url:
|
||||
from app.utils.proxy import mask_proxy_url
|
||||
|
||||
if proxy_url:
|
||||
logger.info('Proxy configured', proxy_url=mask_proxy_url(proxy_url))
|
||||
if nalogo_proxy_url:
|
||||
source = 'NALOGO_PROXY_URL' if settings.NALOGO_PROXY_URL else 'PROXY_URL (fallback)'
|
||||
logger.info('Nalogo proxy configured', proxy_url=mask_proxy_url(nalogo_proxy_url), source=source)
|
||||
|
||||
maintenance_service.set_bot(bot)
|
||||
logger.info('Бот установлен в maintenance_service')
|
||||
@@ -129,11 +134,11 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
chat_type_filter = ChatTypeFilterMiddleware()
|
||||
dp.message.middleware(chat_type_filter)
|
||||
dp.callback_query.middleware(chat_type_filter)
|
||||
dp.message.middleware(LoggingMiddleware())
|
||||
dp.callback_query.middleware(LoggingMiddleware())
|
||||
dp.message.middleware(GlobalErrorMiddleware())
|
||||
dp.callback_query.middleware(GlobalErrorMiddleware())
|
||||
dp.pre_checkout_query.middleware(GlobalErrorMiddleware())
|
||||
dp.message.middleware(LoggingMiddleware())
|
||||
dp.callback_query.middleware(LoggingMiddleware())
|
||||
dp.message.middleware(MaintenanceMiddleware())
|
||||
dp.callback_query.middleware(MaintenanceMiddleware())
|
||||
blacklist_middleware = BlacklistMiddleware()
|
||||
@@ -158,8 +163,12 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
dp.message.middleware(AuthMiddleware())
|
||||
dp.callback_query.middleware(AuthMiddleware())
|
||||
dp.pre_checkout_query.middleware(AuthMiddleware())
|
||||
display_name_restriction = DisplayNameRestrictionMiddleware()
|
||||
dp.message.middleware(display_name_restriction)
|
||||
dp.callback_query.middleware(display_name_restriction)
|
||||
dp.message.middleware(SubscriptionStatusMiddleware())
|
||||
dp.callback_query.middleware(SubscriptionStatusMiddleware())
|
||||
dp.pre_checkout_query.middleware(SubscriptionStatusMiddleware())
|
||||
start.register_handlers(dp)
|
||||
menu.register_handlers(dp)
|
||||
subscription.register_handlers(dp)
|
||||
@@ -271,12 +280,28 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
except Exception as e:
|
||||
logger.warning('Failed to load menu layout cache', error=e)
|
||||
|
||||
try:
|
||||
from app.services.remnawave_retry_queue import remnawave_retry_queue
|
||||
|
||||
await remnawave_retry_queue.start()
|
||||
logger.info('RemnaWave retry queue запущен')
|
||||
except Exception as e:
|
||||
logger.error('Ошибка запуска RemnaWave retry queue', error=e)
|
||||
|
||||
logger.info('Бот успешно настроен')
|
||||
|
||||
return bot, dp
|
||||
|
||||
|
||||
async def shutdown_bot():
|
||||
try:
|
||||
from app.services.remnawave_retry_queue import remnawave_retry_queue
|
||||
|
||||
await remnawave_retry_queue.stop()
|
||||
logger.info('RemnaWave retry queue остановлен')
|
||||
except Exception as e:
|
||||
logger.error('Ошибка остановки RemnaWave retry queue', error=e)
|
||||
|
||||
try:
|
||||
await maintenance_service.stop_monitoring()
|
||||
logger.info('Мониторинг техработ остановлен')
|
||||
|
||||
@@ -49,7 +49,17 @@ def validate_telegram_login_widget(data: dict[str, Any], max_age_seconds: int =
|
||||
auth_time = datetime.fromtimestamp(int(auth_date), tz=UTC)
|
||||
age = (datetime.now(UTC) - auth_time).total_seconds()
|
||||
if age > max_age_seconds or age < -_MAX_CLOCK_SKEW_SECONDS:
|
||||
logger.warning(
|
||||
'Telegram widget auth rejected: too old',
|
||||
age_hours=round(age / 3600, 1),
|
||||
max_age_hours=round(max_age_seconds / 3600, 1),
|
||||
)
|
||||
return False
|
||||
if age > 86400:
|
||||
logger.info(
|
||||
'Telegram widget auth accepted with stale auth_date',
|
||||
age_hours=round(age / 3600, 1),
|
||||
)
|
||||
except (ValueError, TypeError, OSError):
|
||||
return False
|
||||
|
||||
@@ -96,7 +106,17 @@ def validate_telegram_init_data(init_data: str, max_age_seconds: int = 86400) ->
|
||||
auth_time = datetime.fromtimestamp(int(auth_date), tz=UTC)
|
||||
age = (datetime.now(UTC) - auth_time).total_seconds()
|
||||
if age > max_age_seconds or age < -_MAX_CLOCK_SKEW_SECONDS:
|
||||
logger.warning(
|
||||
'Telegram initData rejected: too old',
|
||||
age_hours=round(age / 3600, 1),
|
||||
max_age_hours=round(max_age_seconds / 3600, 1),
|
||||
)
|
||||
return None
|
||||
if age > 86400:
|
||||
logger.info(
|
||||
'Telegram initData accepted with stale auth_date (Telegram caching bug)',
|
||||
age_hours=round(age / 3600, 1),
|
||||
)
|
||||
except (ValueError, TypeError, OSError):
|
||||
return None
|
||||
|
||||
|
||||
@@ -13,6 +13,10 @@ 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_news import router as admin_news_router
|
||||
from .admin_news_categories import router as admin_news_categories_router
|
||||
from .admin_news_media import router as admin_news_media_router
|
||||
from .admin_news_tags import router as admin_news_tags_router
|
||||
from .admin_partners import router as admin_partners_router
|
||||
from .admin_payment_methods import router as admin_payment_methods_router
|
||||
from .admin_payments import router as admin_payments_router
|
||||
@@ -42,6 +46,7 @@ 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 .news import router as news_router
|
||||
from .notifications import router as notifications_router
|
||||
from .oauth import router as oauth_router
|
||||
from .partner_application import router as partner_application_router
|
||||
@@ -50,6 +55,7 @@ from .promo import router as promo_router
|
||||
from .promocode import router as promocode_router
|
||||
from .referral import router as referral_router
|
||||
from .subscription import router as subscription_router
|
||||
from .subscription_modules.multi_tariff import router as multi_tariff_subscription_router
|
||||
from .ticket_notifications import (
|
||||
admin_router as admin_ticket_notifications_router,
|
||||
router as ticket_notifications_router,
|
||||
@@ -61,7 +67,7 @@ from .withdrawal import router as withdrawal_router
|
||||
|
||||
|
||||
# Main cabinet router
|
||||
router = APIRouter(prefix='/cabinet', tags=['Cabinet'])
|
||||
router = APIRouter(prefix='/cabinet', tags=['Cabinet'], redirect_slashes=False)
|
||||
|
||||
# Include all sub-routers
|
||||
router.include_router(auth_router)
|
||||
@@ -69,6 +75,7 @@ router.include_router(oauth_router)
|
||||
router.include_router(account_linking_router)
|
||||
router.include_router(merge_router)
|
||||
router.include_router(subscription_router)
|
||||
router.include_router(multi_tariff_subscription_router)
|
||||
router.include_router(balance_router)
|
||||
router.include_router(referral_router)
|
||||
router.include_router(partner_application_router)
|
||||
@@ -85,6 +92,7 @@ router.include_router(info_router)
|
||||
router.include_router(branding_router)
|
||||
router.include_router(landing_router)
|
||||
router.include_router(media_router)
|
||||
router.include_router(news_router)
|
||||
|
||||
# Wheel routes
|
||||
router.include_router(wheel_router)
|
||||
@@ -126,6 +134,12 @@ router.include_router(admin_apps_router)
|
||||
router.include_router(admin_roles_router)
|
||||
router.include_router(admin_policies_router)
|
||||
router.include_router(admin_audit_log_router)
|
||||
# Categories/tags/media routers MUST be before the main news router
|
||||
# to avoid /admin/news/{article_id} catching /admin/news/categories etc.
|
||||
router.include_router(admin_news_categories_router)
|
||||
router.include_router(admin_news_tags_router)
|
||||
router.include_router(admin_news_media_router)
|
||||
router.include_router(admin_news_router)
|
||||
|
||||
# WebSocket route
|
||||
router.include_router(websocket_router)
|
||||
|
||||
@@ -487,7 +487,8 @@ async def link_telegram(
|
||||
|
||||
if request.init_data:
|
||||
# Mini App flow: validate initData
|
||||
user_data = validate_telegram_init_data(request.init_data)
|
||||
# Generous max_age: Telegram Desktop/iOS cache initData with stale auth_date
|
||||
user_data = validate_telegram_init_data(request.init_data, max_age_seconds=86400 * 30)
|
||||
if not user_data or not user_data.get('id'):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
@@ -560,7 +561,8 @@ async def link_telegram(
|
||||
if request.photo_url is not None:
|
||||
widget_data['photo_url'] = request.photo_url
|
||||
|
||||
if not validate_telegram_login_widget(widget_data):
|
||||
# Generous max_age: Telegram caches auth data with stale auth_date
|
||||
if not validate_telegram_login_widget(widget_data, max_age_seconds=86400 * 30):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Invalid or expired Telegram Login Widget data',
|
||||
@@ -620,6 +622,24 @@ async def link_telegram(
|
||||
telegram_id=telegram_id,
|
||||
user_id=user.id,
|
||||
)
|
||||
# BUG-1 fix: Sync all subscriptions with RemnaWave panel so it knows the new telegram_id
|
||||
try:
|
||||
from app.services.remnawave_resync_service import resync_user_subscriptions_with_panel
|
||||
|
||||
resync_result = await resync_user_subscriptions_with_panel(db, user)
|
||||
logger.info(
|
||||
'Post-TG-link resync completed',
|
||||
user_id=user.id,
|
||||
telegram_id=telegram_id,
|
||||
synced=resync_result['synced'],
|
||||
failed=resync_result['failed'],
|
||||
)
|
||||
except Exception as resync_error:
|
||||
logger.error(
|
||||
'Post-TG-link resync failed (non-fatal)',
|
||||
user_id=user.id,
|
||||
error=resync_error,
|
||||
)
|
||||
return LinkCallbackResponse(success=True, message='linked')
|
||||
|
||||
|
||||
@@ -865,6 +885,25 @@ async def execute_merge_endpoint(
|
||||
detail='Failed to load merged user',
|
||||
)
|
||||
|
||||
# BUG-7 fix: Resync merged user's subscriptions with RemnaWave panel
|
||||
try:
|
||||
from app.services.remnawave_resync_service import resync_user_subscriptions_with_panel
|
||||
|
||||
resync_result = await resync_user_subscriptions_with_panel(db, merged_user)
|
||||
logger.info(
|
||||
'Post-merge resync completed',
|
||||
primary_user_id=primary_user_id,
|
||||
secondary_user_id=secondary_user_id,
|
||||
synced=resync_result['synced'],
|
||||
failed=resync_result['failed'],
|
||||
)
|
||||
except Exception as resync_error:
|
||||
logger.error(
|
||||
'Post-merge resync failed (non-fatal)',
|
||||
primary_user_id=primary_user_id,
|
||||
error=resync_error,
|
||||
)
|
||||
|
||||
# 5. Create auth tokens for the merged user
|
||||
try:
|
||||
auth_response = await _create_auth_response(merged_user, db)
|
||||
|
||||
@@ -141,6 +141,7 @@ def _serialize_broadcast(broadcast: BroadcastHistory) -> BroadcastResponse:
|
||||
created_at=broadcast.created_at,
|
||||
completed_at=broadcast.completed_at,
|
||||
progress_percent=progress,
|
||||
category=getattr(broadcast, 'category', 'system') or 'system',
|
||||
channel=getattr(broadcast, 'channel', 'telegram') or 'telegram',
|
||||
email_subject=getattr(broadcast, 'email_subject', None),
|
||||
email_html_content=getattr(broadcast, 'email_html_content', None),
|
||||
@@ -432,6 +433,7 @@ async def create_broadcast(
|
||||
status='queued',
|
||||
admin_id=admin.id,
|
||||
admin_name=admin.username or f'Admin #{admin.id}',
|
||||
category=request.category,
|
||||
)
|
||||
db.add(broadcast)
|
||||
await db.commit()
|
||||
@@ -453,6 +455,8 @@ async def create_broadcast(
|
||||
selected_buttons=request.selected_buttons,
|
||||
media=media_config,
|
||||
initiator_name=admin.username or f'Admin #{admin.id}',
|
||||
custom_buttons=[btn.model_dump() for btn in request.custom_buttons] if request.custom_buttons else None,
|
||||
category=request.category,
|
||||
)
|
||||
|
||||
# Start broadcast
|
||||
@@ -625,6 +629,7 @@ async def create_combined_broadcast(
|
||||
status='queued',
|
||||
admin_id=admin.id,
|
||||
admin_name=admin_name,
|
||||
category=request.category,
|
||||
channel=request.channel,
|
||||
email_subject=request.email_subject.strip() if request.email_subject else None,
|
||||
email_html_content=request.email_html_content.strip() if request.email_html_content else None,
|
||||
@@ -651,6 +656,8 @@ async def create_combined_broadcast(
|
||||
selected_buttons=request.selected_buttons,
|
||||
media=media_config,
|
||||
initiator_name=admin_name,
|
||||
custom_buttons=[btn.model_dump() for btn in request.custom_buttons] if request.custom_buttons else None,
|
||||
category=request.category,
|
||||
)
|
||||
|
||||
await broadcast_service.start_broadcast(broadcast.id, telegram_config)
|
||||
|
||||
@@ -42,9 +42,9 @@ router = APIRouter(prefix='/admin/menu-layout', tags=['Admin Menu Layout'])
|
||||
# ---- Constants ---------------------------------------------------------------
|
||||
|
||||
MAX_ROWS = 20
|
||||
MAX_BUTTONS_PER_ROW = 3
|
||||
MAX_BUTTONS_PER_ROW = 8 # Telegram inline keyboard limit
|
||||
MAX_LABEL_LENGTH = 100
|
||||
URL_PATTERN = re.compile(r'^https?://')
|
||||
URL_PATTERN = re.compile(r'^(https?://|tg://)')
|
||||
|
||||
|
||||
# ---- Schemas -----------------------------------------------------------------
|
||||
@@ -275,7 +275,7 @@ def _validate_update_payload(rows: list[RowConfig]) -> None:
|
||||
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://.',
|
||||
detail=f'Custom button "{btn.id}" must have a URL starting with http://, https://, or tg://.',
|
||||
)
|
||||
if btn.open_in == 'webapp' and not btn.url.startswith('https://'):
|
||||
raise HTTPException(
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
"""Admin routes for managing news articles in cabinet."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.crud.news import (
|
||||
create_news_article,
|
||||
delete_news_article,
|
||||
get_all_news,
|
||||
get_all_news_count,
|
||||
get_news_article_by_id,
|
||||
unfeature_all_news,
|
||||
update_news_article,
|
||||
)
|
||||
from app.database.crud.news_categories import get_category_by_id
|
||||
from app.database.crud.news_tags import get_tag_by_id
|
||||
from app.database.models import NewsArticle, User
|
||||
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
from ..schemas.news import (
|
||||
NewsArticleListItem,
|
||||
NewsArticleResponse,
|
||||
NewsCreateRequest,
|
||||
NewsListResponse,
|
||||
NewsToggleResponse,
|
||||
NewsUpdateRequest,
|
||||
)
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/news', tags=['Cabinet Admin News'])
|
||||
|
||||
|
||||
def _article_to_detail(article: NewsArticle) -> dict[str, Any]:
|
||||
"""Convert NewsArticle ORM instance to full detail dict.
|
||||
|
||||
Expects the ``author`` relationship to be eagerly loaded.
|
||||
"""
|
||||
author_name: str | None = None
|
||||
if article.author:
|
||||
author_name = article.author.first_name or article.author.username or f'#{article.author.id}'
|
||||
|
||||
return {
|
||||
'id': article.id,
|
||||
'title': article.title,
|
||||
'slug': article.slug,
|
||||
'content': article.content,
|
||||
'excerpt': article.excerpt,
|
||||
'category': article.category,
|
||||
'category_color': article.category_color,
|
||||
'tag': article.tag,
|
||||
'category_id': article.category_id,
|
||||
'tag_id': article.tag_id,
|
||||
'featured_image_url': article.featured_image_url,
|
||||
'is_published': article.is_published,
|
||||
'is_featured': article.is_featured,
|
||||
'published_at': article.published_at,
|
||||
'read_time_minutes': article.read_time_minutes,
|
||||
'views_count': article.views_count,
|
||||
'author_name': author_name,
|
||||
'created_at': article.created_at,
|
||||
'updated_at': article.updated_at,
|
||||
}
|
||||
|
||||
|
||||
@router.get('', response_model=NewsListResponse)
|
||||
async def list_all_news(
|
||||
admin: User = Depends(require_permission('news:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
) -> NewsListResponse:
|
||||
"""Get all news articles (admin view, includes unpublished)."""
|
||||
try:
|
||||
articles = await get_all_news(db, limit=limit, offset=offset)
|
||||
total = await get_all_news_count(db)
|
||||
|
||||
items = [NewsArticleListItem.model_validate(a) for a in articles]
|
||||
|
||||
return NewsListResponse(items=items, total=total)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception('Failed to list all news')
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to load news articles',
|
||||
)
|
||||
|
||||
|
||||
@router.get('/{article_id}', response_model=NewsArticleResponse)
|
||||
async def get_article_detail(
|
||||
article_id: int,
|
||||
admin: User = Depends(require_permission('news:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> NewsArticleResponse:
|
||||
"""Get a single news article by ID (admin view)."""
|
||||
article = await get_news_article_by_id(db, article_id)
|
||||
if not article:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Article not found',
|
||||
)
|
||||
|
||||
return NewsArticleResponse(**_article_to_detail(article))
|
||||
|
||||
|
||||
@router.post('', response_model=NewsArticleResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_article(
|
||||
request: NewsCreateRequest,
|
||||
admin: User = Depends(require_permission('news:create')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> NewsArticleResponse:
|
||||
"""Create a new news article."""
|
||||
try:
|
||||
# Resolve category from FK -- sync legacy string fields from the managed entity
|
||||
category_name = request.category
|
||||
category_color = request.category_color
|
||||
if request.category_id is not None:
|
||||
cat = await get_category_by_id(db, request.category_id)
|
||||
if not cat:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f'Category with id={request.category_id} not found',
|
||||
)
|
||||
category_name = cat.name
|
||||
category_color = cat.color
|
||||
|
||||
# Resolve tag from FK -- sync legacy string field from the managed entity
|
||||
tag_name = request.tag
|
||||
if request.tag_id is not None:
|
||||
tag_obj = await get_tag_by_id(db, request.tag_id)
|
||||
if not tag_obj:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f'Tag with id={request.tag_id} not found',
|
||||
)
|
||||
tag_name = tag_obj.name
|
||||
|
||||
if request.is_featured:
|
||||
await unfeature_all_news(db)
|
||||
article = await create_news_article(
|
||||
db,
|
||||
title=request.title,
|
||||
slug=request.slug,
|
||||
content=request.content,
|
||||
excerpt=request.excerpt,
|
||||
category=category_name,
|
||||
category_color=category_color,
|
||||
tag=tag_name,
|
||||
category_id=request.category_id,
|
||||
tag_id=request.tag_id,
|
||||
featured_image_url=request.featured_image_url,
|
||||
is_published=request.is_published,
|
||||
is_featured=request.is_featured,
|
||||
read_time_minutes=request.read_time_minutes,
|
||||
created_by=admin.id,
|
||||
)
|
||||
except IntegrityError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail='An article with this slug already exists',
|
||||
)
|
||||
except Exception:
|
||||
logger.exception('Failed to create news article')
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to create article',
|
||||
)
|
||||
|
||||
# Reload with author relationship
|
||||
article = await get_news_article_by_id(db, article.id)
|
||||
if not article:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to reload article after creation',
|
||||
)
|
||||
return NewsArticleResponse(**_article_to_detail(article))
|
||||
|
||||
|
||||
@router.put('/{article_id}', response_model=NewsArticleResponse)
|
||||
async def update_article(
|
||||
article_id: int,
|
||||
request: NewsUpdateRequest,
|
||||
admin: User = Depends(require_permission('news:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> NewsArticleResponse:
|
||||
"""Update an existing news article."""
|
||||
article = await get_news_article_by_id(db, article_id)
|
||||
if not article:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Article not found',
|
||||
)
|
||||
|
||||
try:
|
||||
update_data = request.model_dump(exclude_unset=True)
|
||||
|
||||
# Resolve category from FK -- sync legacy string fields from the managed entity
|
||||
if 'category_id' in update_data and update_data['category_id'] is not None:
|
||||
cat = await get_category_by_id(db, update_data['category_id'])
|
||||
if not cat:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f'Category with id={update_data["category_id"]} not found',
|
||||
)
|
||||
update_data['category'] = cat.name
|
||||
update_data['category_color'] = cat.color
|
||||
|
||||
# Resolve tag from FK -- sync legacy string field from the managed entity
|
||||
if 'tag_id' in update_data and update_data['tag_id'] is not None:
|
||||
tag_obj = await get_tag_by_id(db, update_data['tag_id'])
|
||||
if not tag_obj:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f'Tag with id={update_data["tag_id"]} not found',
|
||||
)
|
||||
update_data['tag'] = tag_obj.name
|
||||
|
||||
if update_data.get('is_featured'):
|
||||
await unfeature_all_news(db)
|
||||
article = await update_news_article(db, article, **update_data)
|
||||
except IntegrityError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail='An article with this slug already exists',
|
||||
)
|
||||
except Exception:
|
||||
logger.exception('Failed to update news article', article_id=article_id)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to update article',
|
||||
)
|
||||
|
||||
# Reload with author relationship (update used bulk UPDATE, author not populated)
|
||||
article = await get_news_article_by_id(db, article.id)
|
||||
if not article:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to reload article after update',
|
||||
)
|
||||
return NewsArticleResponse(**_article_to_detail(article))
|
||||
|
||||
|
||||
@router.delete('/{article_id}', status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def remove_article(
|
||||
article_id: int,
|
||||
admin: User = Depends(require_permission('news:delete')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> None:
|
||||
"""Delete a news article."""
|
||||
article = await get_news_article_by_id(db, article_id)
|
||||
if not article:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Article not found',
|
||||
)
|
||||
|
||||
try:
|
||||
await delete_news_article(db, article)
|
||||
except Exception:
|
||||
logger.exception('Failed to delete news article', article_id=article_id)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to delete article',
|
||||
)
|
||||
|
||||
|
||||
@router.post('/{article_id}/publish', response_model=NewsToggleResponse)
|
||||
async def toggle_publish(
|
||||
article_id: int,
|
||||
admin: User = Depends(require_permission('news:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> NewsToggleResponse:
|
||||
"""Toggle the published status of a news article."""
|
||||
article = await get_news_article_by_id(db, article_id)
|
||||
if not article:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Article not found',
|
||||
)
|
||||
|
||||
new_published = not article.is_published
|
||||
|
||||
update_kwargs: dict[str, Any] = {'is_published': new_published}
|
||||
# Auto-set published_at on first publish
|
||||
if new_published and article.published_at is None:
|
||||
update_kwargs['published_at'] = datetime.now(UTC)
|
||||
|
||||
try:
|
||||
article = await update_news_article(db, article, **update_kwargs)
|
||||
return NewsToggleResponse(
|
||||
id=article.id,
|
||||
is_published=article.is_published,
|
||||
is_featured=article.is_featured,
|
||||
published_at=article.published_at,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception('Failed to toggle publish', article_id=article_id)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to toggle publish status',
|
||||
)
|
||||
|
||||
|
||||
@router.post('/{article_id}/feature', response_model=NewsToggleResponse)
|
||||
async def toggle_featured(
|
||||
article_id: int,
|
||||
admin: User = Depends(require_permission('news:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> NewsToggleResponse:
|
||||
"""Toggle the featured status of a news article."""
|
||||
article = await get_news_article_by_id(db, article_id)
|
||||
if not article:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Article not found',
|
||||
)
|
||||
|
||||
try:
|
||||
new_featured = not article.is_featured
|
||||
# Only one article can be featured at a time — unfeature all others first
|
||||
if new_featured:
|
||||
await unfeature_all_news(db)
|
||||
article = await update_news_article(db, article, is_featured=new_featured)
|
||||
return NewsToggleResponse(
|
||||
id=article.id,
|
||||
is_published=article.is_published,
|
||||
is_featured=article.is_featured,
|
||||
published_at=article.published_at,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception('Failed to toggle featured', article_id=article_id)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to toggle featured status',
|
||||
)
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Admin routes for managing news categories."""
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.crud.news_categories import (
|
||||
create_category,
|
||||
delete_category,
|
||||
get_all_categories,
|
||||
get_category_by_id,
|
||||
update_category,
|
||||
)
|
||||
from app.database.models import User
|
||||
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
from ..schemas.news_categories import NewsCategoryCreate, NewsCategoryResponse, NewsCategoryUpdate
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/news/categories', tags=['Cabinet Admin News Categories'])
|
||||
|
||||
|
||||
@router.get('', response_model=list[NewsCategoryResponse])
|
||||
async def list_categories(
|
||||
admin: User = Depends(require_permission('news:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> list[NewsCategoryResponse]:
|
||||
"""Get all news categories."""
|
||||
categories = await get_all_categories(db)
|
||||
return [NewsCategoryResponse.model_validate(c) for c in categories]
|
||||
|
||||
|
||||
@router.post('', response_model=NewsCategoryResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_new_category(
|
||||
request: NewsCategoryCreate,
|
||||
admin: User = Depends(require_permission('news:create')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> NewsCategoryResponse:
|
||||
"""Create a new news category."""
|
||||
try:
|
||||
category = await create_category(db, name=request.name, color=request.color)
|
||||
except IntegrityError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail='Category already exists',
|
||||
)
|
||||
return NewsCategoryResponse.model_validate(category)
|
||||
|
||||
|
||||
@router.put('/{category_id}', response_model=NewsCategoryResponse)
|
||||
async def update_existing_category(
|
||||
category_id: int,
|
||||
request: NewsCategoryUpdate,
|
||||
admin: User = Depends(require_permission('news:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> NewsCategoryResponse:
|
||||
"""Update an existing news category."""
|
||||
category = await get_category_by_id(db, category_id)
|
||||
if not category:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Category not found',
|
||||
)
|
||||
try:
|
||||
category = await update_category(db, category, **request.model_dump(exclude_unset=True))
|
||||
except IntegrityError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail='Category name already exists',
|
||||
)
|
||||
return NewsCategoryResponse.model_validate(category)
|
||||
|
||||
|
||||
@router.delete('/{category_id}', status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def remove_category(
|
||||
category_id: int,
|
||||
admin: User = Depends(require_permission('news:delete')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> None:
|
||||
"""Delete a news category. Articles using it will have category_id set to NULL."""
|
||||
category = await get_category_by_id(db, category_id)
|
||||
if not category:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Category not found',
|
||||
)
|
||||
await delete_category(db, category)
|
||||
@@ -0,0 +1,157 @@
|
||||
"""Admin routes for managing news article media (images/videos)."""
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile, status
|
||||
from PIL import Image as PILImage
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.services.news_media_service import (
|
||||
SavedMedia,
|
||||
delete_media_file,
|
||||
detect_file_type,
|
||||
ensure_upload_dirs,
|
||||
save_image,
|
||||
save_video,
|
||||
)
|
||||
|
||||
from ..dependencies import require_permission
|
||||
from ..schemas.news_media import NewsMediaUploadResponse
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
_BYTES_PER_MB = 1024 * 1024
|
||||
|
||||
# Only allow UUID-hex filenames with expected extensions (path traversal defense-in-depth).
|
||||
# thumb_ prefix is NOT allowed — thumbnails are cleaned up automatically when the main file is deleted.
|
||||
_SAFE_FILENAME_RE = re.compile(r'^[0-9a-f]{32}\.(jpg|mp4|webm)$')
|
||||
|
||||
router = APIRouter(prefix='/admin/news/media', tags=['Cabinet Admin News Media'])
|
||||
|
||||
|
||||
_ALLOWED_SCHEMES = frozenset({'http', 'https'})
|
||||
|
||||
|
||||
def _build_media_url(request: Request, relative_path: str) -> str:
|
||||
"""Build a full URL for a media file, respecting reverse proxy headers."""
|
||||
proto = request.headers.get('X-Forwarded-Proto', request.url.scheme).split(',')[0].strip()
|
||||
if proto not in _ALLOWED_SCHEMES:
|
||||
proto = 'https'
|
||||
host = request.headers.get('X-Forwarded-Host', request.headers.get('Host', request.url.netloc))
|
||||
host = host.split(',')[0].strip()
|
||||
return f'{proto}://{host}/uploads/{relative_path}'
|
||||
|
||||
|
||||
def _build_response(request: Request, saved: SavedMedia) -> NewsMediaUploadResponse:
|
||||
"""Convert SavedMedia to API response with full URLs."""
|
||||
thumbnail_url = _build_media_url(request, saved.thumbnail_path) if saved.thumbnail_path else None
|
||||
|
||||
return NewsMediaUploadResponse(
|
||||
url=_build_media_url(request, saved.relative_path),
|
||||
thumbnail_url=thumbnail_url,
|
||||
media_type=saved.media_type,
|
||||
filename=saved.filename,
|
||||
size_bytes=saved.size_bytes,
|
||||
width=saved.width,
|
||||
height=saved.height,
|
||||
)
|
||||
|
||||
|
||||
@router.post('/upload', response_model=NewsMediaUploadResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def upload_media(
|
||||
request: Request,
|
||||
file: UploadFile = File(...),
|
||||
admin: User = Depends(require_permission('news:edit')),
|
||||
) -> NewsMediaUploadResponse:
|
||||
"""Upload an image or video for a news article."""
|
||||
# Read with a hard budget to prevent memory exhaustion from huge uploads.
|
||||
# Read slightly over the max allowed size so we can detect oversized files.
|
||||
absolute_max_bytes = settings.MEDIA_MAX_VIDEO_SIZE_MB * _BYTES_PER_MB + 1
|
||||
data = await file.read(absolute_max_bytes)
|
||||
await file.close()
|
||||
if not data:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Empty file',
|
||||
)
|
||||
|
||||
if len(data) >= absolute_max_bytes:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||||
detail=f'File too large. Absolute maximum: {settings.MEDIA_MAX_VIDEO_SIZE_MB} MB',
|
||||
)
|
||||
|
||||
# Detect type from magic bytes
|
||||
try:
|
||||
media_type, _ext = detect_file_type(data)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
|
||||
detail='Unsupported file type. Allowed: JPEG, PNG, WebP, MP4, WebM',
|
||||
) from None
|
||||
|
||||
# Enforce per-type size limits
|
||||
max_size_mb = settings.MEDIA_MAX_IMAGE_SIZE_MB if media_type == 'image' else settings.MEDIA_MAX_VIDEO_SIZE_MB
|
||||
if len(data) > max_size_mb * _BYTES_PER_MB:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||||
detail=f'File too large. Maximum size for {media_type}: {max_size_mb} MB',
|
||||
)
|
||||
|
||||
upload_path = settings.get_media_upload_path()
|
||||
await asyncio.to_thread(ensure_upload_dirs, upload_path)
|
||||
|
||||
try:
|
||||
if media_type == 'image':
|
||||
saved = await save_image(
|
||||
data,
|
||||
upload_path,
|
||||
max_dim=settings.MEDIA_IMAGE_MAX_DIMENSION,
|
||||
quality=settings.MEDIA_JPEG_QUALITY,
|
||||
)
|
||||
else:
|
||||
saved = await save_video(data, upload_path)
|
||||
except (ValueError, OSError, PILImage.DecompressionBombError) as exc:
|
||||
logger.warning('Failed to save uploaded media', media_type=media_type, error=str(exc))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail='Failed to process uploaded file',
|
||||
) from None
|
||||
|
||||
logger.info(
|
||||
'Media uploaded',
|
||||
filename=saved.filename,
|
||||
media_type=saved.media_type,
|
||||
size_bytes=saved.size_bytes,
|
||||
admin_id=admin.id,
|
||||
)
|
||||
|
||||
return _build_response(request, saved)
|
||||
|
||||
|
||||
@router.delete('/{filename}', status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_media(
|
||||
filename: str,
|
||||
admin: User = Depends(require_permission('news:delete')),
|
||||
) -> None:
|
||||
"""Delete a previously uploaded media file."""
|
||||
if not _SAFE_FILENAME_RE.match(filename):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Invalid filename',
|
||||
)
|
||||
|
||||
upload_path = settings.get_media_upload_path()
|
||||
|
||||
deleted = await asyncio.to_thread(delete_media_file, filename, upload_path)
|
||||
if not deleted:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='File not found',
|
||||
)
|
||||
|
||||
logger.info('Media deleted', filename=filename, admin_id=admin.id)
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Admin routes for managing news tags."""
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.crud.news_tags import (
|
||||
create_tag,
|
||||
delete_tag,
|
||||
get_all_tags,
|
||||
get_tag_by_id,
|
||||
update_tag,
|
||||
)
|
||||
from app.database.models import User
|
||||
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
from ..schemas.news_tags import NewsTagCreate, NewsTagResponse, NewsTagUpdate
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/news/tags', tags=['Cabinet Admin News Tags'])
|
||||
|
||||
|
||||
@router.get('', response_model=list[NewsTagResponse])
|
||||
async def list_tags(
|
||||
admin: User = Depends(require_permission('news:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> list[NewsTagResponse]:
|
||||
"""Get all news tags."""
|
||||
tags = await get_all_tags(db)
|
||||
return [NewsTagResponse.model_validate(t) for t in tags]
|
||||
|
||||
|
||||
@router.post('', response_model=NewsTagResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_new_tag(
|
||||
request: NewsTagCreate,
|
||||
admin: User = Depends(require_permission('news:create')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> NewsTagResponse:
|
||||
"""Create a new news tag."""
|
||||
try:
|
||||
tag = await create_tag(db, name=request.name, color=request.color)
|
||||
except IntegrityError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail='Tag already exists',
|
||||
)
|
||||
return NewsTagResponse.model_validate(tag)
|
||||
|
||||
|
||||
@router.put('/{tag_id}', response_model=NewsTagResponse)
|
||||
async def update_existing_tag(
|
||||
tag_id: int,
|
||||
request: NewsTagUpdate,
|
||||
admin: User = Depends(require_permission('news:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> NewsTagResponse:
|
||||
"""Update an existing news tag."""
|
||||
tag = await get_tag_by_id(db, tag_id)
|
||||
if not tag:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Tag not found',
|
||||
)
|
||||
try:
|
||||
tag = await update_tag(db, tag, **request.model_dump(exclude_unset=True))
|
||||
except IntegrityError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail='Tag name already exists',
|
||||
)
|
||||
return NewsTagResponse.model_validate(tag)
|
||||
|
||||
|
||||
@router.delete('/{tag_id}', status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def remove_tag(
|
||||
tag_id: int,
|
||||
admin: User = Depends(require_permission('news:delete')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> None:
|
||||
"""Delete a news tag. Articles using it will have tag_id set to NULL."""
|
||||
tag = await get_tag_by_id(db, tag_id)
|
||||
if not tag:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Tag not found',
|
||||
)
|
||||
await delete_tag(db, tag)
|
||||
@@ -4,14 +4,14 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from typing import Any, ClassVar
|
||||
|
||||
import structlog
|
||||
from aiogram import Bot
|
||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, validator
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.bot_factory import create_bot
|
||||
@@ -128,6 +128,20 @@ class PromoOfferBroadcastRequest(BaseModel):
|
||||
message_text: str | None = Field(None, description='Custom message text (HTML)')
|
||||
button_text: str | None = Field(None, description='Button text')
|
||||
|
||||
_TARGET_ALIASES: ClassVar[dict[str, str]] = {
|
||||
'no_sub': 'no',
|
||||
'all_users': 'all',
|
||||
'active_subscribers': 'active',
|
||||
'trial_users': 'trial',
|
||||
}
|
||||
|
||||
@validator('target')
|
||||
def normalize_target(cls, value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
normalized = value.strip().lower()
|
||||
return cls._TARGET_ALIASES.get(normalized, normalized)
|
||||
|
||||
|
||||
class PromoOfferBroadcastResponse(BaseModel):
|
||||
created_offers: int
|
||||
|
||||
@@ -28,6 +28,7 @@ from app.database.crud.promocode import (
|
||||
get_promocodes_list,
|
||||
update_promocode,
|
||||
)
|
||||
from app.database.crud.tariff import get_tariff_by_id
|
||||
from app.database.models import PromoCode, PromoCodeType, PromoCodeUse, PromoGroup, User
|
||||
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
@@ -55,6 +56,8 @@ class PromoCodeResponse(BaseModel):
|
||||
valid_from: datetime
|
||||
valid_until: datetime | None = None
|
||||
promo_group_id: int | None = None
|
||||
tariff_id: int | None = None
|
||||
tariff_name: str | None = None
|
||||
created_by: int | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
@@ -93,6 +96,7 @@ class PromoCodeCreateRequest(BaseModel):
|
||||
is_active: bool = True
|
||||
first_purchase_only: bool = False
|
||||
promo_group_id: int | None = None
|
||||
tariff_id: int | None = None
|
||||
|
||||
|
||||
class PromoCodeUpdateRequest(BaseModel):
|
||||
@@ -106,6 +110,7 @@ class PromoCodeUpdateRequest(BaseModel):
|
||||
is_active: bool | None = None
|
||||
first_purchase_only: bool | None = None
|
||||
promo_group_id: int | None = None
|
||||
tariff_id: int | None = None
|
||||
|
||||
|
||||
# ============== PromoGroup Schemas ==============
|
||||
@@ -168,7 +173,12 @@ def _normalize_datetime(value: datetime | None) -> datetime | None:
|
||||
return value
|
||||
|
||||
|
||||
def _serialize_promocode(promocode: PromoCode) -> PromoCodeResponse:
|
||||
async def _serialize_promocode(db: AsyncSession, promocode: PromoCode) -> PromoCodeResponse:
|
||||
tariff_name = None
|
||||
if promocode.tariff_id:
|
||||
tariff = await get_tariff_by_id(db, promocode.tariff_id)
|
||||
tariff_name = tariff.name if tariff else None
|
||||
|
||||
promo_type = PromoCodeType(promocode.type)
|
||||
return PromoCodeResponse(
|
||||
id=promocode.id,
|
||||
@@ -186,6 +196,8 @@ def _serialize_promocode(promocode: PromoCode) -> PromoCodeResponse:
|
||||
valid_from=promocode.valid_from,
|
||||
valid_until=promocode.valid_until,
|
||||
promo_group_id=promocode.promo_group_id,
|
||||
tariff_id=promocode.tariff_id,
|
||||
tariff_name=tariff_name,
|
||||
created_by=promocode.created_by,
|
||||
created_at=promocode.created_at,
|
||||
updated_at=promocode.updated_at,
|
||||
@@ -315,8 +327,9 @@ async def list_promocodes(
|
||||
total = await get_promocodes_count(db, is_active=is_active) or 0
|
||||
promocodes = await get_promocodes_list(db, offset=offset, limit=limit, is_active=is_active)
|
||||
|
||||
serialized = [await _serialize_promocode(db, p) for p in promocodes]
|
||||
return PromoCodeListResponse(
|
||||
items=[_serialize_promocode(promocode) for promocode in promocodes],
|
||||
items=serialized,
|
||||
total=int(total),
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
@@ -335,7 +348,7 @@ async def get_promocode(
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Promo code not found')
|
||||
|
||||
stats = await get_promocode_statistics(db, promocode_id)
|
||||
base = _serialize_promocode(promocode)
|
||||
base = await _serialize_promocode(db, promocode)
|
||||
recent_uses = [_serialize_recent_use(use) for use in stats.get('recent_uses', [])]
|
||||
|
||||
return PromoCodeDetailResponse(
|
||||
@@ -388,11 +401,13 @@ async def create_promocode_endpoint(
|
||||
update_fields['first_purchase_only'] = payload.first_purchase_only
|
||||
if payload.promo_group_id is not None:
|
||||
update_fields['promo_group_id'] = payload.promo_group_id
|
||||
if payload.tariff_id is not None:
|
||||
update_fields['tariff_id'] = payload.tariff_id
|
||||
|
||||
if update_fields:
|
||||
promocode = await update_promocode(db, promocode, **update_fields)
|
||||
|
||||
return _serialize_promocode(promocode)
|
||||
return await _serialize_promocode(db, promocode)
|
||||
|
||||
|
||||
@router.patch('/{promocode_id}', response_model=PromoCodeResponse)
|
||||
@@ -446,11 +461,14 @@ async def update_promocode_endpoint(
|
||||
if payload.promo_group_id is not None:
|
||||
updates['promo_group_id'] = payload.promo_group_id
|
||||
|
||||
if payload.tariff_id is not None:
|
||||
updates['tariff_id'] = payload.tariff_id if payload.tariff_id != 0 else None
|
||||
|
||||
if not updates:
|
||||
return _serialize_promocode(promocode)
|
||||
return await _serialize_promocode(db, promocode)
|
||||
|
||||
promocode = await update_promocode(db, promocode, **updates)
|
||||
return _serialize_promocode(promocode)
|
||||
return await _serialize_promocode(db, promocode)
|
||||
|
||||
|
||||
@router.delete(
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import re
|
||||
from collections import defaultdict
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
@@ -16,6 +17,7 @@ from app.database.models import (
|
||||
PartnerStatus,
|
||||
ReferralEarning,
|
||||
Subscription,
|
||||
SubscriptionStatus,
|
||||
Tariff,
|
||||
Transaction,
|
||||
TransactionType,
|
||||
@@ -78,6 +80,7 @@ class NetworkUserNode(BaseModel):
|
||||
personal_spent_kopeks: int
|
||||
subscription_name: str | None
|
||||
subscription_end: str | None
|
||||
subscription_status: str | None
|
||||
registered_at: str | None
|
||||
|
||||
|
||||
@@ -114,6 +117,7 @@ class NetworkGraphResponse(BaseModel):
|
||||
total_referrers: int
|
||||
total_campaigns: int
|
||||
total_earnings_kopeks: int
|
||||
total_subscription_revenue_kopeks: int
|
||||
|
||||
|
||||
class NetworkUserDetail(BaseModel):
|
||||
@@ -134,6 +138,7 @@ class NetworkUserDetail(BaseModel):
|
||||
personal_spent_kopeks: int
|
||||
subscription_name: str | None
|
||||
subscription_end: str | None
|
||||
subscription_status: str | None
|
||||
registered_at: str | None
|
||||
|
||||
|
||||
@@ -215,6 +220,7 @@ def _build_user_node(
|
||||
campaign_id: int | None,
|
||||
subscription_name: str | None,
|
||||
subscription_end_str: str | None,
|
||||
subscription_status: str | None,
|
||||
) -> NetworkUserNode:
|
||||
return NetworkUserNode(
|
||||
id=user.id,
|
||||
@@ -232,6 +238,7 @@ def _build_user_node(
|
||||
personal_spent_kopeks=personal_spent,
|
||||
subscription_name=subscription_name,
|
||||
subscription_end=subscription_end_str,
|
||||
subscription_status=subscription_status,
|
||||
registered_at=_format_datetime(user.created_at),
|
||||
)
|
||||
|
||||
@@ -312,7 +319,7 @@ async def _fetch_branch_revenue(db: AsyncSession, user_ids: set[int]) -> dict[in
|
||||
stmt = (
|
||||
select(
|
||||
referred_user.c.referred_by_id,
|
||||
func.coalesce(func.sum(Transaction.amount_kopeks), 0),
|
||||
func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0),
|
||||
)
|
||||
.join(referred_user, Transaction.user_id == referred_user.c.id)
|
||||
.where(
|
||||
@@ -333,7 +340,7 @@ async def _fetch_personal_spent(db: AsyncSession, user_ids: set[int]) -> dict[in
|
||||
return {}
|
||||
|
||||
stmt = (
|
||||
select(Transaction.user_id, func.coalesce(func.sum(Transaction.amount_kopeks), 0))
|
||||
select(Transaction.user_id, func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0))
|
||||
.where(
|
||||
and_(
|
||||
Transaction.user_id.in_(user_ids),
|
||||
@@ -376,18 +383,81 @@ async def _fetch_campaign_registrations(db: AsyncSession, user_ids: set[int] | N
|
||||
return {row[0]: row[1] for row in result}
|
||||
|
||||
|
||||
async def _fetch_subscription_info(db: AsyncSession, user_ids: set[int]) -> dict[int, tuple[str | None, str | None]]:
|
||||
"""Return {user_id: (tariff_name, end_date_iso)} for given users."""
|
||||
def _compute_subscription_status(
|
||||
is_trial: bool | None,
|
||||
db_status: str | None,
|
||||
end_date: datetime | None,
|
||||
now: datetime,
|
||||
) -> str | None:
|
||||
"""Map subscription fields to a frontend status label.
|
||||
|
||||
Returns one of: 'trial_active', 'trial_expired', 'paid_active', 'paid_expired', or None.
|
||||
Statuses DISABLED, PENDING, EXPIRED, LIMITED are treated as inactive regardless of end_date.
|
||||
ACTIVE and TRIAL fall through to a date-based check.
|
||||
"""
|
||||
if is_trial is None:
|
||||
return None
|
||||
if db_status in (
|
||||
SubscriptionStatus.DISABLED.value,
|
||||
SubscriptionStatus.PENDING.value,
|
||||
SubscriptionStatus.EXPIRED.value,
|
||||
SubscriptionStatus.LIMITED.value,
|
||||
):
|
||||
return 'trial_expired' if is_trial else 'paid_expired'
|
||||
if is_trial:
|
||||
return 'trial_active' if (end_date and end_date > now) else 'trial_expired'
|
||||
return 'paid_active' if (end_date and end_date > now) else 'paid_expired'
|
||||
|
||||
|
||||
async def _fetch_subscription_info(
|
||||
db: AsyncSession,
|
||||
user_ids: set[int],
|
||||
) -> dict[int, tuple[str | None, str | None, str | None]]:
|
||||
"""Return {user_id: (tariff_name, end_date_iso, subscription_status)} for given users."""
|
||||
if not user_ids:
|
||||
return {}
|
||||
|
||||
stmt = (
|
||||
select(Subscription.user_id, Tariff.name, Subscription.end_date)
|
||||
row_num = (
|
||||
func.row_number()
|
||||
.over(
|
||||
partition_by=Subscription.user_id,
|
||||
order_by=Subscription.end_date.desc().nullslast(),
|
||||
)
|
||||
.label('rn')
|
||||
)
|
||||
|
||||
inner = (
|
||||
select(
|
||||
Subscription.user_id,
|
||||
Tariff.name,
|
||||
Subscription.end_date,
|
||||
Subscription.is_trial,
|
||||
Subscription.status,
|
||||
row_num,
|
||||
)
|
||||
.outerjoin(Tariff, Subscription.tariff_id == Tariff.id)
|
||||
.where(Subscription.user_id.in_(user_ids))
|
||||
)
|
||||
subq = inner.subquery()
|
||||
|
||||
stmt = select(
|
||||
subq.c.user_id,
|
||||
subq.c.name,
|
||||
subq.c.end_date,
|
||||
subq.c.is_trial,
|
||||
subq.c.status,
|
||||
).where(subq.c.rn == 1)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
return {row[0]: (row[1], _format_datetime(row[2]) if row[2] else None) for row in result}
|
||||
now = datetime.now(UTC)
|
||||
out: dict[int, tuple[str | None, str | None, str | None]] = {}
|
||||
for row in result:
|
||||
user_id, tariff_name, end_date, is_trial, db_status = row
|
||||
end_date_iso = _format_datetime(end_date) if end_date else None
|
||||
sub_status = _compute_subscription_status(is_trial, db_status, end_date, now)
|
||||
out[user_id] = (tariff_name, end_date_iso, sub_status)
|
||||
|
||||
return out
|
||||
|
||||
|
||||
async def _fetch_campaign_stats(
|
||||
@@ -428,7 +498,7 @@ async def _fetch_campaign_stats(
|
||||
user_spent: dict[int, int] = {}
|
||||
if all_campaign_users:
|
||||
spent_stmt = (
|
||||
select(Transaction.user_id, func.coalesce(func.sum(Transaction.amount_kopeks), 0))
|
||||
select(Transaction.user_id, func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0))
|
||||
.where(
|
||||
and_(
|
||||
Transaction.user_id.in_(all_campaign_users),
|
||||
@@ -540,6 +610,7 @@ async def get_referral_network(
|
||||
total_referrers=0,
|
||||
total_campaigns=0,
|
||||
total_earnings_kopeks=0,
|
||||
total_subscription_revenue_kopeks=0,
|
||||
)
|
||||
|
||||
# Cap to prevent excessive response sizes (deterministic: keep lowest IDs for stability)
|
||||
@@ -567,7 +638,7 @@ async def get_referral_network(
|
||||
# Build user nodes
|
||||
user_nodes: list[NetworkUserNode] = []
|
||||
for user in users:
|
||||
sub = sub_info.get(user.id, (None, None))
|
||||
sub = sub_info.get(user.id, (None, None, None))
|
||||
user_nodes.append(
|
||||
_build_user_node(
|
||||
user,
|
||||
@@ -578,6 +649,7 @@ async def get_referral_network(
|
||||
campaign_id=campaign_regs.get(user.id),
|
||||
subscription_name=sub[0],
|
||||
subscription_end_str=sub[1],
|
||||
subscription_status=sub[2],
|
||||
)
|
||||
)
|
||||
|
||||
@@ -629,6 +701,7 @@ async def get_referral_network(
|
||||
total_referrers = len([u for u in user_nodes if u.direct_referrals > 0])
|
||||
|
||||
total_earnings = sum(personal_revenue.values())
|
||||
total_subscription_revenue = sum(personal_spent.values())
|
||||
|
||||
return NetworkGraphResponse(
|
||||
users=user_nodes,
|
||||
@@ -638,6 +711,7 @@ async def get_referral_network(
|
||||
total_referrers=total_referrers,
|
||||
total_campaigns=len(campaign_nodes),
|
||||
total_earnings_kopeks=total_earnings,
|
||||
total_subscription_revenue_kopeks=total_subscription_revenue,
|
||||
)
|
||||
|
||||
|
||||
@@ -784,6 +858,7 @@ async def _build_scoped_graph(
|
||||
total_referrers=0,
|
||||
total_campaigns=len(campaign_nodes),
|
||||
total_earnings_kopeks=0,
|
||||
total_subscription_revenue_kopeks=0,
|
||||
)
|
||||
return NetworkGraphResponse(
|
||||
users=[],
|
||||
@@ -793,6 +868,7 @@ async def _build_scoped_graph(
|
||||
total_referrers=0,
|
||||
total_campaigns=0,
|
||||
total_earnings_kopeks=0,
|
||||
total_subscription_revenue_kopeks=0,
|
||||
)
|
||||
|
||||
# Cap to prevent excessive response sizes
|
||||
@@ -816,7 +892,7 @@ async def _build_scoped_graph(
|
||||
|
||||
user_nodes: list[NetworkUserNode] = []
|
||||
for user in users:
|
||||
sub = sub_info.get(user.id, (None, None))
|
||||
sub = sub_info.get(user.id, (None, None, None))
|
||||
user_nodes.append(
|
||||
_build_user_node(
|
||||
user,
|
||||
@@ -827,6 +903,7 @@ async def _build_scoped_graph(
|
||||
campaign_id=campaign_regs.get(user.id),
|
||||
subscription_name=sub[0],
|
||||
subscription_end_str=sub[1],
|
||||
subscription_status=sub[2],
|
||||
)
|
||||
)
|
||||
|
||||
@@ -878,6 +955,7 @@ async def _build_scoped_graph(
|
||||
|
||||
total_referrers = len([u for u in user_nodes if u.direct_referrals > 0])
|
||||
total_earnings = sum(personal_revenue.values())
|
||||
total_subscription_revenue = sum(personal_spent.values())
|
||||
|
||||
return NetworkGraphResponse(
|
||||
users=user_nodes,
|
||||
@@ -887,6 +965,7 @@ async def _build_scoped_graph(
|
||||
total_referrers=total_referrers,
|
||||
total_campaigns=len(campaign_nodes),
|
||||
total_earnings_kopeks=total_earnings,
|
||||
total_subscription_revenue_kopeks=total_subscription_revenue,
|
||||
)
|
||||
|
||||
|
||||
@@ -1029,7 +1108,7 @@ async def get_network_user_detail(
|
||||
# Fetch user with subscription eagerly loaded
|
||||
stmt = (
|
||||
select(User)
|
||||
.options(selectinload(User.subscription).selectinload(Subscription.tariff))
|
||||
.options(selectinload(User.subscriptions).selectinload(Subscription.tariff))
|
||||
.where(User.id == user_id)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
@@ -1057,7 +1136,7 @@ async def get_network_user_detail(
|
||||
branch_revenue = 0
|
||||
|
||||
# Personal spent
|
||||
spent_stmt = select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
|
||||
spent_stmt = select(func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0)).where(
|
||||
and_(
|
||||
Transaction.user_id == user_id,
|
||||
Transaction.type.in_(SPENT_TRANSACTION_TYPES),
|
||||
@@ -1102,7 +1181,7 @@ async def get_network_user_detail(
|
||||
|
||||
# Branch revenue: total spent by all users in the branch
|
||||
branch_user_ids_stmt = select(branch_cte.c.id)
|
||||
branch_rev_stmt = select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
|
||||
branch_rev_stmt = select(func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0)).where(
|
||||
and_(
|
||||
Transaction.user_id.in_(branch_user_ids_stmt),
|
||||
Transaction.type.in_(SPENT_TRANSACTION_TYPES),
|
||||
@@ -1124,10 +1203,19 @@ async def get_network_user_detail(
|
||||
# Subscription info
|
||||
subscription_name: str | None = None
|
||||
subscription_end: str | None = None
|
||||
if user.subscription is not None:
|
||||
if user.subscription.tariff is not None:
|
||||
subscription_name = user.subscription.tariff.name
|
||||
subscription_end = _format_datetime(user.subscription.end_date)
|
||||
subscription_status: str | None = None
|
||||
subs = getattr(user, 'subscriptions', None) or []
|
||||
subscription = next((s for s in subs if s.is_active), subs[0] if subs else None)
|
||||
if subscription is not None:
|
||||
if subscription.tariff is not None:
|
||||
subscription_name = subscription.tariff.name
|
||||
subscription_end = _format_datetime(subscription.end_date)
|
||||
subscription_status = _compute_subscription_status(
|
||||
subscription.is_trial,
|
||||
subscription.status,
|
||||
subscription.end_date,
|
||||
datetime.now(UTC),
|
||||
)
|
||||
|
||||
return NetworkUserDetail(
|
||||
id=user.id,
|
||||
@@ -1147,6 +1235,7 @@ async def get_network_user_detail(
|
||||
personal_spent_kopeks=personal_spent,
|
||||
subscription_name=subscription_name,
|
||||
subscription_end=subscription_end,
|
||||
subscription_status=subscription_status,
|
||||
registered_at=_format_datetime(user.created_at),
|
||||
)
|
||||
|
||||
@@ -1215,7 +1304,7 @@ async def get_network_campaign_detail(
|
||||
total_spent = 0
|
||||
if campaign_user_ids:
|
||||
spent_stmt = (
|
||||
select(Transaction.user_id, func.coalesce(func.sum(Transaction.amount_kopeks), 0))
|
||||
select(Transaction.user_id, func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0))
|
||||
.where(
|
||||
and_(
|
||||
Transaction.user_id.in_(campaign_user_ids),
|
||||
@@ -1336,7 +1425,7 @@ async def search_referral_network(
|
||||
sub_info = await _fetch_subscription_info(db, matched_ids)
|
||||
|
||||
for user in matched_users:
|
||||
sub = sub_info.get(user.id, (None, None))
|
||||
sub = sub_info.get(user.id, (None, None, None))
|
||||
user_nodes.append(
|
||||
_build_user_node(
|
||||
user,
|
||||
@@ -1347,6 +1436,7 @@ async def search_referral_network(
|
||||
campaign_id=campaign_regs.get(user.id),
|
||||
subscription_name=sub[0],
|
||||
subscription_end_str=sub[1],
|
||||
subscription_status=sub[2],
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1400,7 +1490,7 @@ async def search_referral_network(
|
||||
campaign_user_spent: dict[int, int] = {}
|
||||
if all_campaign_user_ids:
|
||||
spent_stmt = (
|
||||
select(Transaction.user_id, func.coalesce(func.sum(Transaction.amount_kopeks), 0))
|
||||
select(Transaction.user_id, func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0))
|
||||
.where(
|
||||
and_(
|
||||
Transaction.user_id.in_(all_campaign_user_ids),
|
||||
|
||||
@@ -5,6 +5,7 @@ from typing import Any
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.crud.server_squad import (
|
||||
@@ -129,22 +130,22 @@ def _serialize_node(node_data: dict[str, Any]) -> NodeInfo:
|
||||
is_disabled=bool(node_data.get('is_disabled')),
|
||||
is_node_online=bool(node_data.get('is_node_online')),
|
||||
is_xray_running=bool(node_data.get('is_xray_running')),
|
||||
users_online=node_data.get('users_online'),
|
||||
users_online=node_data.get('users_online', 0),
|
||||
traffic_used_bytes=node_data.get('traffic_used_bytes'),
|
||||
traffic_limit_bytes=node_data.get('traffic_limit_bytes'),
|
||||
last_status_change=_parse_datetime(node_data.get('last_status_change')),
|
||||
last_status_message=node_data.get('last_status_message'),
|
||||
xray_uptime=node_data.get('xray_uptime'),
|
||||
xray_uptime=node_data.get('xray_uptime', 0) or 0,
|
||||
is_traffic_tracking_active=bool(node_data.get('is_traffic_tracking_active', False)),
|
||||
traffic_reset_day=node_data.get('traffic_reset_day'),
|
||||
notify_percent=node_data.get('notify_percent'),
|
||||
consumption_multiplier=float(node_data.get('consumption_multiplier', 1.0)),
|
||||
cpu_count=node_data.get('cpu_count'),
|
||||
cpu_model=node_data.get('cpu_model'),
|
||||
total_ram=node_data.get('total_ram'),
|
||||
created_at=_parse_datetime(node_data.get('created_at')),
|
||||
updated_at=_parse_datetime(node_data.get('updated_at')),
|
||||
provider_uuid=node_data.get('provider_uuid'),
|
||||
versions=node_data.get('versions'),
|
||||
system=node_data.get('system'),
|
||||
active_plugin_uuid=node_data.get('active_plugin_uuid'),
|
||||
)
|
||||
|
||||
|
||||
@@ -208,11 +209,9 @@ async def get_system_statistics(
|
||||
users_by_status=stats.get('users_by_status', {}),
|
||||
server_info=ServerInfo(
|
||||
cpu_cores=server_data.get('cpu_cores', 0),
|
||||
cpu_physical_cores=server_data.get('cpu_physical_cores', 0),
|
||||
memory_total=server_data.get('memory_total', 0),
|
||||
memory_used=server_data.get('memory_used', 0),
|
||||
memory_free=server_data.get('memory_free', 0),
|
||||
memory_available=server_data.get('memory_available', 0),
|
||||
uptime_seconds=server_data.get('uptime_seconds', 0),
|
||||
),
|
||||
bandwidth=Bandwidth(
|
||||
@@ -397,15 +396,21 @@ async def perform_node_action(
|
||||
)
|
||||
|
||||
|
||||
class RestartAllNodesPayload(BaseModel):
|
||||
force_restart: bool = False
|
||||
|
||||
|
||||
@router.post('/nodes/restart-all', response_model=NodeActionResponse)
|
||||
async def restart_all_nodes(
|
||||
payload: RestartAllNodesPayload | None = None,
|
||||
admin: User = Depends(require_permission('remnawave:manage')),
|
||||
) -> NodeActionResponse:
|
||||
"""Restart all nodes."""
|
||||
service = _get_service()
|
||||
_ensure_configured(service)
|
||||
|
||||
success = await service.restart_all_nodes()
|
||||
force = payload.force_restart if payload else False
|
||||
success = await service.restart_all_nodes(force_restart=force)
|
||||
|
||||
if success:
|
||||
logger.info('Admin restarted all nodes', telegram_id=admin.telegram_id)
|
||||
|
||||
@@ -441,6 +441,14 @@ async def assign_role(
|
||||
detail='Role not found',
|
||||
)
|
||||
|
||||
# Superadmin role is managed exclusively via ADMIN_IDS/ADMIN_EMAILS env config
|
||||
if role.level >= SUPERADMIN_LEVEL:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Superadmin role is managed via ADMIN_IDS/ADMIN_EMAILS environment variables. '
|
||||
'Add the user there and restart the bot.',
|
||||
)
|
||||
|
||||
admin_level = await _get_admin_level(db, admin)
|
||||
|
||||
# Cannot assign a role with level >= own level
|
||||
@@ -450,13 +458,6 @@ async def assign_role(
|
||||
detail='Cannot assign a role with level >= your own role level',
|
||||
)
|
||||
|
||||
# Superadmin assignments must be permanent — expiry would cause silent lockout
|
||||
if role.level == SUPERADMIN_LEVEL and payload.expires_at is not None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Superadmin role assignments cannot be time-limited',
|
||||
)
|
||||
|
||||
# Verify target user exists
|
||||
from app.database.crud.user import get_user_by_id
|
||||
|
||||
@@ -505,9 +506,7 @@ async def revoke_role(
|
||||
admin: User = Depends(require_permission('roles:assign')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Revoke a role assignment. Cannot remove the last superadmin."""
|
||||
from app.config import settings
|
||||
from app.database.crud.user import get_user_by_id
|
||||
"""Revoke a role assignment. Superadmin roles are managed via env config."""
|
||||
from app.database.models import UserRole
|
||||
|
||||
# Lock the assignment row (FOR UPDATE held until commit)
|
||||
@@ -526,6 +525,14 @@ async def revoke_role(
|
||||
detail='Associated role not found',
|
||||
)
|
||||
|
||||
# Superadmin role is managed exclusively via env config
|
||||
if role.level >= SUPERADMIN_LEVEL:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Superadmin role is managed via ADMIN_IDS/ADMIN_EMAILS environment variables. '
|
||||
'Remove the user from env and restart the bot.',
|
||||
)
|
||||
|
||||
admin_level = await _get_admin_level(db, admin)
|
||||
|
||||
# Cannot revoke a role at or above own level
|
||||
@@ -535,33 +542,6 @@ async def revoke_role(
|
||||
detail='Cannot revoke a role at or above your own level',
|
||||
)
|
||||
|
||||
# Block self-revocation of superadmin role
|
||||
if role.level == SUPERADMIN_LEVEL and user_role.user_id == admin.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Cannot revoke your own superadmin role',
|
||||
)
|
||||
|
||||
# Protect last superadmin (level 999).
|
||||
# Advisory lock serializes concurrent superadmin revocations so two requests
|
||||
# cannot both read count=2 and then both proceed to revoke.
|
||||
if role.level == SUPERADMIN_LEVEL:
|
||||
if not settings.is_sqlite():
|
||||
await db.execute(sa.text('SELECT pg_advisory_xact_lock(736453)'))
|
||||
superadmin_count = await UserRoleCRUD.get_superadmin_count(db)
|
||||
if superadmin_count <= 1:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Cannot remove the last superadmin',
|
||||
)
|
||||
|
||||
# Warn if target user is a legacy admin — RBAC revocation won't actually block access
|
||||
target_user = await get_user_by_id(db, user_role.user_id)
|
||||
is_target_legacy = target_user and settings.is_admin(
|
||||
telegram_id=target_user.telegram_id,
|
||||
email=target_user.email if target_user.email_verified else None,
|
||||
)
|
||||
|
||||
# Revoke directly on the locked object (avoid CRUD re-fetch without FOR UPDATE)
|
||||
user_role.is_active = False
|
||||
await db.flush()
|
||||
@@ -575,10 +555,4 @@ async def revoke_role(
|
||||
role_name=role.name,
|
||||
)
|
||||
|
||||
result_msg = {'message': 'Role revoked', 'assignment_id': assignment_id}
|
||||
if is_target_legacy:
|
||||
result_msg['warning'] = (
|
||||
'This user is still listed in ADMIN_IDS/ADMIN_EMAILS env config. '
|
||||
'They retain full access until removed from those settings and the bot is restarted.'
|
||||
)
|
||||
return result_msg
|
||||
return {'message': 'Role revoked', 'assignment_id': assignment_id}
|
||||
|
||||
@@ -128,7 +128,7 @@ async def get_sales_summary(
|
||||
|
||||
# Manual top-ups by admins
|
||||
manual_topup_result = await db.execute(
|
||||
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
|
||||
select(func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0)).where(
|
||||
and_(
|
||||
Transaction.type == TransactionType.DEPOSIT.value,
|
||||
Transaction.is_completed == True,
|
||||
@@ -246,7 +246,7 @@ async def get_sales_summary(
|
||||
|
||||
# Add-on revenue
|
||||
addon_revenue_result = await db.execute(
|
||||
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
|
||||
select(func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0)).where(
|
||||
and_(
|
||||
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
|
||||
Transaction.is_completed == True,
|
||||
@@ -256,7 +256,7 @@ async def get_sales_summary(
|
||||
)
|
||||
)
|
||||
)
|
||||
addon_revenue = abs(addon_revenue_result.scalar() or 0)
|
||||
addon_revenue = addon_revenue_result.scalar() or 0
|
||||
|
||||
return SalesSummary(
|
||||
total_revenue_kopeks=total_revenue + manual_topup,
|
||||
@@ -1101,11 +1101,11 @@ async def get_deposits_stats(
|
||||
select(
|
||||
Transaction.payment_method.label('method'),
|
||||
func.count(Transaction.id).label('count'),
|
||||
func.coalesce(func.sum(Transaction.amount_kopeks), 0).label('amount'),
|
||||
func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0).label('amount'),
|
||||
)
|
||||
.where(base_filter)
|
||||
.group_by(Transaction.payment_method)
|
||||
.order_by(func.sum(Transaction.amount_kopeks).desc())
|
||||
.order_by(func.sum(func.abs(Transaction.amount_kopeks)).desc())
|
||||
)
|
||||
by_method = [
|
||||
DepositByMethodItem(method=row.method or 'unknown', count=row.count, amount_kopeks=row.amount)
|
||||
@@ -1116,7 +1116,7 @@ async def get_deposits_stats(
|
||||
select(
|
||||
func.date(Transaction.created_at).label('date'),
|
||||
func.count(Transaction.id).label('count'),
|
||||
func.coalesce(func.sum(Transaction.amount_kopeks), 0).label('amount'),
|
||||
func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0).label('amount'),
|
||||
)
|
||||
.where(base_filter)
|
||||
.group_by(func.date(Transaction.created_at))
|
||||
@@ -1137,7 +1137,7 @@ async def get_deposits_stats(
|
||||
select(
|
||||
func.date(Transaction.created_at).label('date'),
|
||||
Transaction.payment_method.label('method'),
|
||||
func.coalesce(func.sum(Transaction.amount_kopeks), 0).label('amount'),
|
||||
func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0).label('amount'),
|
||||
)
|
||||
.where(base_filter)
|
||||
.group_by(func.date(Transaction.created_at), Transaction.payment_method)
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import sys
|
||||
import time
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
@@ -49,15 +50,11 @@ class NodeStatus(BaseModel):
|
||||
is_disabled: bool
|
||||
users_online: int
|
||||
traffic_used_bytes: int | None = None
|
||||
uptime: str | None = None
|
||||
xray_version: str | None = None
|
||||
node_version: str | None = None
|
||||
last_status_message: str | None = None
|
||||
xray_uptime: str | None = None
|
||||
xray_uptime: int = 0
|
||||
is_xray_running: bool | None = None
|
||||
cpu_count: int | None = None
|
||||
cpu_model: str | None = None
|
||||
total_ram: str | None = None
|
||||
versions: dict[str, str] | None = None
|
||||
system: dict[str, Any] | None = None
|
||||
country_code: str | None = None
|
||||
|
||||
|
||||
@@ -469,15 +466,11 @@ async def _get_nodes_overview() -> NodesOverview:
|
||||
is_disabled=n.get('is_disabled', False),
|
||||
users_online=n.get('users_online', 0) or 0,
|
||||
traffic_used_bytes=n.get('traffic_used_bytes'),
|
||||
uptime=n.get('uptime'),
|
||||
xray_version=n.get('xray_version'),
|
||||
node_version=n.get('node_version'),
|
||||
last_status_message=n.get('last_status_message'),
|
||||
xray_uptime=n.get('xray_uptime'),
|
||||
xray_uptime=n.get('xray_uptime', 0) or 0,
|
||||
is_xray_running=n.get('is_xray_running'),
|
||||
cpu_count=n.get('cpu_count'),
|
||||
cpu_model=n.get('cpu_model'),
|
||||
total_ram=n.get('total_ram'),
|
||||
versions=n.get('versions'),
|
||||
system=n.get('system'),
|
||||
country_code=n.get('country_code'),
|
||||
)
|
||||
for n in nodes
|
||||
|
||||
@@ -8,6 +8,7 @@ from sqlalchemy import and_, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.server_squad import get_all_server_squads
|
||||
from app.database.crud.tariff import (
|
||||
create_tariff,
|
||||
@@ -656,7 +657,11 @@ async def _background_sync_squads(tariff_id: int, admin_id: int) -> None:
|
||||
|
||||
async def _sync_one(sub: Subscription) -> None:
|
||||
nonlocal updated, failed
|
||||
remnawave_uuid = sub.user.remnawave_uuid if sub.user else None
|
||||
remnawave_uuid = (
|
||||
getattr(sub, 'remnawave_uuid', None)
|
||||
if settings.is_multi_tariff_enabled()
|
||||
else (sub.user.remnawave_uuid if sub.user else None)
|
||||
)
|
||||
if not remnawave_uuid:
|
||||
return
|
||||
async with semaphore:
|
||||
@@ -767,7 +772,11 @@ async def sync_tariff_squads(
|
||||
skipped_count += 1
|
||||
return 'skipped'
|
||||
|
||||
remnawave_uuid = sub.user.remnawave_uuid if sub.user else None
|
||||
remnawave_uuid = (
|
||||
getattr(sub, 'remnawave_uuid', None)
|
||||
if settings.is_multi_tariff_enabled()
|
||||
else (sub.user.remnawave_uuid if sub.user else None)
|
||||
)
|
||||
if not remnawave_uuid:
|
||||
skipped_count += 1
|
||||
return 'skipped'
|
||||
|
||||
@@ -21,6 +21,8 @@ from ..dependencies import get_cabinet_db, require_permission
|
||||
from ..schemas.traffic import (
|
||||
ExportCsvRequest,
|
||||
ExportCsvResponse,
|
||||
SubscriptionEnrichmentInfo,
|
||||
SubscriptionTrafficInfo,
|
||||
TrafficEnrichmentResponse,
|
||||
TrafficNodeInfo,
|
||||
TrafficUsageResponse,
|
||||
@@ -156,15 +158,42 @@ def _compute_date_range(period_days: int) -> tuple[str, str]:
|
||||
|
||||
|
||||
async def _load_user_map(db: AsyncSession) -> dict[str, User]:
|
||||
"""Load all users with remnawave_uuid, eagerly loading subscription + tariff."""
|
||||
stmt = (
|
||||
"""Load all users with remnawave_uuid, eagerly loading subscription + tariff.
|
||||
|
||||
In multi-tariff mode UUIDs live on Subscription rows, not on User.
|
||||
Both sources are merged so the caller gets a complete uuid → User map.
|
||||
"""
|
||||
from app.config import settings
|
||||
|
||||
# Build user map from both user-level and subscription-level UUIDs
|
||||
user_map: dict[str, User] = {}
|
||||
|
||||
# Legacy: user-level UUIDs
|
||||
stmt_users = (
|
||||
select(User)
|
||||
.where(User.remnawave_uuid.isnot(None))
|
||||
.options(selectinload(User.subscription).selectinload(Subscription.tariff))
|
||||
.options(selectinload(User.subscriptions).selectinload(Subscription.tariff))
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
users = result.scalars().all()
|
||||
return {u.remnawave_uuid: u for u in users if u.remnawave_uuid}
|
||||
result_users = await db.execute(stmt_users)
|
||||
users = result_users.scalars().all()
|
||||
for u in users:
|
||||
if u.remnawave_uuid:
|
||||
user_map[u.remnawave_uuid] = u
|
||||
|
||||
# Multi-tariff: subscription-level UUIDs
|
||||
if settings.is_multi_tariff_enabled():
|
||||
stmt_subs = (
|
||||
select(Subscription)
|
||||
.where(Subscription.remnawave_uuid.isnot(None))
|
||||
.options(selectinload(Subscription.user).selectinload(User.subscriptions).selectinload(Subscription.tariff))
|
||||
)
|
||||
result_subs = await db.execute(stmt_subs)
|
||||
subs = result_subs.scalars().all()
|
||||
for sub in subs:
|
||||
if sub.remnawave_uuid and sub.user and sub.remnawave_uuid not in user_map:
|
||||
user_map[sub.remnawave_uuid] = sub.user
|
||||
|
||||
return user_map
|
||||
|
||||
|
||||
def _build_traffic_items(
|
||||
@@ -202,19 +231,23 @@ def _build_traffic_items(
|
||||
):
|
||||
continue
|
||||
|
||||
sub = user.subscription
|
||||
subs = getattr(user, 'subscriptions', None) or []
|
||||
|
||||
# Primary subscription for backward-compat top-level fields
|
||||
primary_sub = next((s for s in subs if s.is_active), subs[0] if subs else None)
|
||||
tariff_name = None
|
||||
subscription_status = None
|
||||
traffic_limit_gb = 0.0
|
||||
device_limit = 1
|
||||
|
||||
if sub:
|
||||
subscription_status = _get_status(sub)
|
||||
traffic_limit_gb = float(sub.traffic_limit_gb or 0)
|
||||
device_limit = sub.device_limit or 1
|
||||
if sub.tariff:
|
||||
tariff_name = sub.tariff.name
|
||||
if primary_sub:
|
||||
subscription_status = _get_status(primary_sub)
|
||||
traffic_limit_gb = float(primary_sub.traffic_limit_gb or 0)
|
||||
device_limit = primary_sub.device_limit or 1
|
||||
if primary_sub.tariff:
|
||||
tariff_name = primary_sub.tariff.name
|
||||
|
||||
# Filtering uses primary sub values (keeps existing filter semantics)
|
||||
if tariff_filter is not None:
|
||||
if (tariff_name or '') not in tariff_filter:
|
||||
continue
|
||||
@@ -229,6 +262,18 @@ def _build_traffic_items(
|
||||
|
||||
total_bytes = sum(traffic.values())
|
||||
|
||||
# Build per-subscription detail list for multi-subscription display
|
||||
subscriptions_traffic = [
|
||||
SubscriptionTrafficInfo(
|
||||
subscription_id=sub.id,
|
||||
tariff_name=sub.tariff.name if sub.tariff else None,
|
||||
status=_get_status(sub),
|
||||
traffic_limit_gb=float(sub.traffic_limit_gb or 0),
|
||||
device_limit=sub.device_limit or 1,
|
||||
)
|
||||
for sub in subs
|
||||
]
|
||||
|
||||
items.append(
|
||||
UserTrafficItem(
|
||||
user_id=user.id,
|
||||
@@ -242,6 +287,7 @@ def _build_traffic_items(
|
||||
device_limit=device_limit,
|
||||
node_traffic=traffic,
|
||||
total_bytes=total_bytes,
|
||||
subscriptions=subscriptions_traffic,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -305,15 +351,21 @@ async def get_traffic_usage(
|
||||
# Collect all available tariff names (before filtering)
|
||||
available_tariffs = sorted(
|
||||
{
|
||||
u.subscription.tariff.name
|
||||
sub.tariff.name
|
||||
for u in user_map.values()
|
||||
if u.subscription and u.subscription.tariff and u.subscription.tariff.name
|
||||
for sub in (getattr(u, 'subscriptions', None) or [])
|
||||
if sub.tariff and sub.tariff.name
|
||||
}
|
||||
)
|
||||
|
||||
# Collect all available statuses (before filtering)
|
||||
available_statuses = sorted(
|
||||
{_get_status(sub) for u in user_map.values() if (sub := u.subscription) and _get_status(sub)}
|
||||
{
|
||||
_get_status(sub)
|
||||
for u in user_map.values()
|
||||
for sub in (getattr(u, 'subscriptions', None) or [])
|
||||
if _get_status(sub)
|
||||
}
|
||||
)
|
||||
|
||||
# Parse tariff filter
|
||||
@@ -466,27 +518,42 @@ async def _build_enrichment(db: AsyncSession, user_map: dict[str, User]) -> dict
|
||||
enrichment: dict[int, UserTrafficEnrichment] = {}
|
||||
for uuid, user in user_map.items():
|
||||
uid = user.id
|
||||
sub = user.subscription
|
||||
subs_list = getattr(user, 'subscriptions', None) or []
|
||||
|
||||
# Primary subscription for backward-compat top-level date fields
|
||||
primary_sub = next((s for s in subs_list if s.is_active), subs_list[0] if subs_list else None)
|
||||
|
||||
start_date = None
|
||||
end_date = None
|
||||
if sub:
|
||||
if sub.start_date:
|
||||
start_date = sub.start_date.isoformat()
|
||||
if sub.end_date:
|
||||
end_date = sub.end_date.isoformat()
|
||||
if primary_sub:
|
||||
if primary_sub.start_date:
|
||||
start_date = primary_sub.start_date.isoformat()
|
||||
if primary_sub.end_date:
|
||||
end_date = primary_sub.end_date.isoformat()
|
||||
|
||||
last_node_name = None
|
||||
last_uuid = last_node_uuid_by_user.get(uid)
|
||||
if last_uuid:
|
||||
last_node_name = node_uuid_to_name.get(last_uuid)
|
||||
|
||||
# Build per-subscription enrichment list for multi-subscription display
|
||||
subscriptions_enrichment = [
|
||||
SubscriptionEnrichmentInfo(
|
||||
subscription_id=sub.id,
|
||||
tariff_name=sub.tariff.name if sub.tariff else None,
|
||||
start_date=sub.start_date.isoformat() if sub.start_date else None,
|
||||
end_date=sub.end_date.isoformat() if sub.end_date else None,
|
||||
)
|
||||
for sub in subs_list
|
||||
]
|
||||
|
||||
enrichment[uid] = UserTrafficEnrichment(
|
||||
devices_connected=devices_by_user.get(uid, 0),
|
||||
total_spent_kopeks=spending_map.get(uid, 0),
|
||||
subscription_start_date=start_date,
|
||||
subscription_end_date=end_date,
|
||||
last_node_name=last_node_name,
|
||||
subscriptions=subscriptions_enrichment,
|
||||
)
|
||||
|
||||
return enrichment
|
||||
|
||||
+586
-124
File diff suppressed because it is too large
Load Diff
+316
-117
@@ -61,6 +61,7 @@ from ..auth.email_verification import (
|
||||
is_token_expired,
|
||||
)
|
||||
from ..auth.jwt_handler import get_refresh_token_expires_at
|
||||
from ..auth.merge_service import create_merge_token
|
||||
from ..dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
from ..ip_utils import get_client_ip
|
||||
from ..schemas.auth import (
|
||||
@@ -143,22 +144,28 @@ async def _store_refresh_token(
|
||||
refresh_token: str,
|
||||
device_info: str | None = None,
|
||||
) -> None:
|
||||
"""Store refresh token hash in database."""
|
||||
"""Store refresh token hash in database using upsert to avoid duplicate key errors."""
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
|
||||
token_hash = hashlib.sha256(refresh_token.encode()).hexdigest()
|
||||
expires_at = get_refresh_token_expires_at()
|
||||
|
||||
token_record = CabinetRefreshToken(
|
||||
stmt = pg_insert(CabinetRefreshToken).values(
|
||||
user_id=user_id,
|
||||
token_hash=token_hash,
|
||||
device_info=device_info,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
db.add(token_record)
|
||||
try:
|
||||
await db.commit()
|
||||
except IntegrityError:
|
||||
await db.rollback()
|
||||
logger.debug('Refresh token already exists (duplicate)', user_id=user_id)
|
||||
stmt = stmt.on_conflict_do_update(
|
||||
index_elements=['token_hash'],
|
||||
set_={
|
||||
'expires_at': expires_at,
|
||||
'device_info': device_info,
|
||||
'revoked_at': None,
|
||||
},
|
||||
)
|
||||
await db.execute(stmt)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def _process_campaign_bonus(
|
||||
@@ -239,11 +246,39 @@ async def _process_referral_code(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
referral_code: str | None,
|
||||
*,
|
||||
is_new_user: bool = False,
|
||||
) -> None:
|
||||
"""Set referred_by_id for user if referral_code is valid. Never raises."""
|
||||
if not referral_code or user.referred_by_id:
|
||||
"""Process referral for a newly created user. Never raises.
|
||||
|
||||
Only applies to new users (is_new_user=True). Existing users cannot be
|
||||
assigned a referrer — same logic as the bot /start handler.
|
||||
|
||||
Handles two cases:
|
||||
- referred_by_id already set by create_user() → fire registration event
|
||||
- referred_by_id not set (resolution failed earlier) → resolve, set, fire
|
||||
"""
|
||||
if not referral_code or not is_new_user:
|
||||
return
|
||||
try:
|
||||
from app.bot_factory import create_bot
|
||||
|
||||
# Lock user row to prevent concurrent referral application (TOCTOU race)
|
||||
await db.execute(select(User).where(User.id == user.id).with_for_update())
|
||||
await db.refresh(user)
|
||||
|
||||
# Case 1: referred_by_id already set by create_user() — just fire the event
|
||||
if user.referred_by_id:
|
||||
async with create_bot() as bot:
|
||||
await process_referral_registration(db, user.id, user.referred_by_id, bot=bot)
|
||||
logger.info(
|
||||
'Referral registration processed for pre-set referrer',
|
||||
user_id=user.id,
|
||||
referrer_id=user.referred_by_id,
|
||||
)
|
||||
return
|
||||
|
||||
# Case 2: referred_by_id not set — resolve referral code and set it
|
||||
referrer = await get_user_by_referral_code(db, referral_code)
|
||||
if not referrer:
|
||||
return
|
||||
@@ -254,8 +289,6 @@ async def _process_referral_code(
|
||||
user.referred_by_id = referrer.id
|
||||
await db.flush()
|
||||
|
||||
from app.bot_factory import create_bot
|
||||
|
||||
async with create_bot() as bot:
|
||||
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)
|
||||
@@ -288,94 +321,123 @@ async def _sync_subscription_from_panel_by_email(db: AsyncSession, user: User) -
|
||||
logger.debug('No subscription found in panel for email', email=user.email)
|
||||
return
|
||||
|
||||
# Take first user if multiple found
|
||||
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
|
||||
|
||||
# Create or update subscription
|
||||
from app.database.crud.subscription import get_subscription_by_user_id
|
||||
# In multi-tariff mode, sync ALL panel users (each = one subscription)
|
||||
# In single-tariff mode, process only the first
|
||||
from app.database.crud.subscription import get_active_subscriptions_by_user_id, get_subscription_by_user_id
|
||||
from app.database.models import Subscription, SubscriptionStatus
|
||||
|
||||
existing_sub = await get_subscription_by_user_id(db, user.id)
|
||||
panel_users_to_sync = panel_users if settings.is_multi_tariff_enabled() else panel_users[:1]
|
||||
|
||||
# Parse panel data — panel returns local time with misleading +00:00 offset
|
||||
expire_at = panel_datetime_to_utc(panel_user.expire_at)
|
||||
traffic_limit_gb = panel_user.traffic_limit_bytes // (1024**3) if panel_user.traffic_limit_bytes > 0 else 0
|
||||
traffic_used_gb = panel_user.used_traffic_bytes / (1024**3) if panel_user.used_traffic_bytes > 0 else 0
|
||||
for panel_user in panel_users_to_sync:
|
||||
logger.info('Syncing panel subscription for email', email=user.email, uuid=panel_user.uuid)
|
||||
|
||||
# Extract squad UUIDs from active_internal_squads
|
||||
connected_squads = [s.get('uuid', '') for s in (panel_user.active_internal_squads or []) if s.get('uuid')]
|
||||
# Check if another user already owns this remnawave_uuid
|
||||
if settings.is_multi_tariff_enabled():
|
||||
from sqlalchemy import select as _select
|
||||
|
||||
# Device limit from panel
|
||||
device_limit = panel_user.hwid_device_limit or 0
|
||||
from app.database.models import Subscription as _Subscription
|
||||
|
||||
# Determine status — expire_at is now naive UTC
|
||||
current_time = datetime.now(UTC)
|
||||
_sub_result = await db.execute(
|
||||
_select(_Subscription).where(_Subscription.remnawave_uuid == panel_user.uuid)
|
||||
)
|
||||
_existing_sub = _sub_result.scalar_one_or_none()
|
||||
if _existing_sub and _existing_sub.user_id != user.id:
|
||||
logger.warning(
|
||||
'Panel UUID already owned by another user subscription, skipping',
|
||||
email=user.email,
|
||||
panel_uuid=panel_user.uuid,
|
||||
existing_owner_id=_existing_sub.user_id,
|
||||
)
|
||||
continue
|
||||
else:
|
||||
from app.database.crud.user import get_user_by_remnawave_uuid
|
||||
|
||||
if panel_user.status.value == 'ACTIVE' and expire_at > current_time:
|
||||
sub_status = SubscriptionStatus.ACTIVE
|
||||
elif expire_at <= current_time:
|
||||
sub_status = SubscriptionStatus.EXPIRED
|
||||
else:
|
||||
sub_status = SubscriptionStatus.DISABLED
|
||||
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',
|
||||
email=user.email,
|
||||
panel_uuid=panel_user.uuid,
|
||||
existing_owner_id=existing_owner.id,
|
||||
)
|
||||
continue
|
||||
|
||||
if existing_sub:
|
||||
# Update existing subscription (expire_at already naive UTC)
|
||||
existing_sub.end_date = expire_at
|
||||
existing_sub.traffic_limit_gb = traffic_limit_gb
|
||||
existing_sub.traffic_used_gb = traffic_used_gb
|
||||
existing_sub.status = sub_status.value
|
||||
existing_sub.remnawave_short_uuid = panel_user.short_uuid
|
||||
existing_sub.subscription_url = panel_user.subscription_url
|
||||
existing_sub.subscription_crypto_link = panel_user.happ_crypto_link
|
||||
existing_sub.connected_squads = connected_squads
|
||||
existing_sub.device_limit = device_limit
|
||||
existing_sub.is_trial = False # Panel subscription is not trial
|
||||
logger.info(
|
||||
'Updated subscription for email user squads: devices',
|
||||
email=user.email,
|
||||
connected_squads=connected_squads,
|
||||
device_limit=device_limit,
|
||||
)
|
||||
else:
|
||||
# Create new subscription (expire_at and current_time already naive UTC)
|
||||
new_sub = Subscription(
|
||||
user_id=user.id,
|
||||
start_date=current_time,
|
||||
end_date=expire_at,
|
||||
traffic_limit_gb=traffic_limit_gb,
|
||||
traffic_used_gb=traffic_used_gb,
|
||||
status=sub_status.value,
|
||||
is_trial=False,
|
||||
remnawave_short_uuid=panel_user.short_uuid,
|
||||
subscription_url=panel_user.subscription_url,
|
||||
subscription_crypto_link=panel_user.happ_crypto_link,
|
||||
connected_squads=connected_squads,
|
||||
device_limit=device_limit,
|
||||
)
|
||||
db.add(new_sub)
|
||||
logger.info(
|
||||
'Created subscription for email user squads: devices',
|
||||
email=user.email,
|
||||
connected_squads=connected_squads,
|
||||
device_limit=device_limit,
|
||||
# Link user to panel (only in single-tariff mode)
|
||||
if not settings.is_multi_tariff_enabled():
|
||||
user.remnawave_uuid = panel_user.uuid
|
||||
|
||||
# Find existing subscription
|
||||
if settings.is_multi_tariff_enabled():
|
||||
active_subs = await get_active_subscriptions_by_user_id(db, user.id)
|
||||
existing_sub = next(
|
||||
(s for s in active_subs if s.remnawave_uuid == panel_user.uuid),
|
||||
None,
|
||||
)
|
||||
else:
|
||||
existing_sub = await get_subscription_by_user_id(db, user.id)
|
||||
|
||||
# Parse panel data
|
||||
expire_at = panel_datetime_to_utc(panel_user.expire_at)
|
||||
traffic_limit_gb = (
|
||||
panel_user.traffic_limit_bytes // (1024**3) if panel_user.traffic_limit_bytes > 0 else 0
|
||||
)
|
||||
traffic_used_gb = panel_user.used_traffic_bytes / (1024**3) if panel_user.used_traffic_bytes > 0 else 0
|
||||
connected_squads = [
|
||||
s.get('uuid', '') for s in (panel_user.active_internal_squads or []) if s.get('uuid')
|
||||
]
|
||||
device_limit = panel_user.hwid_device_limit or 0
|
||||
|
||||
# Determine status
|
||||
current_time = datetime.now(UTC)
|
||||
if panel_user.status.value == 'ACTIVE' and expire_at > current_time:
|
||||
sub_status = SubscriptionStatus.ACTIVE
|
||||
elif expire_at <= current_time:
|
||||
sub_status = SubscriptionStatus.EXPIRED
|
||||
else:
|
||||
sub_status = SubscriptionStatus.DISABLED
|
||||
|
||||
if existing_sub:
|
||||
existing_sub.end_date = expire_at
|
||||
existing_sub.traffic_limit_gb = traffic_limit_gb
|
||||
existing_sub.traffic_used_gb = traffic_used_gb
|
||||
existing_sub.status = sub_status.value
|
||||
existing_sub.remnawave_short_uuid = panel_user.short_uuid
|
||||
existing_sub.subscription_url = panel_user.subscription_url
|
||||
existing_sub.subscription_crypto_link = panel_user.happ_crypto_link
|
||||
existing_sub.connected_squads = connected_squads
|
||||
existing_sub.device_limit = device_limit
|
||||
existing_sub.is_trial = False
|
||||
logger.info(
|
||||
'Updated subscription for email user',
|
||||
email=user.email,
|
||||
uuid=panel_user.uuid,
|
||||
)
|
||||
else:
|
||||
from app.database.crud.subscription import generate_unique_short_id
|
||||
|
||||
_short_id = await generate_unique_short_id(db)
|
||||
new_sub = Subscription(
|
||||
user_id=user.id,
|
||||
start_date=current_time,
|
||||
end_date=expire_at,
|
||||
traffic_limit_gb=traffic_limit_gb,
|
||||
traffic_used_gb=traffic_used_gb,
|
||||
status=sub_status.value,
|
||||
is_trial=False,
|
||||
remnawave_uuid=panel_user.uuid if settings.is_multi_tariff_enabled() else None,
|
||||
remnawave_short_id=_short_id,
|
||||
remnawave_short_uuid=panel_user.short_uuid,
|
||||
subscription_url=panel_user.subscription_url,
|
||||
subscription_crypto_link=panel_user.happ_crypto_link,
|
||||
connected_squads=connected_squads,
|
||||
device_limit=device_limit,
|
||||
)
|
||||
db.add(new_sub)
|
||||
logger.info(
|
||||
'Created subscription for email user',
|
||||
email=user.email,
|
||||
uuid=panel_user.uuid,
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
@@ -405,7 +467,11 @@ async def auth_telegram(
|
||||
detail='Too many requests',
|
||||
headers={'Retry-After': '60'},
|
||||
)
|
||||
user_data = validate_telegram_init_data(request.init_data)
|
||||
# Telegram Desktop/iOS cache initData with stale auth_date (known Telegram bug:
|
||||
# https://github.com/telegramdesktop/tdesktop/issues/28303).
|
||||
# Use generous max_age: HMAC signature proves authenticity,
|
||||
# JWT tokens handle actual session expiration after login.
|
||||
user_data = validate_telegram_init_data(request.init_data, max_age_seconds=86400 * 30)
|
||||
|
||||
if not user_data:
|
||||
raise HTTPException(
|
||||
@@ -434,10 +500,35 @@ async def auth_telegram(
|
||||
try:
|
||||
referrer = await get_user_by_referral_code(db, request.referral_code)
|
||||
if referrer:
|
||||
referrer_id = referrer.id
|
||||
# Self-referral protection by telegram_id (user doesn't exist yet, can't compare user.id)
|
||||
if referrer.telegram_id and referrer.telegram_id == telegram_id:
|
||||
logger.warning(
|
||||
'Self-referral attempt blocked via telegram_id',
|
||||
telegram_id=telegram_id,
|
||||
referral_code=request.referral_code,
|
||||
)
|
||||
else:
|
||||
referrer_id = referrer.id
|
||||
except Exception as e:
|
||||
logger.warning('Failed to resolve referral code', referral_code=request.referral_code, error=e)
|
||||
|
||||
# Fallback: check Redis for pending referral from /start (user opened cabinet before completing bot registration)
|
||||
if not referrer_id and not user and telegram_id:
|
||||
try:
|
||||
from app.services.referral_service import get_pending_referral
|
||||
|
||||
pending = await get_pending_referral(telegram_id)
|
||||
if pending and pending.get('referrer_id'):
|
||||
referrer_id = pending['referrer_id']
|
||||
logger.info(
|
||||
'Resolved referral from Redis pending_referral (cabinet)',
|
||||
telegram_id=telegram_id,
|
||||
referrer_id=referrer_id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning('Failed to check pending referral', error=e)
|
||||
|
||||
is_new_user = not user
|
||||
if not user:
|
||||
# Create new user from Telegram initData
|
||||
logger.info('Creating new user from cabinet (initData): telegram_id', telegram_id=telegram_id)
|
||||
@@ -481,8 +572,17 @@ async def auth_telegram(
|
||||
# Store refresh token
|
||||
await _store_refresh_token(db, user.id, response.refresh_token)
|
||||
|
||||
# Process referral code (before campaign bonus, which may also set referrer)
|
||||
await _process_referral_code(db, user, request.referral_code)
|
||||
# Process referral code (only for new users — existing users cannot be assigned a referrer)
|
||||
await _process_referral_code(db, user, request.referral_code, is_new_user=is_new_user)
|
||||
|
||||
# Clear Redis pending referral after successful user creation with referral
|
||||
if referrer_id:
|
||||
try:
|
||||
from app.services.referral_service import clear_pending_referral
|
||||
|
||||
await clear_pending_referral(telegram_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Process campaign bonus
|
||||
response.campaign_bonus = await _process_campaign_bonus(db, user, request.campaign_slug)
|
||||
@@ -515,7 +615,8 @@ async def auth_telegram_widget(
|
||||
|
||||
widget_data = request.model_dump(exclude={'campaign_slug', 'referral_code'})
|
||||
|
||||
if not validate_telegram_login_widget(widget_data):
|
||||
# Generous max_age: Telegram caches auth data with stale auth_date
|
||||
if not validate_telegram_login_widget(widget_data, max_age_seconds=86400 * 30):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail='Invalid or expired Telegram authentication data',
|
||||
@@ -529,10 +630,19 @@ async def auth_telegram_widget(
|
||||
try:
|
||||
referrer = await get_user_by_referral_code(db, request.referral_code)
|
||||
if referrer:
|
||||
referrer_id = referrer.id
|
||||
# Self-referral protection by telegram_id (user doesn't exist yet, can't compare user.id)
|
||||
if referrer.telegram_id and referrer.telegram_id == request.id:
|
||||
logger.warning(
|
||||
'Self-referral attempt blocked via telegram_id',
|
||||
telegram_id=request.id,
|
||||
referral_code=request.referral_code,
|
||||
)
|
||||
else:
|
||||
referrer_id = referrer.id
|
||||
except Exception as e:
|
||||
logger.warning('Failed to resolve referral code', referral_code=request.referral_code, error=e)
|
||||
|
||||
is_new_user = not user
|
||||
if not user:
|
||||
# Create new user from Telegram data
|
||||
logger.info(
|
||||
@@ -569,8 +679,17 @@ async def auth_telegram_widget(
|
||||
response = await _create_auth_response(user, db)
|
||||
await _store_refresh_token(db, user.id, response.refresh_token)
|
||||
|
||||
# Process referral code (before campaign bonus, which may also set referrer)
|
||||
await _process_referral_code(db, user, request.referral_code)
|
||||
# Process referral code (only for new users — existing users cannot be assigned a referrer)
|
||||
await _process_referral_code(db, user, request.referral_code, is_new_user=is_new_user)
|
||||
|
||||
# Clear Redis pending referral after successful registration
|
||||
if referrer_id and request.id:
|
||||
try:
|
||||
from app.services.referral_service import clear_pending_referral
|
||||
|
||||
await clear_pending_referral(request.id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Process campaign bonus
|
||||
response.campaign_bonus = await _process_campaign_bonus(db, user, request.campaign_slug)
|
||||
@@ -661,10 +780,19 @@ async def auth_telegram_oidc(
|
||||
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))
|
||||
# Self-referral protection by telegram_id (user doesn't exist yet, can't compare user.id)
|
||||
if referrer.telegram_id and referrer.telegram_id == telegram_id:
|
||||
logger.warning(
|
||||
'Self-referral attempt blocked via telegram_id',
|
||||
telegram_id=telegram_id,
|
||||
referral_code=request.referral_code,
|
||||
)
|
||||
else:
|
||||
referrer_id = referrer.id
|
||||
except Exception as e:
|
||||
logger.warning('Failed to resolve referral code', referral_code=request.referral_code, error=e)
|
||||
|
||||
is_new_user = not user
|
||||
if not user:
|
||||
logger.info('Creating new user from cabinet OIDC', telegram_id=telegram_id, username=username)
|
||||
user = await create_user(
|
||||
@@ -698,7 +826,17 @@ async def auth_telegram_oidc(
|
||||
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)
|
||||
# Process referral code (only for new users — existing users cannot be assigned a referrer)
|
||||
await _process_referral_code(db, user, request.referral_code, is_new_user=is_new_user)
|
||||
|
||||
# Clear Redis pending referral after successful registration
|
||||
if referrer_id and telegram_id:
|
||||
try:
|
||||
from app.services.referral_service import clear_pending_referral
|
||||
|
||||
await clear_pending_referral(telegram_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
response.campaign_bonus = await _process_campaign_bonus(db, user, request.campaign_slug)
|
||||
if response.campaign_bonus:
|
||||
@@ -710,6 +848,7 @@ async def auth_telegram_oidc(
|
||||
@router.post('/email/register')
|
||||
async def register_email(
|
||||
request: EmailRegisterRequest,
|
||||
raw_request: Request,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
@@ -718,7 +857,24 @@ async def register_email(
|
||||
|
||||
Requires valid JWT token from Telegram authentication.
|
||||
Sends verification email to the provided address.
|
||||
If the email belongs to another active user, offers account merge.
|
||||
"""
|
||||
# Rate limit
|
||||
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 user already has a verified email — block before doing anything else
|
||||
if user.email and user.email_verified:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='You already have a verified email',
|
||||
)
|
||||
|
||||
# Check for disposable email
|
||||
if disposable_email_service.is_disposable(request.email):
|
||||
raise HTTPException(
|
||||
@@ -726,21 +882,38 @@ async def register_email(
|
||||
detail='Disposable email addresses are not allowed',
|
||||
)
|
||||
|
||||
# Check if email already exists (case-insensitive)
|
||||
# Check if email already exists (case-insensitive, exclude deleted users)
|
||||
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,
|
||||
detail='This email is already registered',
|
||||
existing_result = await db.execute(
|
||||
select(User).where(
|
||||
func.lower(User.email) == email_lower,
|
||||
User.status != UserStatus.DELETED.value,
|
||||
)
|
||||
|
||||
# Check if user already has email
|
||||
if user.email and user.email_verified:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='You already have a verified email',
|
||||
)
|
||||
existing_email_user = existing_result.scalar_one_or_none()
|
||||
if existing_email_user:
|
||||
if existing_email_user.id == user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='This email is already linked to your account',
|
||||
)
|
||||
# Offer account merge instead of blocking
|
||||
logger.info(
|
||||
'Email register conflict: email already linked to another user, offering merge',
|
||||
current_user_id=user.id,
|
||||
existing_user_id=existing_email_user.id,
|
||||
)
|
||||
merge_token = await create_merge_token(
|
||||
primary_user_id=user.id,
|
||||
secondary_user_id=existing_email_user.id,
|
||||
provider='email',
|
||||
provider_id=email_lower,
|
||||
)
|
||||
return {
|
||||
'message': 'Account merge required',
|
||||
'merge_required': True,
|
||||
'merge_token': merge_token,
|
||||
}
|
||||
|
||||
# Update user
|
||||
user.email = request.email
|
||||
@@ -885,12 +1058,26 @@ async def register_email_standalone(
|
||||
referred_by_id=referrer.id if referrer else None,
|
||||
)
|
||||
|
||||
# Сохранить campaign_slug для обработки при верификации email
|
||||
if request.campaign_slug:
|
||||
user.pending_campaign_slug = request.campaign_slug
|
||||
|
||||
# Для тестового 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('Email auto-verified (test or verification disabled)', email=request.email, user_id=user.id)
|
||||
# Sync existing panel subscription (same as manual verification flow)
|
||||
try:
|
||||
await _sync_subscription_from_panel_by_email(db, user)
|
||||
except Exception:
|
||||
logger.warning('Failed to sync panel subscription after auto-verify', user_id=user.id, exc_info=True)
|
||||
# Process campaign bonus immediately for auto-verified users
|
||||
if request.campaign_slug:
|
||||
await _process_campaign_bonus(db, user, request.campaign_slug)
|
||||
user.pending_campaign_slug = None
|
||||
await db.commit()
|
||||
else:
|
||||
# Сгенерировать токен верификации
|
||||
verification_token = generate_verification_token()
|
||||
@@ -1001,8 +1188,12 @@ async def verify_email(
|
||||
response = await _create_auth_response(user, db)
|
||||
await _store_refresh_token(db, user.id, response.refresh_token)
|
||||
|
||||
# Process campaign bonus
|
||||
response.campaign_bonus = await _process_campaign_bonus(db, user, request.campaign_slug)
|
||||
# Process campaign bonus (prefer request param, fallback to saved slug from registration)
|
||||
effective_campaign_slug = request.campaign_slug or user.pending_campaign_slug
|
||||
response.campaign_bonus = await _process_campaign_bonus(db, user, effective_campaign_slug)
|
||||
if user.pending_campaign_slug:
|
||||
user.pending_campaign_slug = None
|
||||
await db.commit()
|
||||
if response.campaign_bonus:
|
||||
response.user = _user_to_response(user)
|
||||
|
||||
@@ -1793,6 +1984,14 @@ async def poll_deep_link_token(
|
||||
response = await _create_auth_response(user, db)
|
||||
await _store_refresh_token(db, user.id, response.refresh_token, device_info='deep_link')
|
||||
|
||||
# Deep link auth is always for existing users — referral code not applicable
|
||||
# (kept for campaign bonus processing only)
|
||||
|
||||
# Process campaign bonus
|
||||
response.campaign_bonus = await _process_campaign_bonus(db, user, request.campaign_slug)
|
||||
if response.campaign_bonus:
|
||||
response.user = _user_to_response(user)
|
||||
|
||||
logger.info('Deep link auth successful', user_id=user.id, telegram_id=user.telegram_id)
|
||||
|
||||
return response
|
||||
|
||||
@@ -360,7 +360,7 @@ async def create_topup(
|
||||
option = (request.payment_option or '').strip().lower()
|
||||
# Use description with telegram_id for tax receipts
|
||||
description = settings.get_balance_payment_description(
|
||||
request.amount_kopeks, telegram_user_id=user.telegram_id
|
||||
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
|
||||
)
|
||||
if option == 'sbp':
|
||||
result = await payment_service.create_yookassa_sbp_payment(
|
||||
@@ -423,7 +423,7 @@ async def create_topup(
|
||||
amount_usd=amount_usd,
|
||||
asset=settings.CRYPTOBOT_DEFAULT_ASSET,
|
||||
description=settings.get_balance_payment_description(
|
||||
request.amount_kopeks, telegram_user_id=user.telegram_id
|
||||
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
|
||||
),
|
||||
payload=f'cabinet_topup_{user.id}_{request.amount_kopeks}',
|
||||
)
|
||||
@@ -484,7 +484,7 @@ async def create_topup(
|
||||
user_id=user.id,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
description=settings.get_balance_payment_description(
|
||||
request.amount_kopeks, telegram_user_id=user.telegram_id
|
||||
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
|
||||
),
|
||||
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
|
||||
payment_method_code=method_code,
|
||||
@@ -513,7 +513,9 @@ async def create_topup(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
description=settings.get_balance_payment_description(request.amount_kopeks),
|
||||
description=settings.get_balance_payment_description(
|
||||
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
|
||||
),
|
||||
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
|
||||
return_url=cabinet_return_url,
|
||||
success_url=cabinet_success_url,
|
||||
@@ -540,7 +542,9 @@ async def create_topup(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
description=settings.get_balance_payment_description(request.amount_kopeks),
|
||||
description=settings.get_balance_payment_description(
|
||||
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
|
||||
),
|
||||
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
|
||||
)
|
||||
|
||||
@@ -570,8 +574,11 @@ async def create_topup(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
description=settings.get_balance_payment_description(request.amount_kopeks),
|
||||
description=settings.get_balance_payment_description(
|
||||
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
|
||||
),
|
||||
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
|
||||
payment_method=option,
|
||||
)
|
||||
|
||||
if result:
|
||||
@@ -610,7 +617,9 @@ async def create_topup(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
description=settings.get_balance_payment_description(request.amount_kopeks),
|
||||
description=settings.get_balance_payment_description(
|
||||
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
|
||||
),
|
||||
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
|
||||
return_url=cabinet_success_url,
|
||||
failed_url=cabinet_failed_url,
|
||||
@@ -637,7 +646,9 @@ async def create_topup(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
description=settings.get_balance_payment_description(request.amount_kopeks),
|
||||
description=settings.get_balance_payment_description(
|
||||
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
|
||||
),
|
||||
telegram_id=user.telegram_id,
|
||||
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
|
||||
return_url=cabinet_success_url,
|
||||
@@ -665,7 +676,9 @@ async def create_topup(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
description=settings.get_balance_payment_description(request.amount_kopeks),
|
||||
description=settings.get_balance_payment_description(
|
||||
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
|
||||
),
|
||||
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
|
||||
)
|
||||
|
||||
@@ -686,7 +699,7 @@ async def create_topup(
|
||||
)
|
||||
|
||||
# Use payment_option to select sbp or card
|
||||
KASSA_AI_OPTION_MAP = {'sbp': 44, 'card': 36}
|
||||
KASSA_AI_OPTION_MAP = {'sbp': 44, 'card': 36, 'sberpay': 43}
|
||||
option = (request.payment_option or '').strip().lower()
|
||||
ps_id = KASSA_AI_OPTION_MAP.get(option) # None = use env default
|
||||
|
||||
@@ -695,7 +708,9 @@ async def create_topup(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
description=settings.get_balance_payment_description(request.amount_kopeks),
|
||||
description=settings.get_balance_payment_description(
|
||||
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
|
||||
),
|
||||
email=getattr(user, 'email', None),
|
||||
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
|
||||
payment_system_id=ps_id,
|
||||
@@ -722,7 +737,9 @@ async def create_topup(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
description=settings.get_balance_payment_description(request.amount_kopeks),
|
||||
description=settings.get_balance_payment_description(
|
||||
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
|
||||
),
|
||||
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
|
||||
success_url=cabinet_success_url,
|
||||
fail_url=cabinet_failed_url,
|
||||
@@ -748,6 +765,35 @@ async def create_topup(
|
||||
payment_url = f'{settings.TRIBUTE_DONATE_LINK}&user_id={user_identifier}'
|
||||
payment_id = f'tribute_{user_identifier}_{request.amount_kopeks}'
|
||||
|
||||
elif request.payment_method == 'severpay':
|
||||
if not settings.is_severpay_enabled():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='SeverPay payment method is unavailable',
|
||||
)
|
||||
|
||||
payment_service = PaymentService()
|
||||
result = await payment_service.create_severpay_payment(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
description=settings.get_balance_payment_description(
|
||||
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
|
||||
),
|
||||
email=getattr(user, 'email', None),
|
||||
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
|
||||
return_url=cabinet_success_url,
|
||||
)
|
||||
|
||||
if result and result.get('payment_url'):
|
||||
payment_url = result.get('payment_url')
|
||||
payment_id = str(result.get('local_payment_id') or result.get('order_id') or 'pending')
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to create SeverPay payment',
|
||||
)
|
||||
|
||||
else:
|
||||
# For other payment methods, redirect to bot
|
||||
raise HTTPException(
|
||||
|
||||
@@ -9,14 +9,28 @@ from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.contest import (
|
||||
create_attempt,
|
||||
get_active_rounds,
|
||||
get_attempt,
|
||||
increment_winner_count,
|
||||
)
|
||||
from app.database.crud.subscription import get_subscription_by_user_id
|
||||
from app.database.crud.subscription import get_active_subscriptions_by_user_id, get_subscription_by_user_id
|
||||
from app.database.models import SubscriptionStatus, User
|
||||
|
||||
|
||||
async def _resolve_subscription_for_prize(db, user_id: int):
|
||||
"""Resolve best subscription for applying contest prize (days/traffic)."""
|
||||
if settings.is_multi_tariff_enabled():
|
||||
active_subs = await get_active_subscriptions_by_user_id(db, user_id)
|
||||
# Prefer non-daily with most days left
|
||||
non_daily = [s for s in active_subs if not (s.tariff and getattr(s.tariff, 'is_daily', False))]
|
||||
eligible = non_daily or active_subs
|
||||
return max(eligible, key=lambda s: s.days_left) if eligible else None
|
||||
return await get_subscription_by_user_id(db, user_id)
|
||||
|
||||
|
||||
from app.services.contest_rotation_service import (
|
||||
GAME_ANAGRAM,
|
||||
GAME_BLITZ,
|
||||
@@ -98,7 +112,7 @@ async def _award_prize(db: AsyncSession, user_id: int, prize_type: str, prize_va
|
||||
except ValueError:
|
||||
return 'Error: invalid prize value'
|
||||
|
||||
subscription = await get_subscription_by_user_id(db, user_id)
|
||||
subscription = await _resolve_subscription_for_prize(db, user_id)
|
||||
if not subscription:
|
||||
return 'Error: subscription not found'
|
||||
|
||||
@@ -151,7 +165,7 @@ async def get_contests_count(
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get count of contests available for the user."""
|
||||
subscription = await get_subscription_by_user_id(db, user.id)
|
||||
subscription = await _resolve_subscription_for_prize(db, user.id)
|
||||
|
||||
if not _user_allowed(subscription):
|
||||
return ContestsCountResponse(count=0)
|
||||
@@ -183,7 +197,7 @@ async def get_contests(
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get list of available contests/games."""
|
||||
subscription = await get_subscription_by_user_id(db, user.id)
|
||||
subscription = await _resolve_subscription_for_prize(db, user.id)
|
||||
|
||||
if not _user_allowed(subscription):
|
||||
raise HTTPException(
|
||||
@@ -230,7 +244,7 @@ async def get_contest_game(
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get game data for a specific contest round."""
|
||||
subscription = await get_subscription_by_user_id(db, user.id)
|
||||
subscription = await _resolve_subscription_for_prize(db, user.id)
|
||||
|
||||
if not _user_allowed(subscription):
|
||||
raise HTTPException(
|
||||
@@ -350,7 +364,7 @@ async def submit_contest_answer(
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Submit answer for a contest round."""
|
||||
subscription = await get_subscription_by_user_id(db, user.id)
|
||||
subscription = await _resolve_subscription_for_prize(db, user.id)
|
||||
|
||||
if not _user_allowed(subscription):
|
||||
raise HTTPException(
|
||||
|
||||
@@ -425,8 +425,8 @@ async def create_gift_purchase(
|
||||
warning=recipient_warning,
|
||||
)
|
||||
|
||||
# Balance mode
|
||||
if user.balance_kopeks < price_kopeks:
|
||||
# Balance mode (skip for 100% discount)
|
||||
if price_kopeks > 0 and user.balance_kopeks < price_kopeks:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Insufficient balance',
|
||||
@@ -724,7 +724,7 @@ async def activate_gift_by_code(
|
||||
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail='Too many requests')
|
||||
|
||||
code = body.code.strip()
|
||||
if code.upper().startswith('GIFT-'):
|
||||
if code.upper().startswith('GIFT-') or code.upper().startswith('GIFT_'):
|
||||
code = code[5:]
|
||||
|
||||
if len(code) < 8:
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
"""Public news routes for cabinet - user-facing news/blog section."""
|
||||
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path, Query, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.crud.news import (
|
||||
get_news_article_by_slug,
|
||||
get_news_categories,
|
||||
get_published_news,
|
||||
get_published_news_count,
|
||||
increment_views,
|
||||
)
|
||||
from app.database.models import NewsArticle, User
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
from ..schemas.news import (
|
||||
NewsArticleListItem,
|
||||
NewsArticleResponse,
|
||||
NewsListResponse,
|
||||
)
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
# Slug constraint: alphanumeric, hyphens, underscores, max 500 chars
|
||||
_SLUG_MAX_LENGTH: int = 500
|
||||
_SLUG_PATTERN: str = r'^[a-zA-Z0-9_-]+$'
|
||||
|
||||
# --- View counter deduplication ---
|
||||
# In-memory TTL cache to prevent a single user from inflating view counts.
|
||||
# Key: (user_id, article_id), Value: timestamp of last counted view.
|
||||
# Views from the same user on the same article within _VIEW_DEDUP_SECONDS are ignored.
|
||||
_VIEW_DEDUP_SECONDS: int = 300 # 5 minutes
|
||||
_VIEW_DEDUP_MAX_SIZE: int = 10_000 # max entries before eviction
|
||||
_view_dedup_cache: dict[tuple[int, int], float] = {}
|
||||
|
||||
|
||||
def _should_count_view(user_id: int, article_id: int) -> bool:
|
||||
"""Return True if this view should be counted (not a duplicate within TTL)."""
|
||||
now = time.monotonic()
|
||||
key = (user_id, article_id)
|
||||
last_seen = _view_dedup_cache.get(key)
|
||||
|
||||
if last_seen is not None and (now - last_seen) < _VIEW_DEDUP_SECONDS:
|
||||
return False
|
||||
|
||||
# Evict stale entries if cache grows too large
|
||||
if len(_view_dedup_cache) >= _VIEW_DEDUP_MAX_SIZE:
|
||||
cutoff = now - _VIEW_DEDUP_SECONDS
|
||||
stale_keys = [k for k, v in _view_dedup_cache.items() if v < cutoff]
|
||||
for k in stale_keys:
|
||||
del _view_dedup_cache[k]
|
||||
|
||||
_view_dedup_cache[key] = now
|
||||
return True
|
||||
|
||||
|
||||
router = APIRouter(prefix='/news', tags=['Cabinet News'])
|
||||
|
||||
|
||||
def _article_to_response(article: NewsArticle, *, include_content: bool = True) -> dict[str, Any]:
|
||||
"""Convert NewsArticle ORM instance to response dict.
|
||||
|
||||
``author_name`` is only resolved when ``include_content=True`` (single-article
|
||||
detail view) because the author relationship is not eagerly loaded for list
|
||||
queries -- accessing it there would trigger a lazy-load or raise
|
||||
``MissingGreenlet`` in async context.
|
||||
"""
|
||||
data: dict[str, Any] = {
|
||||
'id': article.id,
|
||||
'title': article.title,
|
||||
'slug': article.slug,
|
||||
'excerpt': article.excerpt,
|
||||
'category': article.category,
|
||||
'category_color': article.category_color,
|
||||
'tag': article.tag,
|
||||
'featured_image_url': article.featured_image_url,
|
||||
'is_published': article.is_published,
|
||||
'is_featured': article.is_featured,
|
||||
'published_at': article.published_at,
|
||||
'read_time_minutes': article.read_time_minutes,
|
||||
'views_count': article.views_count,
|
||||
}
|
||||
|
||||
if include_content:
|
||||
author_name: str | None = None
|
||||
if article.author:
|
||||
author_name = article.author.first_name or article.author.username or f'#{article.author.id}'
|
||||
data['content'] = article.content
|
||||
data['author_name'] = author_name
|
||||
data['created_at'] = article.created_at
|
||||
data['updated_at'] = article.updated_at
|
||||
|
||||
return data
|
||||
|
||||
|
||||
# NOTE: /categories MUST be declared before /{slug} to avoid route conflict
|
||||
@router.get('/categories', response_model=list[str])
|
||||
async def list_categories(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> list[str]:
|
||||
"""Get list of distinct news categories."""
|
||||
try:
|
||||
return await get_news_categories(db)
|
||||
except Exception:
|
||||
logger.exception('Failed to get news categories')
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to load categories',
|
||||
)
|
||||
|
||||
|
||||
@router.get('', response_model=NewsListResponse)
|
||||
async def list_published_news(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
category: str | None = Query(None, max_length=100),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
offset: int = Query(0, ge=0),
|
||||
) -> NewsListResponse:
|
||||
"""Get paginated list of published news articles.
|
||||
|
||||
SQLAlchemy AsyncSession does not support concurrent operations, so
|
||||
queries run sequentially.
|
||||
"""
|
||||
try:
|
||||
articles = await get_published_news(db, category=category, limit=limit, offset=offset)
|
||||
total = await get_published_news_count(db, category=category)
|
||||
categories = await get_news_categories(db)
|
||||
|
||||
items = [NewsArticleListItem(**_article_to_response(a, include_content=False)) for a in articles]
|
||||
|
||||
return NewsListResponse(items=items, total=total, categories=categories)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception('Failed to list published news')
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to load news',
|
||||
)
|
||||
|
||||
|
||||
@router.get('/{slug}', response_model=NewsArticleResponse)
|
||||
async def get_article_by_slug(
|
||||
slug: str = Path(..., max_length=_SLUG_MAX_LENGTH, pattern=_SLUG_PATTERN),
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> NewsArticleResponse:
|
||||
"""Get a single published news article by slug. Increments view count."""
|
||||
article = await get_news_article_by_slug(db, slug)
|
||||
|
||||
if not article or not article.is_published:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Article not found',
|
||||
)
|
||||
|
||||
# Build response dict while session attributes are still loaded.
|
||||
# increment_views() calls db.commit() which expires all ORM attributes;
|
||||
# accessing them afterwards triggers lazy-load → MissingGreenlet in async.
|
||||
response_data = _article_to_response(article, include_content=True)
|
||||
|
||||
# Increment views with per-user deduplication (5-min TTL).
|
||||
if _should_count_view(user.id, article.id):
|
||||
try:
|
||||
new_count = await increment_views(db, article.id)
|
||||
response_data['views_count'] = new_count
|
||||
except Exception:
|
||||
logger.warning('Failed to increment views', article_id=article.id)
|
||||
|
||||
return NewsArticleResponse(**response_data)
|
||||
@@ -40,6 +40,8 @@ async def _finalize_oauth_login(
|
||||
provider: str,
|
||||
campaign_slug: str | None = None,
|
||||
referral_code: str | None = None,
|
||||
*,
|
||||
is_new_user: bool = False,
|
||||
) -> AuthResponse:
|
||||
"""Update last login, create tokens, store refresh token."""
|
||||
user.cabinet_last_login = datetime.now(UTC)
|
||||
@@ -47,10 +49,10 @@ async def _finalize_oauth_login(
|
||||
auth_response = await _create_auth_response(user, db)
|
||||
await _store_refresh_token(db, user.id, auth_response.refresh_token, device_info=f'oauth:{provider}')
|
||||
|
||||
# Process referral code (before campaign bonus, which may also set referrer)
|
||||
# Process referral code (only for new users — existing users cannot be assigned a referrer)
|
||||
from .auth import _process_referral_code, _user_to_response
|
||||
|
||||
await _process_referral_code(db, user, referral_code)
|
||||
await _process_referral_code(db, user, referral_code, is_new_user=is_new_user)
|
||||
|
||||
auth_response.campaign_bonus = await _process_campaign_bonus(db, user, campaign_slug)
|
||||
if auth_response.campaign_bonus:
|
||||
@@ -232,4 +234,19 @@ async def oauth_callback(
|
||||
referred_by_id=referrer_id,
|
||||
)
|
||||
logger.info('New OAuth user created', provider=provider, user_id=user.id)
|
||||
return await _finalize_oauth_login(db, user, provider, request.campaign_slug, request.referral_code)
|
||||
|
||||
# Commit user before panel sync (sync does its own commit/rollback)
|
||||
await db.commit()
|
||||
|
||||
# Sync existing panel subscriptions by email (if verified)
|
||||
if user_info.email and user_info.email_verified:
|
||||
try:
|
||||
from app.cabinet.routes.auth import _sync_subscription_from_panel_by_email
|
||||
|
||||
await _sync_subscription_from_panel_by_email(db, user)
|
||||
except Exception:
|
||||
logger.warning('Failed to sync panel subscription for new OAuth user', user_id=user.id, exc_info=True)
|
||||
|
||||
return await _finalize_oauth_login(
|
||||
db, user, provider, request.campaign_slug, request.referral_code, is_new_user=True
|
||||
)
|
||||
|
||||
@@ -309,7 +309,7 @@ async def claim_promo_offer(
|
||||
|
||||
# Handle test access offers
|
||||
if effect_type == 'test_access':
|
||||
await db.refresh(user, ['subscription'])
|
||||
await db.refresh(user, ['subscriptions'])
|
||||
success, newly_added, expires_at, error_code = await promo_offer_service.grant_test_access(
|
||||
db,
|
||||
user,
|
||||
|
||||
@@ -20,6 +20,7 @@ class PromocodeActivateRequest(BaseModel):
|
||||
"""Request to activate a promo code."""
|
||||
|
||||
code: str = Field(..., min_length=1, max_length=50, description='Promo code to activate')
|
||||
subscription_id: int | None = Field(None, description='Subscription ID for multi-tariff promo codes')
|
||||
|
||||
|
||||
class PromocodeActivateResponse(BaseModel):
|
||||
@@ -41,7 +42,7 @@ class PromocodeDeactivateResponse(BaseModel):
|
||||
discount_percent: int = 0
|
||||
|
||||
|
||||
@router.post('/activate', response_model=PromocodeActivateResponse)
|
||||
@router.post('/activate')
|
||||
async def activate_promocode(
|
||||
request: PromocodeActivateRequest,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
@@ -50,7 +51,17 @@ async def activate_promocode(
|
||||
"""Activate a promo code for the current user."""
|
||||
promocode_service = PromoCodeService()
|
||||
|
||||
result = await promocode_service.activate_promocode(db=db, user_id=user.id, code=request.code.strip())
|
||||
result = await promocode_service.activate_promocode(
|
||||
db=db, user_id=user.id, code=request.code.strip(), subscription_id=request.subscription_id
|
||||
)
|
||||
|
||||
if result.get('error') == 'select_subscription':
|
||||
return {
|
||||
'success': False,
|
||||
'error': 'select_subscription',
|
||||
'eligible_subscriptions': result.get('eligible_subscriptions', []),
|
||||
'code': result.get('code', request.code.strip()),
|
||||
}
|
||||
|
||||
if result['success']:
|
||||
balance_before_rubles = result.get('balance_before_kopeks', 0) / 100
|
||||
@@ -68,10 +79,13 @@ async def activate_promocode(
|
||||
error_messages = {
|
||||
'not_found': 'Promo code not found',
|
||||
'expired': 'Promo code has expired',
|
||||
'inactive': 'Promo code is deactivated',
|
||||
'not_yet_valid': 'Promo code is not yet active',
|
||||
'used': 'Promo code has been fully used',
|
||||
'already_used_by_user': 'You have already used this promo code',
|
||||
'active_discount_exists': 'You already have an active discount. Deactivate it first via /deactivate-discount',
|
||||
'no_subscription_for_days': 'This promo code requires an active or expired subscription',
|
||||
'subscription_not_found': 'Subscription not found',
|
||||
'not_first_purchase': 'This promo code is only available for first purchase',
|
||||
'daily_limit': 'Too many promo code activations today',
|
||||
'user_not_found': 'User not found',
|
||||
|
||||
@@ -119,7 +119,11 @@ async def get_referral_list(
|
||||
):
|
||||
"""Get list of invited users."""
|
||||
# Base query with eager loading of subscription relationship
|
||||
query = select(User).options(selectinload(User.subscription)).where(User.referred_by_id == user.id)
|
||||
query = (
|
||||
select(User)
|
||||
.options(selectinload(User.subscriptions).selectinload(Subscription.tariff))
|
||||
.where(User.referred_by_id == user.id)
|
||||
)
|
||||
|
||||
# Get total count
|
||||
count_query = select(func.count()).select_from(User).where(User.referred_by_id == user.id)
|
||||
@@ -139,7 +143,7 @@ async def get_referral_list(
|
||||
username=r.username,
|
||||
first_name=r.first_name,
|
||||
created_at=r.created_at,
|
||||
has_subscription=r.subscription is not None,
|
||||
has_subscription=bool(getattr(r, 'subscriptions', None)),
|
||||
has_paid=r.has_had_paid_subscription,
|
||||
)
|
||||
for r in referrals
|
||||
|
||||
+37
-4647
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,30 @@
|
||||
"""Subscription sub-modules for cabinet API.
|
||||
|
||||
Each module contains a subset of endpoints from the original monolithic subscription.py.
|
||||
The main subscription.py includes all sub-routers for backward compatibility.
|
||||
"""
|
||||
|
||||
from .autopay import router as autopay_router
|
||||
from .daily import router as daily_router
|
||||
from .devices import router as devices_router
|
||||
from .multi_tariff import router as multi_tariff_router
|
||||
from .purchase import router as purchase_router
|
||||
from .renewal import router as renewal_router
|
||||
from .servers import router as servers_router
|
||||
from .status import router as status_router
|
||||
from .tariff_switch import router as tariff_switch_router
|
||||
from .traffic import router as traffic_router
|
||||
|
||||
|
||||
__all__ = [
|
||||
'autopay_router',
|
||||
'daily_router',
|
||||
'devices_router',
|
||||
'multi_tariff_router',
|
||||
'purchase_router',
|
||||
'renewal_router',
|
||||
'servers_router',
|
||||
'status_router',
|
||||
'tariff_switch_router',
|
||||
'traffic_router',
|
||||
]
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Autopay settings endpoint.
|
||||
|
||||
PATCH /subscription/autopay
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import User
|
||||
|
||||
from ...dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
from ...schemas.subscription import AutopayUpdateRequest
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.patch('/autopay')
|
||||
async def update_autopay(
|
||||
request: AutopayUpdateRequest,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
subscription_id: int | None = Query(None, description='Subscription ID for multi-tariff'),
|
||||
):
|
||||
"""Update autopay settings."""
|
||||
from .helpers import resolve_subscription
|
||||
|
||||
subscription = await resolve_subscription(db, user, subscription_id)
|
||||
|
||||
if not subscription:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='No subscription found',
|
||||
)
|
||||
|
||||
if request.enabled:
|
||||
# Classic subscriptions cannot use autopay when tariff mode is enabled
|
||||
from app.config import settings
|
||||
|
||||
if settings.is_tariffs_mode() and not subscription.tariff_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Autopay is not available for classic subscriptions. Please purchase a tariff.',
|
||||
)
|
||||
|
||||
# Триальные подписки — пробник, автопродление не имеет смысла
|
||||
if subscription.is_trial:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Autopay is not available for trial subscriptions',
|
||||
)
|
||||
|
||||
# Суточные подписки имеют свой механизм продления (DailySubscriptionService),
|
||||
# глобальный autopay для них запрещён
|
||||
await db.refresh(subscription, ['tariff'])
|
||||
if subscription.tariff and getattr(subscription.tariff, 'is_daily', False):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Autopay is not available for daily subscriptions',
|
||||
)
|
||||
|
||||
subscription.autopay_enabled = request.enabled
|
||||
|
||||
if request.days_before is not None:
|
||||
subscription.autopay_days_before = request.days_before
|
||||
|
||||
await db.commit()
|
||||
|
||||
return {
|
||||
'message': 'Autopay settings updated',
|
||||
'autopay_enabled': subscription.autopay_enabled,
|
||||
'autopay_days_before': subscription.autopay_days_before,
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
"""Daily subscription management endpoints.
|
||||
|
||||
POST /subscription/pause
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query as QueryParam, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.tariff import get_tariff_by_id
|
||||
from app.database.models import User
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
|
||||
from ...dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
from .helpers import resolve_subscription
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post('/pause')
|
||||
async def toggle_subscription_pause(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
subscription_id: int | None = QueryParam(None, description='Subscription ID for multi-tariff'),
|
||||
) -> dict[str, Any]:
|
||||
"""Toggle pause/resume for daily subscription."""
|
||||
subscription = await resolve_subscription(db, user, subscription_id)
|
||||
|
||||
if not subscription:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='No subscription found',
|
||||
)
|
||||
|
||||
tariff_id = getattr(subscription, 'tariff_id', None)
|
||||
if not tariff_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Subscription has no tariff',
|
||||
)
|
||||
|
||||
tariff = await get_tariff_by_id(db, tariff_id)
|
||||
if not tariff or not getattr(tariff, 'is_daily', False):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Pause is only available for daily tariffs',
|
||||
)
|
||||
|
||||
# Determine current state
|
||||
from app.database.models import SubscriptionStatus
|
||||
|
||||
is_currently_paused = getattr(subscription, 'is_daily_paused', False)
|
||||
was_disabled = subscription.status in (
|
||||
SubscriptionStatus.DISABLED.value,
|
||||
SubscriptionStatus.EXPIRED.value,
|
||||
SubscriptionStatus.LIMITED.value,
|
||||
)
|
||||
|
||||
# System-DISABLED subs (insufficient balance) should always be treated as needing resume,
|
||||
# even if is_daily_paused is False (it's set by the system, not the user)
|
||||
if was_disabled and not is_currently_paused:
|
||||
new_paused_state = False # Force resume path
|
||||
else:
|
||||
new_paused_state = not is_currently_paused
|
||||
|
||||
raw_daily_price = getattr(tariff, 'daily_price_kopeks', 0)
|
||||
|
||||
# Lock user BEFORE discount computation to prevent TOCTOU on promo group
|
||||
# IMPORTANT: must happen BEFORE modifying subscription — lock_user_for_pricing
|
||||
# reloads subscriptions via selectinload which resets in-memory changes
|
||||
from app.database.crud.user import lock_user_for_pricing
|
||||
|
||||
user = await lock_user_for_pricing(db, user.id)
|
||||
|
||||
# Re-fetch subscription after lock (selectinload may have replaced the ORM object)
|
||||
subscription = await resolve_subscription(db, user, subscription_id)
|
||||
if not subscription:
|
||||
raise HTTPException(status_code=404, detail='Subscription not found after lock')
|
||||
|
||||
subscription.is_daily_paused = new_paused_state
|
||||
|
||||
# Apply group discount to daily price (consistent with DailySubscriptionService and miniapp resume)
|
||||
from app.services.pricing_engine import PricingEngine
|
||||
|
||||
promo_group = PricingEngine.resolve_promo_group(user)
|
||||
daily_group_pct = promo_group.get_discount_percent('period', 1) if promo_group else 0
|
||||
daily_price = (
|
||||
PricingEngine.apply_discount(raw_daily_price, daily_group_pct) if daily_group_pct > 0 else raw_daily_price
|
||||
)
|
||||
|
||||
# If resuming, check balance and charge
|
||||
if not new_paused_state:
|
||||
if daily_price > 0 and user.balance_kopeks < daily_price:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_402_PAYMENT_REQUIRED,
|
||||
detail={
|
||||
'code': 'insufficient_balance',
|
||||
'message': 'Insufficient balance to resume daily subscription',
|
||||
'required': daily_price,
|
||||
'balance': user.balance_kopeks,
|
||||
},
|
||||
)
|
||||
|
||||
# Charge daily fee FIRST, then restore ACTIVE status
|
||||
if was_disabled:
|
||||
if daily_price > 0:
|
||||
from app.database.crud.user import subtract_user_balance
|
||||
|
||||
deducted = await subtract_user_balance(
|
||||
db,
|
||||
user,
|
||||
daily_price,
|
||||
f'Суточная оплата тарифа «{tariff.name}» (возобновление)',
|
||||
mark_as_paid_subscription=True,
|
||||
)
|
||||
if not deducted:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_402_PAYMENT_REQUIRED,
|
||||
detail={
|
||||
'code': 'insufficient_balance',
|
||||
'message': 'Balance deduction failed',
|
||||
'required': daily_price,
|
||||
'balance': user.balance_kopeks,
|
||||
},
|
||||
)
|
||||
|
||||
from app.database.crud.transaction import create_transaction
|
||||
from app.database.models import TransactionType
|
||||
|
||||
try:
|
||||
await create_transaction(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
amount_kopeks=daily_price,
|
||||
description=f'Суточная оплата тарифа «{tariff.name}» (возобновление)',
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning('Failed to create resume transaction', error=exc)
|
||||
|
||||
# Balance deducted successfully — now activate
|
||||
subscription.status = SubscriptionStatus.ACTIVE.value
|
||||
subscription.last_daily_charge_at = datetime.now(UTC)
|
||||
subscription.end_date = datetime.now(UTC) + timedelta(days=1)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
await db.refresh(user)
|
||||
|
||||
# Sync with RemnaWave only when resuming from DISABLED state
|
||||
if not new_paused_state and was_disabled:
|
||||
try:
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.create_remnawave_user(
|
||||
db,
|
||||
subscription,
|
||||
reset_traffic=False,
|
||||
reset_reason=None,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error('Error syncing RemnaWave user on resume', error=e)
|
||||
from app.services.remnawave_retry_queue import remnawave_retry_queue
|
||||
|
||||
remnawave_retry_queue.enqueue(
|
||||
subscription_id=subscription.id,
|
||||
user_id=user.id,
|
||||
action='create',
|
||||
)
|
||||
|
||||
if new_paused_state:
|
||||
message = 'Daily subscription paused'
|
||||
else:
|
||||
message = 'Daily subscription resumed'
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'message': message,
|
||||
'is_paused': new_paused_state,
|
||||
'balance_kopeks': user.balance_kopeks,
|
||||
'balance_label': settings.format_price(user.balance_kopeks),
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,234 @@
|
||||
"""Shared helper functions for subscription modules."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import structlog
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import Subscription, User
|
||||
|
||||
from ...schemas.subscription import (
|
||||
ServerInfo,
|
||||
SubscriptionResponse,
|
||||
)
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def resolve_subscription(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
subscription_id: int | None,
|
||||
) -> Subscription | None:
|
||||
"""Resolve target subscription: by ID in multi-tariff mode, or legacy fallback.
|
||||
|
||||
Args:
|
||||
db: Database session.
|
||||
user: Current user.
|
||||
subscription_id: Optional subscription ID (from query param).
|
||||
|
||||
Returns:
|
||||
Target Subscription or None if not found.
|
||||
|
||||
Raises:
|
||||
HTTPException: If subscription_id provided but not found for this user.
|
||||
"""
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.database.crud.subscription import get_subscription_by_id_for_user
|
||||
|
||||
if subscription_id and settings.is_multi_tariff_enabled():
|
||||
subscription = await get_subscription_by_id_for_user(db, subscription_id, user.id)
|
||||
if not subscription:
|
||||
raise HTTPException(status_code=404, detail='Subscription not found')
|
||||
return subscription
|
||||
|
||||
if settings.is_multi_tariff_enabled() and not subscription_id:
|
||||
from app.database.crud.subscription import get_active_subscriptions_by_user_id
|
||||
|
||||
active_subs = await get_active_subscriptions_by_user_id(db, user.id)
|
||||
if active_subs:
|
||||
non_daily = [s for s in active_subs if not getattr(s, 'is_daily_tariff', False)]
|
||||
pool = non_daily or active_subs
|
||||
return max(pool, key=lambda s: s.days_left)
|
||||
return None
|
||||
|
||||
await db.refresh(user, ['subscriptions'])
|
||||
return user.subscription
|
||||
|
||||
|
||||
def _get_addon_discount_percent(
|
||||
user: User | None,
|
||||
category: str,
|
||||
period_days_hint: int | None = None,
|
||||
) -> int:
|
||||
"""Get addon discount percent for user — delegates to PricingEngine."""
|
||||
from app.services.pricing_engine import PricingEngine
|
||||
|
||||
return PricingEngine.get_addon_discount_percent(user, category, period_days_hint)
|
||||
|
||||
|
||||
def _apply_addon_discount(
|
||||
user: User,
|
||||
category: str,
|
||||
amount: int,
|
||||
period_days: int | None = None,
|
||||
) -> dict[str, int]:
|
||||
"""Apply addon discount to amount.
|
||||
|
||||
Returns dict with keys: discounted, discount, percent
|
||||
"""
|
||||
from app.utils.pricing_utils import apply_percentage_discount
|
||||
|
||||
percent = _get_addon_discount_percent(user, category, period_days)
|
||||
if percent <= 0 or amount <= 0:
|
||||
return {'discounted': amount, 'discount': 0, 'percent': 0}
|
||||
|
||||
discounted_amount, discount_value = apply_percentage_discount(amount, percent)
|
||||
return {
|
||||
'discounted': discounted_amount,
|
||||
'discount': discount_value,
|
||||
'percent': percent,
|
||||
}
|
||||
|
||||
|
||||
def _subscription_to_response(
|
||||
subscription: Subscription,
|
||||
servers: list[ServerInfo] | None = None,
|
||||
tariff_name: str | None = None,
|
||||
traffic_purchases: list[dict[str, Any]] | None = None,
|
||||
user: User | None = None,
|
||||
) -> SubscriptionResponse:
|
||||
"""Convert Subscription model to response."""
|
||||
now = datetime.now(UTC)
|
||||
|
||||
# Use actual_status property for correct status (same as bot uses)
|
||||
actual_status = subscription.actual_status
|
||||
is_expired = actual_status == 'expired'
|
||||
is_active = actual_status in ('active', 'trial')
|
||||
is_limited = actual_status == 'limited'
|
||||
|
||||
# Calculate time remaining
|
||||
days_left = 0
|
||||
hours_left = 0
|
||||
minutes_left = 0
|
||||
time_left_display = ''
|
||||
|
||||
if subscription.end_date and not is_expired:
|
||||
time_delta = subscription.end_date - now
|
||||
total_seconds = max(0, int(time_delta.total_seconds()))
|
||||
|
||||
days_left = total_seconds // 86400 # 86400 seconds in a day
|
||||
remaining_seconds = total_seconds % 86400
|
||||
hours_left = remaining_seconds // 3600
|
||||
minutes_left = (remaining_seconds % 3600) // 60
|
||||
|
||||
# Create human-readable display
|
||||
if days_left > 0:
|
||||
time_left_display = f'{days_left}d {hours_left}h'
|
||||
elif hours_left > 0:
|
||||
time_left_display = f'{hours_left}h {minutes_left}m'
|
||||
elif minutes_left > 0:
|
||||
time_left_display = f'{minutes_left}m'
|
||||
else:
|
||||
time_left_display = '0m'
|
||||
else:
|
||||
time_left_display = '0m'
|
||||
|
||||
traffic_limit_gb = subscription.traffic_limit_gb or 0
|
||||
traffic_used_gb = subscription.traffic_used_gb or 0.0
|
||||
|
||||
if traffic_limit_gb > 0:
|
||||
traffic_used_percent = min(100, (traffic_used_gb / traffic_limit_gb) * 100)
|
||||
else:
|
||||
traffic_used_percent = 0
|
||||
|
||||
# Check if this is a daily tariff
|
||||
is_daily_paused = getattr(subscription, 'is_daily_paused', False) or False
|
||||
tariff_id = getattr(subscription, 'tariff_id', None)
|
||||
|
||||
# Use subscription's is_daily_tariff property if available
|
||||
is_daily = False
|
||||
daily_price_kopeks = None
|
||||
|
||||
if hasattr(subscription, 'is_daily_tariff'):
|
||||
is_daily = subscription.is_daily_tariff
|
||||
elif tariff_id and hasattr(subscription, 'tariff') and subscription.tariff:
|
||||
is_daily = getattr(subscription.tariff, 'is_daily', False)
|
||||
|
||||
# Get daily_price_kopeks, tariff_name, traffic_reset_mode from tariff
|
||||
traffic_reset_mode = None
|
||||
if tariff_id and hasattr(subscription, 'tariff') and subscription.tariff:
|
||||
daily_price_kopeks = getattr(subscription.tariff, 'daily_price_kopeks', None)
|
||||
# Применяем скидку промогруппы + promo-offer для отображения
|
||||
if daily_price_kopeks and daily_price_kopeks > 0 and user:
|
||||
from app.services.pricing_engine import PricingEngine
|
||||
from app.utils.promo_offer import get_user_active_promo_discount_percent
|
||||
|
||||
_promo_group = user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else None
|
||||
_group_pct = _promo_group.get_discount_percent('period', 1) if _promo_group else 0
|
||||
_offer_pct = get_user_active_promo_discount_percent(user)
|
||||
if _group_pct > 0 or _offer_pct > 0:
|
||||
daily_price_kopeks, _, _ = PricingEngine.apply_stacked_discounts(
|
||||
daily_price_kopeks, _group_pct, _offer_pct
|
||||
)
|
||||
if not tariff_name: # Only set if not passed as parameter
|
||||
tariff_name = getattr(subscription.tariff, 'name', None)
|
||||
traffic_reset_mode = (
|
||||
getattr(subscription.tariff, 'traffic_reset_mode', None) or settings.DEFAULT_TRAFFIC_RESET_STRATEGY
|
||||
)
|
||||
|
||||
# Calculate next daily charge time (24 hours after last charge)
|
||||
next_daily_charge_at = None
|
||||
if is_daily and not is_daily_paused:
|
||||
last_charge = getattr(subscription, 'last_daily_charge_at', None)
|
||||
if last_charge:
|
||||
next_charge = last_charge + timedelta(days=1)
|
||||
# Если время списания уже прошло — не показываем (DailySubscriptionService обработает)
|
||||
if next_charge > datetime.now(UTC):
|
||||
next_daily_charge_at = next_charge
|
||||
|
||||
# Проверяем настройку скрытия ссылки (скрывается только текст, кнопки работают)
|
||||
hide_link = settings.should_hide_subscription_link()
|
||||
|
||||
return SubscriptionResponse(
|
||||
id=subscription.id,
|
||||
status=actual_status, # Use actual_status instead of raw status
|
||||
is_trial=subscription.is_trial or actual_status == 'trial',
|
||||
start_date=subscription.start_date,
|
||||
end_date=subscription.end_date,
|
||||
days_left=days_left,
|
||||
hours_left=hours_left,
|
||||
minutes_left=minutes_left,
|
||||
time_left_display=time_left_display,
|
||||
traffic_limit_gb=traffic_limit_gb,
|
||||
traffic_used_gb=round(traffic_used_gb, 2),
|
||||
traffic_used_percent=round(traffic_used_percent, 1),
|
||||
device_limit=subscription.device_limit or 0,
|
||||
connected_squads=subscription.connected_squads or [],
|
||||
servers=servers or [],
|
||||
autopay_enabled=subscription.autopay_enabled or False,
|
||||
autopay_days_before=subscription.autopay_days_before or 3,
|
||||
subscription_url=subscription.subscription_url,
|
||||
hide_subscription_link=hide_link,
|
||||
is_active=is_active,
|
||||
is_expired=is_expired,
|
||||
is_limited=is_limited,
|
||||
traffic_purchases=traffic_purchases or [],
|
||||
is_daily=is_daily,
|
||||
is_daily_paused=is_daily_paused,
|
||||
daily_price_kopeks=daily_price_kopeks,
|
||||
next_daily_charge_at=next_daily_charge_at,
|
||||
tariff_id=tariff_id,
|
||||
tariff_name=tariff_name,
|
||||
traffic_reset_mode=traffic_reset_mode,
|
||||
)
|
||||
@@ -0,0 +1,156 @@
|
||||
"""Multi-tariff subscription endpoints for cabinet API.
|
||||
|
||||
GET /subscriptions — list all user subscriptions (multi-tariff)
|
||||
GET /subscriptions/{id} — get specific subscription details
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.subscription import (
|
||||
decrement_subscription_server_counts,
|
||||
get_all_subscriptions_by_user_id,
|
||||
get_subscription_by_id_for_user,
|
||||
)
|
||||
from app.database.models import SubscriptionStatus, User
|
||||
|
||||
from ...dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/subscriptions', tags=['Cabinet Multi-Tariff'], redirect_slashes=False)
|
||||
|
||||
|
||||
class SubscriptionListItem(BaseModel):
|
||||
id: int
|
||||
status: str
|
||||
tariff_id: int | None = None
|
||||
tariff_name: str | None = None
|
||||
traffic_limit_gb: int = 0
|
||||
traffic_used_gb: float = 0.0
|
||||
device_limit: int = 1
|
||||
end_date: str | None = None
|
||||
subscription_url: str | None = None
|
||||
subscription_crypto_link: str | None = None
|
||||
is_trial: bool = False
|
||||
is_daily: bool = False
|
||||
is_daily_paused: bool = False
|
||||
autopay_enabled: bool = False
|
||||
connected_squads: list[str] | None = None
|
||||
|
||||
|
||||
class SubscriptionsListResponse(BaseModel):
|
||||
subscriptions: list[SubscriptionListItem]
|
||||
multi_tariff_enabled: bool
|
||||
|
||||
|
||||
def _subscription_to_list_item(sub) -> SubscriptionListItem:
|
||||
tariff_name = None
|
||||
if sub.tariff:
|
||||
tariff_name = sub.tariff.name
|
||||
|
||||
return SubscriptionListItem(
|
||||
id=sub.id,
|
||||
status=sub.actual_status,
|
||||
tariff_id=sub.tariff_id,
|
||||
tariff_name=tariff_name,
|
||||
traffic_limit_gb=sub.traffic_limit_gb or 0,
|
||||
traffic_used_gb=sub.traffic_used_gb or 0.0,
|
||||
device_limit=sub.device_limit or 1,
|
||||
end_date=sub.end_date.isoformat() if sub.end_date else None,
|
||||
subscription_url=sub.subscription_url,
|
||||
subscription_crypto_link=sub.subscription_crypto_link,
|
||||
is_trial=sub.is_trial or False,
|
||||
is_daily=bool(sub.tariff and getattr(sub.tariff, 'is_daily', False)),
|
||||
is_daily_paused=bool(getattr(sub, 'is_daily_paused', False)),
|
||||
autopay_enabled=sub.autopay_enabled or False,
|
||||
connected_squads=sub.connected_squads,
|
||||
)
|
||||
|
||||
|
||||
@router.get('', response_model=SubscriptionsListResponse)
|
||||
async def list_subscriptions(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> SubscriptionsListResponse:
|
||||
"""List all user subscriptions. Returns all subscriptions regardless of multi-tariff mode."""
|
||||
subscriptions = await get_all_subscriptions_by_user_id(db, user.id)
|
||||
items = [_subscription_to_list_item(sub) for sub in subscriptions]
|
||||
return SubscriptionsListResponse(
|
||||
subscriptions=items,
|
||||
multi_tariff_enabled=settings.is_multi_tariff_enabled(),
|
||||
)
|
||||
|
||||
|
||||
@router.get('/{subscription_id}', response_model=SubscriptionListItem)
|
||||
async def get_subscription_detail(
|
||||
subscription_id: int,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> SubscriptionListItem:
|
||||
"""Get specific subscription details with ownership check."""
|
||||
subscription = await get_subscription_by_id_for_user(db, subscription_id, user.id)
|
||||
if not subscription:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Subscription not found',
|
||||
)
|
||||
return _subscription_to_list_item(subscription)
|
||||
|
||||
|
||||
@router.delete('/{subscription_id}')
|
||||
async def delete_subscription(
|
||||
subscription_id: int,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> dict:
|
||||
"""Delete an expired/disabled subscription. Active subscriptions cannot be deleted."""
|
||||
subscription = await get_subscription_by_id_for_user(db, subscription_id, user.id)
|
||||
if not subscription:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Subscription not found',
|
||||
)
|
||||
|
||||
# Only expired/disabled subscriptions can be deleted
|
||||
deletable_statuses = {
|
||||
SubscriptionStatus.EXPIRED.value,
|
||||
SubscriptionStatus.DISABLED.value,
|
||||
}
|
||||
if getattr(subscription, 'actual_status', subscription.status) not in deletable_statuses:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Only expired or disabled subscriptions can be deleted',
|
||||
)
|
||||
|
||||
# Delete from RemnaWave panel (stops webhooks / phantom notifications)
|
||||
if subscription.remnawave_uuid:
|
||||
try:
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
|
||||
service = SubscriptionService()
|
||||
await service.delete_remnawave_user(subscription.remnawave_uuid)
|
||||
except Exception as e:
|
||||
logger.warning('Failed to delete RemnaWave user on subscription delete', error=e)
|
||||
|
||||
# Decrement server counts
|
||||
await decrement_subscription_server_counts(db, subscription)
|
||||
|
||||
# Delete the subscription
|
||||
await db.delete(subscription)
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
'Subscription deleted by user',
|
||||
subscription_id=subscription_id,
|
||||
user_id=user.id,
|
||||
tariff_id=subscription.tariff_id,
|
||||
)
|
||||
|
||||
return {'message': 'Subscription deleted'}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,269 @@
|
||||
"""Subscription renewal endpoints.
|
||||
|
||||
GET /subscription/renewal-options
|
||||
POST /subscription/renew
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.tariff import get_tariff_by_id
|
||||
from app.database.models import PaymentMethod, SubscriptionStatus, User
|
||||
from app.services.pricing_engine import pricing_engine
|
||||
from app.services.subscription_renewal_service import (
|
||||
SubscriptionRenewalChargeError,
|
||||
SubscriptionRenewalService,
|
||||
)
|
||||
from app.services.user_cart_service import user_cart_service
|
||||
|
||||
from ...dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
from ...schemas.subscription import (
|
||||
RenewalOptionResponse,
|
||||
RenewalRequest,
|
||||
)
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get('/renewal-options', response_model=list[RenewalOptionResponse])
|
||||
async def get_renewal_options(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
subscription_id: int | None = Query(None, description='Subscription ID for multi-tariff'),
|
||||
):
|
||||
"""Get available subscription renewal options with prices."""
|
||||
from .helpers import resolve_subscription
|
||||
|
||||
subscription = await resolve_subscription(db, user, subscription_id)
|
||||
if not subscription:
|
||||
return []
|
||||
|
||||
# Classic subscriptions cannot be renewed when tariff mode is enabled
|
||||
if settings.is_tariffs_mode() and not subscription.tariff_id:
|
||||
return []
|
||||
|
||||
_non_renewable = {SubscriptionStatus.DISABLED.value, SubscriptionStatus.PENDING.value}
|
||||
_actual_status = getattr(subscription, 'actual_status', subscription.status)
|
||||
if _actual_status in _non_renewable:
|
||||
return []
|
||||
|
||||
# Determine available periods
|
||||
if subscription.tariff_id and subscription.tariff and subscription.tariff.period_prices:
|
||||
periods = sorted(int(k) for k in subscription.tariff.period_prices.keys())
|
||||
else:
|
||||
periods = settings.get_available_renewal_periods()
|
||||
|
||||
options = []
|
||||
|
||||
for period in periods:
|
||||
pricing = await pricing_engine.calculate_renewal_price(db, subscription, period, user=user)
|
||||
|
||||
if pricing.final_total <= 0 and pricing.original_total <= 0:
|
||||
continue
|
||||
|
||||
original_price = pricing.original_total
|
||||
combined_discount = 0
|
||||
if original_price > 0 and original_price != pricing.final_total:
|
||||
combined_discount = int((original_price - pricing.final_total) * 100 / original_price)
|
||||
|
||||
options.append(
|
||||
RenewalOptionResponse(
|
||||
period_days=period,
|
||||
price_kopeks=pricing.final_total,
|
||||
price_rubles=pricing.final_total / 100,
|
||||
discount_percent=combined_discount,
|
||||
original_price_kopeks=original_price if combined_discount > 0 else None,
|
||||
)
|
||||
)
|
||||
|
||||
return options
|
||||
|
||||
|
||||
@router.post('/renew')
|
||||
async def renew_subscription(
|
||||
request: RenewalRequest,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
subscription_id: int | None = Query(None, description='Subscription ID for multi-tariff'),
|
||||
):
|
||||
"""Renew subscription (pay from balance)."""
|
||||
if getattr(user, 'restriction_subscription', False):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Subscription renewal is restricted for this account',
|
||||
)
|
||||
|
||||
# Support subscription_id from both query param and body (backward compat)
|
||||
from .helpers import resolve_subscription
|
||||
|
||||
_sub_id = subscription_id or request.subscription_id
|
||||
subscription = await resolve_subscription(db, user, _sub_id)
|
||||
if not subscription:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='No subscription found',
|
||||
)
|
||||
|
||||
# Classic subscriptions cannot be renewed when tariff mode is enabled
|
||||
if settings.is_tariffs_mode() and not subscription.tariff_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Classic subscriptions cannot be renewed. Please purchase a tariff.',
|
||||
)
|
||||
|
||||
_non_renewable = {SubscriptionStatus.DISABLED.value, SubscriptionStatus.PENDING.value}
|
||||
_actual_status = getattr(subscription, 'actual_status', subscription.status)
|
||||
if _actual_status in _non_renewable:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Cannot renew subscription with status: {_actual_status}',
|
||||
)
|
||||
|
||||
if subscription.tariff_id and subscription.tariff and subscription.tariff.period_prices:
|
||||
available_periods = [int(p) for p in subscription.tariff.period_prices.keys()]
|
||||
else:
|
||||
available_periods = settings.get_available_renewal_periods()
|
||||
|
||||
if request.period_days not in available_periods:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Selected renewal period is not available',
|
||||
)
|
||||
|
||||
# Lock user row to prevent TOCTOU on promo-offer state
|
||||
from app.database.crud.user import lock_user_for_pricing
|
||||
|
||||
user = await lock_user_for_pricing(db, user.id)
|
||||
|
||||
# Unified pricing via PricingEngine
|
||||
pricing = await pricing_engine.calculate_renewal_price(
|
||||
db,
|
||||
subscription,
|
||||
request.period_days,
|
||||
user=user,
|
||||
)
|
||||
price_kopeks = pricing.final_total
|
||||
promo_offer_discount_value = pricing.promo_offer_discount
|
||||
promo_offer_discount_percent = pricing.breakdown.get('offer_discount_pct', 0)
|
||||
|
||||
if price_kopeks <= 0 and pricing.original_total <= 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Invalid renewal period',
|
||||
)
|
||||
|
||||
original_price_kopeks = pricing.original_total
|
||||
discount_percent = 0
|
||||
if original_price_kopeks > 0 and original_price_kopeks != price_kopeks:
|
||||
discount_percent = int((original_price_kopeks - price_kopeks) * 100 / original_price_kopeks)
|
||||
|
||||
tariff = subscription.tariff if subscription.tariff_id else None
|
||||
|
||||
# Check balance (skip for 100% discount)
|
||||
if price_kopeks > 0 and user.balance_kopeks < price_kopeks:
|
||||
missing = price_kopeks - user.balance_kopeks
|
||||
|
||||
# Get tariff info for cart
|
||||
tariff_id = subscription.tariff_id
|
||||
tariff_name = None
|
||||
tariff_traffic_limit_gb = None
|
||||
tariff_allowed_squads = None
|
||||
|
||||
if tariff_id:
|
||||
tariff = await get_tariff_by_id(db, tariff_id)
|
||||
if tariff:
|
||||
tariff_name = tariff.name
|
||||
tariff_traffic_limit_gb = tariff.traffic_limit_gb
|
||||
tariff_allowed_squads = tariff.allowed_squads or []
|
||||
|
||||
# Save cart for auto-purchase after balance top-up
|
||||
cart_data: dict[str, Any] = {
|
||||
'cart_mode': 'extend',
|
||||
'subscription_id': subscription.id,
|
||||
'tariff_id': tariff_id,
|
||||
'period_days': request.period_days,
|
||||
'total_price': price_kopeks,
|
||||
'user_id': user.id,
|
||||
'saved_cart': True,
|
||||
'missing_amount': missing,
|
||||
'return_to_cart': True,
|
||||
'description': f'Продление подписки на {request.period_days} дней'
|
||||
+ (f' ({tariff_name})' if tariff_name else ''),
|
||||
'discount_percent': discount_percent,
|
||||
'consume_promo_offer': promo_offer_discount_value > 0,
|
||||
'source': 'cabinet',
|
||||
}
|
||||
|
||||
# Add subscription parameters for auto-purchase
|
||||
if tariff_id:
|
||||
cart_data['traffic_limit_gb'] = tariff_traffic_limit_gb
|
||||
# Сохраняем актуальный device_limit подписки (включая докупленные устройства)
|
||||
cart_data['device_limit'] = subscription.device_limit
|
||||
cart_data['allowed_squads'] = tariff_allowed_squads
|
||||
else:
|
||||
# Classic mode: сохраняем текущие параметры подписки для корректной автопокупки
|
||||
cart_data['device_limit'] = subscription.device_limit
|
||||
cart_data['traffic_limit_gb'] = subscription.traffic_limit_gb
|
||||
|
||||
try:
|
||||
await user_cart_service.save_user_cart(user.id, cart_data)
|
||||
logger.info('Cart saved for auto-renewal (cabinet) user', user_id=user.id)
|
||||
except Exception as e:
|
||||
logger.error('Error saving cart for auto-renewal (cabinet)', error=e)
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_402_PAYMENT_REQUIRED,
|
||||
detail={
|
||||
'code': 'insufficient_funds',
|
||||
'message': f'Недостаточно средств. Не хватает {settings.format_price(missing)}',
|
||||
'missing_amount': missing,
|
||||
'cart_saved': True,
|
||||
'cart_mode': 'extend',
|
||||
},
|
||||
)
|
||||
|
||||
# Centralized renewal: balance deduction, extension, RemnaWave sync, admin notification,
|
||||
# server price recording, and compensating refund on failure.
|
||||
renewal_description = f'Продление подписки на {request.period_days} дней' + (f' ({tariff.name})' if tariff else '')
|
||||
renewal_service = SubscriptionRenewalService()
|
||||
|
||||
try:
|
||||
result = await renewal_service.finalize(
|
||||
db,
|
||||
user,
|
||||
subscription,
|
||||
pricing,
|
||||
description=renewal_description,
|
||||
payment_method=PaymentMethod.BALANCE,
|
||||
)
|
||||
except SubscriptionRenewalChargeError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_402_PAYMENT_REQUIRED,
|
||||
detail={
|
||||
'code': 'insufficient_funds',
|
||||
'message': 'Недостаточно средств (concurrent check)',
|
||||
},
|
||||
)
|
||||
|
||||
response: dict[str, Any] = {
|
||||
'message': 'Subscription renewed successfully',
|
||||
'new_end_date': result.subscription.end_date.isoformat(),
|
||||
'amount_paid_kopeks': price_kopeks,
|
||||
}
|
||||
|
||||
# Add discount info to response
|
||||
if promo_offer_discount_value > 0:
|
||||
response['promo_discount_percent'] = promo_offer_discount_percent
|
||||
response['promo_discount_amount_kopeks'] = promo_offer_discount_value
|
||||
response['original_price_kopeks'] = original_price_kopeks
|
||||
|
||||
return response
|
||||
@@ -0,0 +1,268 @@
|
||||
"""Server/country management endpoints.
|
||||
|
||||
GET /subscription/countries
|
||||
POST /subscription/countries
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query as QueryParam, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import User
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
|
||||
from ...dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
from .helpers import resolve_subscription
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get('/countries')
|
||||
async def get_available_countries(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
subscription_id: int | None = QueryParam(None, description='Subscription ID for multi-tariff'),
|
||||
) -> dict[str, Any]:
|
||||
"""Get available countries/servers for the user."""
|
||||
from app.database.crud.server_squad import get_available_server_squads
|
||||
from app.utils.pricing_utils import apply_percentage_discount, calculate_prorated_price
|
||||
|
||||
subscription = await resolve_subscription(db, user, subscription_id)
|
||||
|
||||
promo_group_id = user.promo_group_id
|
||||
available_servers = await get_available_server_squads(db, promo_group_id=promo_group_id)
|
||||
|
||||
connected_squads = []
|
||||
days_left = 0
|
||||
if subscription:
|
||||
connected_squads = subscription.connected_squads or []
|
||||
if subscription.end_date:
|
||||
delta = subscription.end_date - datetime.now(UTC)
|
||||
days_left = max(0, delta.days)
|
||||
|
||||
# Get discount from promo group via PricingEngine (respects apply_discounts_to_addons flag)
|
||||
from app.services.pricing_engine import PricingEngine
|
||||
|
||||
servers_discount_percent = PricingEngine.get_addon_discount_percent(user, 'servers', None)
|
||||
|
||||
countries = []
|
||||
for server in available_servers:
|
||||
base_price = server.price_kopeks
|
||||
|
||||
# Apply discount
|
||||
if servers_discount_percent > 0:
|
||||
discounted_price, _ = apply_percentage_discount(base_price, servers_discount_percent)
|
||||
else:
|
||||
discounted_price = base_price
|
||||
|
||||
# Calculate prorated price if subscription exists
|
||||
prorated_price = discounted_price
|
||||
if subscription and subscription.end_date:
|
||||
prorated_price, _ = calculate_prorated_price(
|
||||
discounted_price,
|
||||
subscription.end_date,
|
||||
)
|
||||
|
||||
countries.append(
|
||||
{
|
||||
'uuid': server.squad_uuid,
|
||||
'name': server.display_name,
|
||||
'country_code': server.country_code,
|
||||
'base_price_kopeks': base_price,
|
||||
'price_kopeks': prorated_price, # Prorated price with discount
|
||||
'price_per_month_kopeks': discounted_price, # Monthly price with discount
|
||||
'price_rubles': prorated_price / 100,
|
||||
'is_available': server.is_available and not server.is_full,
|
||||
'is_connected': server.squad_uuid in connected_squads,
|
||||
'has_discount': servers_discount_percent > 0,
|
||||
'discount_percent': servers_discount_percent,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
'countries': countries,
|
||||
'connected_count': len(connected_squads),
|
||||
'has_subscription': subscription is not None,
|
||||
'days_left': days_left,
|
||||
'discount_percent': servers_discount_percent,
|
||||
}
|
||||
|
||||
|
||||
@router.post('/countries')
|
||||
async def update_countries(
|
||||
request: dict[str, Any],
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
subscription_id: int | None = QueryParam(None, description='Subscription ID for multi-tariff'),
|
||||
) -> dict[str, Any]:
|
||||
"""Update subscription countries/servers."""
|
||||
from app.database.crud.server_squad import add_user_to_servers, get_available_server_squads, get_server_ids_by_uuids
|
||||
from app.database.crud.subscription import add_subscription_servers
|
||||
from app.database.crud.transaction import create_transaction
|
||||
from app.database.crud.user import subtract_user_balance
|
||||
from app.database.models import TransactionType
|
||||
from app.utils.pricing_utils import apply_percentage_discount, calculate_prorated_price
|
||||
|
||||
subscription = await resolve_subscription(db, user, subscription_id)
|
||||
|
||||
if not subscription:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='No subscription found',
|
||||
)
|
||||
|
||||
if subscription.is_trial:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Country management is not available for trial subscriptions',
|
||||
)
|
||||
|
||||
selected_countries = request.get('countries', [])
|
||||
if not selected_countries:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='At least one country must be selected',
|
||||
)
|
||||
|
||||
current_countries = subscription.connected_squads or []
|
||||
promo_group_id = user.promo_group_id
|
||||
|
||||
available_servers = await get_available_server_squads(db, promo_group_id=promo_group_id)
|
||||
allowed_country_ids = {server.squad_uuid for server in available_servers}
|
||||
|
||||
# Validate selected countries
|
||||
for country_uuid in selected_countries:
|
||||
if country_uuid not in allowed_country_ids:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Country {country_uuid} is not available',
|
||||
)
|
||||
|
||||
added = [c for c in selected_countries if c not in current_countries]
|
||||
removed = [c for c in current_countries if c not in selected_countries]
|
||||
|
||||
if not added and not removed:
|
||||
return {
|
||||
'message': 'No changes detected',
|
||||
'connected_squads': current_countries,
|
||||
}
|
||||
|
||||
# Lock user row to prevent TOCTOU on promo-offer state
|
||||
from app.database.crud.user import lock_user_for_pricing
|
||||
|
||||
user = await lock_user_for_pricing(db, user.id)
|
||||
|
||||
# Calculate cost for added servers
|
||||
total_cost = 0
|
||||
added_names = []
|
||||
removed_names = []
|
||||
|
||||
from app.services.pricing_engine import PricingEngine
|
||||
|
||||
servers_discount_percent = PricingEngine.get_addon_discount_percent(user, 'servers', None)
|
||||
|
||||
added_server_prices = []
|
||||
|
||||
for server in available_servers:
|
||||
if server.squad_uuid in added:
|
||||
server_price_per_month = server.price_kopeks
|
||||
if servers_discount_percent > 0:
|
||||
discounted_per_month, _ = apply_percentage_discount(
|
||||
server_price_per_month,
|
||||
servers_discount_percent,
|
||||
)
|
||||
else:
|
||||
discounted_per_month = server_price_per_month
|
||||
|
||||
charged_price, charged_days = calculate_prorated_price(
|
||||
discounted_per_month,
|
||||
subscription.end_date,
|
||||
)
|
||||
|
||||
total_cost += charged_price
|
||||
added_names.append(server.display_name)
|
||||
added_server_prices.append(charged_price)
|
||||
|
||||
if server.squad_uuid in removed:
|
||||
removed_names.append(server.display_name)
|
||||
|
||||
# Check balance
|
||||
if total_cost > 0 and user.balance_kopeks < total_cost:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_402_PAYMENT_REQUIRED,
|
||||
detail=f'Insufficient balance. Need {total_cost / 100:.2f} RUB, have {user.balance_kopeks / 100:.2f} RUB',
|
||||
)
|
||||
|
||||
# Deduct balance and update subscription
|
||||
if added and total_cost > 0:
|
||||
success = await subtract_user_balance(db, user, total_cost, f'Adding countries: {", ".join(added_names)}')
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to charge balance',
|
||||
)
|
||||
|
||||
await create_transaction(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
amount_kopeks=total_cost,
|
||||
description=f'Adding countries to subscription: {", ".join(added_names)}',
|
||||
)
|
||||
|
||||
# Add servers to subscription
|
||||
if added:
|
||||
added_server_ids = await get_server_ids_by_uuids(db, added)
|
||||
if added_server_ids:
|
||||
await add_subscription_servers(db, subscription, added_server_ids, added_server_prices)
|
||||
try:
|
||||
await add_user_to_servers(db, added_server_ids)
|
||||
except Exception as e:
|
||||
logger.error('Ошибка обновления счётчика серверов', error=e)
|
||||
|
||||
# Update connected squads
|
||||
subscription.connected_squads = selected_countries
|
||||
subscription.updated_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
|
||||
# Sync with RemnaWave
|
||||
try:
|
||||
from app.config import settings
|
||||
|
||||
subscription_service = SubscriptionService()
|
||||
_has_panel = (
|
||||
getattr(subscription, 'remnawave_uuid', None)
|
||||
if settings.is_multi_tariff_enabled()
|
||||
else getattr(user, 'remnawave_uuid', None)
|
||||
)
|
||||
if _has_panel:
|
||||
await subscription_service.update_remnawave_user(db, subscription, sync_squads=True)
|
||||
else:
|
||||
await subscription_service.create_remnawave_user(db, subscription)
|
||||
except Exception as e:
|
||||
logger.error('Failed to sync countries with RemnaWave', error=e)
|
||||
from app.services.remnawave_retry_queue import remnawave_retry_queue
|
||||
|
||||
remnawave_retry_queue.enqueue(
|
||||
subscription_id=subscription.id,
|
||||
user_id=user.id,
|
||||
action='update' if _has_panel else 'create',
|
||||
)
|
||||
|
||||
await db.refresh(subscription)
|
||||
|
||||
return {
|
||||
'message': 'Countries updated successfully',
|
||||
'added': added_names,
|
||||
'removed': removed_names,
|
||||
'amount_paid_kopeks': total_cost,
|
||||
'connected_squads': subscription.connected_squads,
|
||||
}
|
||||
@@ -0,0 +1,526 @@
|
||||
"""Subscription status endpoints.
|
||||
|
||||
GET /subscription — subscription info
|
||||
GET /subscription/connection-link
|
||||
GET /subscription/happ-downloads
|
||||
GET /subscription/app-config
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import re
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.tariff import get_tariff_by_id
|
||||
from app.database.models import ServerSquad, User
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
from app.services.system_settings_service import bot_configuration_service
|
||||
|
||||
from ...dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
from ...schemas.subscription import (
|
||||
ServerInfo,
|
||||
SubscriptionStatusResponse,
|
||||
)
|
||||
from .helpers import _subscription_to_response, resolve_subscription
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get('/info', response_model=SubscriptionStatusResponse)
|
||||
async def get_subscription(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
subscription_id: int | None = Query(None, description='Subscription ID for multi-tariff'),
|
||||
):
|
||||
"""Get current user's subscription details."""
|
||||
# Reload user from current session to get fresh data
|
||||
# (user object is from different session in get_current_cabinet_user)
|
||||
from app.database.crud.user import get_user_by_id
|
||||
|
||||
fresh_user = await get_user_by_id(db, user.id)
|
||||
|
||||
if not fresh_user:
|
||||
return SubscriptionStatusResponse(has_subscription=False, subscription=None)
|
||||
|
||||
subscription = await resolve_subscription(db, fresh_user, subscription_id)
|
||||
|
||||
if not subscription:
|
||||
# Return 200 with has_subscription: false instead of 404
|
||||
return SubscriptionStatusResponse(has_subscription=False, subscription=None)
|
||||
|
||||
# Load tariff for daily subscription check and tariff name
|
||||
tariff_name = None
|
||||
if subscription.tariff_id:
|
||||
tariff = await get_tariff_by_id(db, subscription.tariff_id)
|
||||
if tariff:
|
||||
subscription.tariff = tariff
|
||||
tariff_name = tariff.name
|
||||
|
||||
# Fetch server names for connected squads
|
||||
servers: list[ServerInfo] = []
|
||||
connected_squads = subscription.connected_squads or []
|
||||
if connected_squads:
|
||||
result = await db.execute(select(ServerSquad).where(ServerSquad.squad_uuid.in_(connected_squads)))
|
||||
server_squads = result.scalars().all()
|
||||
servers = [
|
||||
ServerInfo(uuid=sq.squad_uuid, name=sq.display_name, country_code=sq.country_code) for sq in server_squads
|
||||
]
|
||||
|
||||
# Fetch traffic purchases (monthly packages)
|
||||
traffic_purchases_data = []
|
||||
from app.database.models import TrafficPurchase
|
||||
|
||||
now = datetime.now(UTC)
|
||||
purchases_query = (
|
||||
select(TrafficPurchase)
|
||||
.where(TrafficPurchase.subscription_id == subscription.id)
|
||||
.where(TrafficPurchase.expires_at > now)
|
||||
.order_by(TrafficPurchase.expires_at.asc())
|
||||
)
|
||||
purchases_result = await db.execute(purchases_query)
|
||||
purchases = purchases_result.scalars().all()
|
||||
|
||||
for purchase in purchases:
|
||||
time_remaining = purchase.expires_at - now
|
||||
days_remaining = max(0, int(time_remaining.total_seconds() / 86400))
|
||||
total_duration_seconds = (purchase.expires_at - purchase.created_at).total_seconds()
|
||||
elapsed_seconds = (now - purchase.created_at).total_seconds()
|
||||
progress_percent = min(
|
||||
100.0, max(0.0, (elapsed_seconds / total_duration_seconds * 100) if total_duration_seconds > 0 else 0)
|
||||
)
|
||||
|
||||
traffic_purchases_data.append(
|
||||
{
|
||||
'id': purchase.id,
|
||||
'traffic_gb': purchase.traffic_gb,
|
||||
'expires_at': purchase.expires_at,
|
||||
'created_at': purchase.created_at,
|
||||
'days_remaining': days_remaining,
|
||||
'progress_percent': round(progress_percent, 1),
|
||||
}
|
||||
)
|
||||
|
||||
subscription_data = _subscription_to_response(
|
||||
subscription, servers, tariff_name, traffic_purchases_data, user=fresh_user
|
||||
)
|
||||
return SubscriptionStatusResponse(has_subscription=True, subscription=subscription_data)
|
||||
|
||||
|
||||
# ============ Connection Link ============
|
||||
|
||||
|
||||
@router.get('/connection-link')
|
||||
async def get_connection_link(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
subscription_id: int | None = Query(None, description='Subscription ID for multi-tariff'),
|
||||
) -> dict[str, Any]:
|
||||
"""Get subscription connection link and instructions."""
|
||||
from app.utils.subscription_utils import (
|
||||
convert_subscription_link_to_happ_scheme,
|
||||
get_display_subscription_link,
|
||||
get_happ_cryptolink_redirect_link,
|
||||
)
|
||||
|
||||
subscription = await resolve_subscription(db, user, subscription_id)
|
||||
|
||||
if not subscription:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='No subscription found',
|
||||
)
|
||||
|
||||
subscription_url = subscription.subscription_url
|
||||
if not subscription_url:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Subscription link not yet generated',
|
||||
)
|
||||
|
||||
display_link = get_display_subscription_link(subscription)
|
||||
happ_redirect = get_happ_cryptolink_redirect_link(subscription_url) if settings.is_happ_cryptolink_mode() else None
|
||||
happ_scheme_link = (
|
||||
convert_subscription_link_to_happ_scheme(subscription_url) if settings.is_happ_cryptolink_mode() else None
|
||||
)
|
||||
|
||||
connect_mode = settings.CONNECT_BUTTON_MODE
|
||||
hide_subscription_link = settings.should_hide_subscription_link()
|
||||
|
||||
return {
|
||||
'subscription_url': subscription_url if not hide_subscription_link else None,
|
||||
'display_link': display_link if not hide_subscription_link else None,
|
||||
'happ_redirect_link': happ_redirect,
|
||||
'happ_scheme_link': happ_scheme_link,
|
||||
'connect_mode': connect_mode,
|
||||
'hide_link': hide_subscription_link,
|
||||
'instructions': {
|
||||
'steps': [
|
||||
'Copy the subscription link',
|
||||
'Open your VPN application',
|
||||
"Find 'Add subscription' or 'Import' option",
|
||||
'Paste the copied link',
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ============ hApp Downloads ============
|
||||
|
||||
|
||||
@router.get('/happ-downloads')
|
||||
async def get_happ_downloads(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
) -> dict[str, Any]:
|
||||
"""Get hApp download links for different platforms."""
|
||||
platforms = {
|
||||
'ios': {
|
||||
'name': 'iOS (iPhone/iPad)',
|
||||
'icon': '🍎',
|
||||
'link': settings.get_happ_download_link('ios'),
|
||||
},
|
||||
'android': {
|
||||
'name': 'Android',
|
||||
'icon': '🤖',
|
||||
'link': settings.get_happ_download_link('android'),
|
||||
},
|
||||
'macos': {
|
||||
'name': 'macOS',
|
||||
'icon': '🖥️',
|
||||
'link': settings.get_happ_download_link('macos'),
|
||||
},
|
||||
'windows': {
|
||||
'name': 'Windows',
|
||||
'icon': '💻',
|
||||
'link': settings.get_happ_download_link('windows'),
|
||||
},
|
||||
}
|
||||
|
||||
# Filter out platforms without links
|
||||
available_platforms = {k: v for k, v in platforms.items() if v['link']}
|
||||
|
||||
return {
|
||||
'platforms': available_platforms,
|
||||
'happ_enabled': bool(available_platforms),
|
||||
}
|
||||
|
||||
|
||||
# ============ App Config for Connection ============
|
||||
|
||||
|
||||
def _get_remnawave_config_uuid() -> str | None:
|
||||
"""Get RemnaWave config UUID from system settings or env."""
|
||||
try:
|
||||
return bot_configuration_service.get_current_value('CABINET_REMNA_SUB_CONFIG')
|
||||
except Exception:
|
||||
return settings.CABINET_REMNA_SUB_CONFIG
|
||||
|
||||
|
||||
def _extract_scheme_from_buttons(buttons: list[dict[str, Any]]) -> tuple[str, bool]:
|
||||
"""Extract URL scheme from buttons list.
|
||||
|
||||
Returns:
|
||||
Tuple of (scheme, uses_crypto_link).
|
||||
uses_crypto_link=True when the template is {{HAPP_CRYPT4_LINK}},
|
||||
meaning subscription_crypto_link should be used as payload.
|
||||
"""
|
||||
for btn in buttons:
|
||||
if not isinstance(btn, dict):
|
||||
continue
|
||||
link = btn.get('link', '') or btn.get('url', '') or btn.get('buttonLink', '')
|
||||
if not link:
|
||||
continue
|
||||
link_upper = link.upper()
|
||||
|
||||
# Check for {{HAPP_CRYPT4_LINK}} -- uses crypto link as payload
|
||||
if '{{HAPP_CRYPT4_LINK}}' in link_upper or 'HAPP_CRYPT4_LINK' in link_upper:
|
||||
scheme = re.sub(r'\{\{HAPP_CRYPT4_LINK\}\}', '', link, flags=re.IGNORECASE)
|
||||
if scheme and '://' in scheme:
|
||||
return scheme, True
|
||||
|
||||
# Check for {{SUBSCRIPTION_LINK}} -- uses plain subscription_url as payload
|
||||
if '{{SUBSCRIPTION_LINK}}' in link_upper or 'SUBSCRIPTION_LINK' in link_upper:
|
||||
scheme = re.sub(r'\{\{SUBSCRIPTION_LINK\}\}', '', link, flags=re.IGNORECASE)
|
||||
if scheme and '://' in scheme:
|
||||
return scheme, False
|
||||
|
||||
# Also check for type="subscriptionLink" buttons with custom schemes
|
||||
btn_type = btn.get('type', '')
|
||||
if btn_type == 'subscriptionLink' and '://' in link and not link.startswith('http'):
|
||||
scheme = link.split('{{')[0] if '{{' in link else link
|
||||
if scheme and '://' in scheme:
|
||||
return scheme, False
|
||||
return '', False
|
||||
|
||||
|
||||
def _get_url_scheme_for_app(app: dict[str, Any]) -> tuple[str, bool]:
|
||||
"""Get URL scheme for app - from config, buttons, or fallback by name.
|
||||
|
||||
Returns:
|
||||
Tuple of (scheme, uses_crypto_link).
|
||||
uses_crypto_link=True means the app template uses {{HAPP_CRYPT4_LINK}},
|
||||
so subscription_crypto_link should be used as the deep link payload.
|
||||
"""
|
||||
# 1. Check urlScheme field (cabinet format stores usesCryptoLink alongside)
|
||||
scheme = str(app.get('urlScheme', '')).strip()
|
||||
if scheme:
|
||||
uses_crypto = bool(app.get('usesCryptoLink', False))
|
||||
return scheme, uses_crypto
|
||||
|
||||
# 2. Extract from buttons in blocks (RemnaWave format)
|
||||
blocks = app.get('blocks', [])
|
||||
for block in blocks:
|
||||
if not isinstance(block, dict):
|
||||
continue
|
||||
buttons = block.get('buttons', [])
|
||||
scheme, uses_crypto = _extract_scheme_from_buttons(buttons)
|
||||
if scheme:
|
||||
return scheme, uses_crypto
|
||||
|
||||
# 3. Check buttons directly in app (alternative structure)
|
||||
direct_buttons = app.get('buttons', [])
|
||||
if direct_buttons:
|
||||
scheme, uses_crypto = _extract_scheme_from_buttons(direct_buttons)
|
||||
if scheme:
|
||||
return scheme, uses_crypto
|
||||
|
||||
# No scheme found
|
||||
logger.debug(
|
||||
'_get_url_scheme_for_app: No scheme found for app has blocks: has buttons: has urlScheme',
|
||||
get=app.get('name'),
|
||||
get_2=bool(app.get('blocks')),
|
||||
get_3=bool(app.get('buttons')),
|
||||
get_4=bool(app.get('urlScheme')),
|
||||
)
|
||||
return '', False
|
||||
|
||||
|
||||
async def _load_app_config_async() -> dict[str, Any] | None:
|
||||
"""Load app config from RemnaWave API (if configured).
|
||||
|
||||
Returns None when no Remnawave config is set or API fails.
|
||||
"""
|
||||
remnawave_uuid = _get_remnawave_config_uuid()
|
||||
|
||||
if remnawave_uuid:
|
||||
try:
|
||||
service = RemnaWaveService()
|
||||
async with service.get_api_client() as api:
|
||||
config = await api.get_subscription_page_config(remnawave_uuid)
|
||||
if config and config.config:
|
||||
logger.debug('Loaded app config from RemnaWave', remnawave_uuid=remnawave_uuid)
|
||||
raw = dict(config.config)
|
||||
raw['_isRemnawave'] = True
|
||||
return raw
|
||||
except Exception as e:
|
||||
logger.warning('Failed to load RemnaWave config', error=e)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _create_deep_link(
|
||||
app: dict[str, Any], subscription_url: str, subscription_crypto_link: str | None = None
|
||||
) -> str | None:
|
||||
"""Create deep link for app with subscription URL.
|
||||
|
||||
Uses urlScheme from RemnaWave config (e.g. "happ://add/", "v2rayng://install-config?url=")
|
||||
combined with the appropriate payload URL.
|
||||
|
||||
Two Happ schemes exist in RemnaWave:
|
||||
- happ://add/{{SUBSCRIPTION_LINK}} -> uses plain subscription_url
|
||||
- happ://crypt4/{{HAPP_CRYPT4_LINK}} -> uses subscription_crypto_link
|
||||
"""
|
||||
if not isinstance(app, dict):
|
||||
return None
|
||||
|
||||
if not subscription_url and not subscription_crypto_link:
|
||||
return None
|
||||
|
||||
scheme, uses_crypto = _get_url_scheme_for_app(app)
|
||||
if not scheme:
|
||||
logger.debug('_create_deep_link: no urlScheme for app', get=app.get('name', 'unknown'))
|
||||
return None
|
||||
|
||||
# Pick the correct payload based on which template the app uses
|
||||
if uses_crypto:
|
||||
if not subscription_crypto_link:
|
||||
logger.debug(
|
||||
'_create_deep_link: app requires crypto link but none available', get=app.get('name', 'unknown')
|
||||
)
|
||||
return None
|
||||
payload = subscription_crypto_link
|
||||
else:
|
||||
if not subscription_url:
|
||||
logger.debug(
|
||||
'_create_deep_link: app requires subscription_url but none available', get=app.get('name', 'unknown')
|
||||
)
|
||||
return None
|
||||
payload = subscription_url
|
||||
|
||||
if app.get('isNeedBase64Encoding'):
|
||||
try:
|
||||
payload = base64.b64encode(payload.encode('utf-8')).decode('utf-8')
|
||||
except Exception as e:
|
||||
logger.warning('Failed to encode payload to base64', error=e)
|
||||
|
||||
return f'{scheme}{payload}'
|
||||
|
||||
|
||||
def _resolve_button_url(
|
||||
url: str,
|
||||
subscription_url: str | None,
|
||||
subscription_crypto_link: str | None,
|
||||
) -> str:
|
||||
"""Resolve template variables in button URLs.
|
||||
|
||||
Matches remnawave/subscription-page frontend TemplateEngine:
|
||||
- {{SUBSCRIPTION_LINK}} -> plain subscription URL
|
||||
- {{HAPP_CRYPT3_LINK}} -> crypto link
|
||||
- {{HAPP_CRYPT4_LINK}} -> crypto link
|
||||
"""
|
||||
if not url:
|
||||
return url
|
||||
result = url
|
||||
if subscription_url:
|
||||
result = result.replace('{{SUBSCRIPTION_LINK}}', subscription_url)
|
||||
if subscription_crypto_link:
|
||||
result = result.replace('{{HAPP_CRYPT3_LINK}}', subscription_crypto_link)
|
||||
result = result.replace('{{HAPP_CRYPT4_LINK}}', subscription_crypto_link)
|
||||
return result
|
||||
|
||||
|
||||
@router.get('/app-config')
|
||||
async def get_app_config(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
subscription_id: int | None = Query(None, description='Subscription ID for multi-tariff'),
|
||||
) -> dict[str, Any]:
|
||||
"""Get app configuration for connection with deep links."""
|
||||
subscription = await resolve_subscription(db, user, subscription_id)
|
||||
|
||||
subscription_url = None
|
||||
subscription_crypto_link = None
|
||||
if subscription:
|
||||
subscription_url = subscription.subscription_url
|
||||
subscription_crypto_link = subscription.subscription_crypto_link
|
||||
|
||||
# Generate crypto link on the fly if subscription_url exists but crypto link is missing.
|
||||
# This covers synced users where enrich_happ_links was not called.
|
||||
if subscription_url and not subscription_crypto_link:
|
||||
try:
|
||||
service = RemnaWaveService()
|
||||
async with service.get_api_client() as api:
|
||||
encrypted = await api.encrypt_happ_crypto_link(subscription_url)
|
||||
if encrypted:
|
||||
subscription_crypto_link = encrypted
|
||||
if subscription:
|
||||
subscription.subscription_crypto_link = encrypted
|
||||
await db.commit()
|
||||
logger.info(
|
||||
'Generated and saved crypto link for user',
|
||||
user_id=user.id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug('Could not generate crypto link', error=e)
|
||||
|
||||
config = await _load_app_config_async()
|
||||
|
||||
if not config:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='App configuration not set up.',
|
||||
)
|
||||
|
||||
config.pop('_isRemnawave', None)
|
||||
hide_link = settings.should_hide_subscription_link()
|
||||
|
||||
# Build platformNames from displayName of each platform
|
||||
platform_names: dict[str, Any] = {}
|
||||
for pk, pd in config.get('platforms', {}).items():
|
||||
if isinstance(pd, dict) and 'displayName' in pd:
|
||||
platform_names[pk] = pd['displayName']
|
||||
fallback_names = {
|
||||
'ios': {'en': 'iPhone/iPad'},
|
||||
'android': {'en': 'Android'},
|
||||
'macos': {'en': 'macOS'},
|
||||
'windows': {'en': 'Windows'},
|
||||
'linux': {'en': 'Linux'},
|
||||
'androidTV': {'en': 'Android TV'},
|
||||
'appleTV': {'en': 'Apple TV'},
|
||||
}
|
||||
for k, v in fallback_names.items():
|
||||
if k not in platform_names:
|
||||
platform_names[k] = v
|
||||
|
||||
# Serve original blocks/svgLibrary enriched with deep links and resolved URLs.
|
||||
platforms: dict[str, Any] = {}
|
||||
for platform_key, platform_data in config.get('platforms', {}).items():
|
||||
if not isinstance(platform_data, dict):
|
||||
continue
|
||||
apps = platform_data.get('apps', [])
|
||||
if not isinstance(apps, list):
|
||||
continue
|
||||
|
||||
enriched_apps = []
|
||||
for app in apps:
|
||||
if not isinstance(app, dict):
|
||||
continue
|
||||
|
||||
# Generate deep link
|
||||
deep_link = None
|
||||
if subscription_url or subscription_crypto_link:
|
||||
deep_link = _create_deep_link(app, subscription_url, subscription_crypto_link)
|
||||
app['deepLink'] = deep_link
|
||||
|
||||
# Resolve templates only for subscriptionLink and copyButton (not external)
|
||||
for block in app.get('blocks', []):
|
||||
if not isinstance(block, dict):
|
||||
continue
|
||||
for btn in block.get('buttons', []):
|
||||
if not isinstance(btn, dict):
|
||||
continue
|
||||
btn_type = btn.get('type', '')
|
||||
if btn_type in ('subscriptionLink', 'copyButton'):
|
||||
url = btn.get('url', '') or btn.get('link', '')
|
||||
if url and '{{' in url:
|
||||
resolved = _resolve_button_url(
|
||||
url,
|
||||
subscription_url,
|
||||
subscription_crypto_link,
|
||||
)
|
||||
# Only set resolvedUrl if ALL templates were resolved;
|
||||
# otherwise let the frontend fall through to deepLink/subscriptionUrl
|
||||
if '{{' not in resolved:
|
||||
btn['resolvedUrl'] = resolved
|
||||
|
||||
enriched_apps.append(app)
|
||||
|
||||
if enriched_apps:
|
||||
platform_output = {k: v for k, v in platform_data.items() if k != 'apps'}
|
||||
platform_output['apps'] = enriched_apps
|
||||
platforms[platform_key] = platform_output
|
||||
|
||||
return {
|
||||
'isRemnawave': True,
|
||||
'platforms': platforms,
|
||||
'svgLibrary': config.get('svgLibrary', {}),
|
||||
'baseTranslations': config.get('baseTranslations'),
|
||||
'baseSettings': config.get('baseSettings'),
|
||||
'uiConfig': config.get('uiConfig', {}),
|
||||
'platformNames': platform_names,
|
||||
'hasSubscription': bool(subscription_url or subscription_crypto_link),
|
||||
'subscriptionUrl': subscription_url,
|
||||
'subscriptionCryptoLink': subscription_crypto_link,
|
||||
'hideLink': hide_link,
|
||||
'branding': config.get('brandingSettings', {}),
|
||||
}
|
||||
@@ -0,0 +1,507 @@
|
||||
"""Tariff switching endpoints.
|
||||
|
||||
POST /subscription/tariff/switch/preview
|
||||
POST /subscription/tariff/switch
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query as QueryParam, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.tariff import get_tariff_by_id
|
||||
from app.database.crud.transaction import create_transaction
|
||||
from app.database.crud.user import subtract_user_balance
|
||||
from app.database.models import PaymentMethod, Subscription, TransactionType, User
|
||||
from app.services.pricing_engine import pricing_engine
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
|
||||
from ...dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
from ...schemas.subscription import TariffPurchaseRequest
|
||||
from .helpers import _subscription_to_response, resolve_subscription
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post('/tariff/switch/preview')
|
||||
async def preview_tariff_switch(
|
||||
request: TariffPurchaseRequest,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
subscription_id: int | None = QueryParam(None, description='Subscription ID for multi-tariff'),
|
||||
) -> dict[str, Any]:
|
||||
"""Preview tariff switch - shows cost calculation."""
|
||||
if not settings.is_tariffs_mode():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Tariffs mode is not enabled',
|
||||
)
|
||||
|
||||
subscription = await resolve_subscription(db, user, subscription_id)
|
||||
|
||||
if not subscription or not subscription.tariff_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='No active subscription with tariff',
|
||||
)
|
||||
|
||||
# Use actual_status for correct status check (handles time-based expiration)
|
||||
actual_status = subscription.actual_status
|
||||
if actual_status == 'expired':
|
||||
# For expired subscriptions, user should purchase a new tariff, not switch
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={
|
||||
'code': 'subscription_expired',
|
||||
'message': 'Subscription is expired. Please purchase a new tariff instead of switching.',
|
||||
'use_purchase_flow': True,
|
||||
},
|
||||
)
|
||||
if actual_status not in ('active', 'trial'):
|
||||
# For disabled/pending subscriptions, block switching with generic error
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={
|
||||
'code': 'subscription_not_active',
|
||||
'message': f'Subscription is not active (status: {actual_status}). Cannot switch tariff.',
|
||||
},
|
||||
)
|
||||
|
||||
current_tariff = await get_tariff_by_id(db, subscription.tariff_id)
|
||||
new_tariff = await get_tariff_by_id(db, request.tariff_id)
|
||||
|
||||
if not new_tariff or not new_tariff.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Tariff not found or inactive',
|
||||
)
|
||||
|
||||
if subscription.tariff_id == request.tariff_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Already on this tariff',
|
||||
)
|
||||
|
||||
# Check tariff availability for user's promo group
|
||||
# Use get_primary_promo_group() for correct promo group resolution
|
||||
promo_group = user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else None
|
||||
if promo_group is None:
|
||||
promo_group = getattr(user, 'promo_group', None)
|
||||
promo_group_id = promo_group.id if promo_group else None
|
||||
if not new_tariff.is_available_for_promo_group(promo_group_id):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Tariff not available for your promo group',
|
||||
)
|
||||
|
||||
# Calculate remaining days
|
||||
remaining_days = 0
|
||||
if subscription.end_date and subscription.end_date > datetime.now(UTC):
|
||||
delta = subscription.end_date - datetime.now(UTC)
|
||||
remaining_days = max(0, delta.days)
|
||||
|
||||
# Calculate switch cost (PricingEngine handles all cases: periodic<->periodic, daily->periodic, periodic->daily)
|
||||
switch_result = pricing_engine.calculate_tariff_switch_cost(
|
||||
current_tariff,
|
||||
new_tariff,
|
||||
remaining_days,
|
||||
user=user,
|
||||
)
|
||||
upgrade_cost = switch_result.upgrade_cost
|
||||
is_upgrade = switch_result.is_upgrade
|
||||
base_upgrade_cost = switch_result.raw_cost
|
||||
discount_value = switch_result.discount_value
|
||||
period_discount_percent = switch_result.effective_discount_pct
|
||||
|
||||
balance = user.balance_kopeks or 0
|
||||
has_enough = balance >= upgrade_cost
|
||||
missing = max(0, upgrade_cost - balance) if not has_enough else 0
|
||||
|
||||
response: dict[str, Any] = {
|
||||
'can_switch': has_enough,
|
||||
'current_tariff_id': current_tariff.id if current_tariff else None,
|
||||
'current_tariff_name': current_tariff.name if current_tariff else None,
|
||||
'new_tariff_id': new_tariff.id,
|
||||
'new_tariff_name': new_tariff.name,
|
||||
'remaining_days': remaining_days,
|
||||
'upgrade_cost_kopeks': upgrade_cost,
|
||||
'upgrade_cost_label': settings.format_price(upgrade_cost) if upgrade_cost > 0 else 'Бесплатно',
|
||||
'balance_kopeks': balance,
|
||||
'balance_label': settings.format_price(balance),
|
||||
'has_enough_balance': has_enough,
|
||||
'missing_amount_kopeks': missing,
|
||||
'missing_amount_label': settings.format_price(missing) if missing > 0 else '',
|
||||
'is_upgrade': is_upgrade,
|
||||
}
|
||||
|
||||
# Add discount info if applicable
|
||||
if period_discount_percent > 0 and discount_value > 0:
|
||||
response['discount_percent'] = period_discount_percent
|
||||
response['discount_kopeks'] = discount_value
|
||||
response['base_upgrade_cost_kopeks'] = base_upgrade_cost
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@router.post('/tariff/switch')
|
||||
async def switch_tariff(
|
||||
request: TariffPurchaseRequest,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
subscription_id: int | None = QueryParam(None, description='Subscription ID for multi-tariff'),
|
||||
) -> dict[str, Any]:
|
||||
"""Switch to a different tariff without changing end date."""
|
||||
if not settings.is_tariffs_mode():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Tariffs mode is not enabled',
|
||||
)
|
||||
|
||||
resolved = await resolve_subscription(db, user, subscription_id)
|
||||
|
||||
if not resolved or not resolved.tariff_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='No active subscription with tariff',
|
||||
)
|
||||
|
||||
# Guard: prevent switching to a tariff the user already owns (multi-tariff)
|
||||
if settings.is_multi_tariff_enabled() and request.tariff_id:
|
||||
from app.database.crud.subscription import get_subscription_by_user_and_tariff
|
||||
|
||||
existing_target = await get_subscription_by_user_and_tariff(db, user.id, request.tariff_id)
|
||||
if existing_target and existing_target.id != resolved.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail='You already have an active subscription for the target tariff',
|
||||
)
|
||||
|
||||
# Lock subscription row to prevent concurrent tariff switches
|
||||
locked_result = await db.execute(
|
||||
select(Subscription)
|
||||
.where(Subscription.id == resolved.id)
|
||||
.with_for_update()
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
subscription = locked_result.scalar_one()
|
||||
|
||||
# Use actual_status for correct status check (handles time-based expiration)
|
||||
actual_status = subscription.actual_status
|
||||
if actual_status == 'expired':
|
||||
# For expired subscriptions, user should purchase a new tariff, not switch
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={
|
||||
'code': 'subscription_expired',
|
||||
'message': 'Subscription is expired. Please purchase a new tariff instead of switching.',
|
||||
'use_purchase_flow': True,
|
||||
},
|
||||
)
|
||||
if actual_status not in ('active', 'trial'):
|
||||
# For disabled/pending subscriptions, block switching with generic error
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={
|
||||
'code': 'subscription_not_active',
|
||||
'message': f'Subscription is not active (status: {actual_status}). Cannot switch tariff.',
|
||||
},
|
||||
)
|
||||
|
||||
current_tariff = await get_tariff_by_id(db, subscription.tariff_id)
|
||||
new_tariff = await get_tariff_by_id(db, request.tariff_id)
|
||||
|
||||
if not new_tariff or not new_tariff.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Tariff not found or inactive',
|
||||
)
|
||||
|
||||
if subscription.tariff_id == request.tariff_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Already on this tariff',
|
||||
)
|
||||
|
||||
# Check tariff availability
|
||||
# Use get_primary_promo_group() for correct promo group resolution
|
||||
promo_group = user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else None
|
||||
if promo_group is None:
|
||||
promo_group = getattr(user, 'promo_group', None)
|
||||
promo_group_id = promo_group.id if promo_group else None
|
||||
if not new_tariff.is_available_for_promo_group(promo_group_id):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Tariff not available',
|
||||
)
|
||||
|
||||
# Lock user BEFORE price computation to prevent TOCTOU on promo offer
|
||||
from app.database.crud.user import lock_user_for_pricing
|
||||
|
||||
user = await lock_user_for_pricing(db, user.id)
|
||||
|
||||
# Calculate remaining days
|
||||
remaining_days = 0
|
||||
if subscription.end_date and subscription.end_date > datetime.now(UTC):
|
||||
delta = subscription.end_date - datetime.now(UTC)
|
||||
remaining_days = max(0, delta.days)
|
||||
|
||||
# Calculate cost (PricingEngine handles all cases: periodic<->periodic, daily->periodic, periodic->daily)
|
||||
switch_result = pricing_engine.calculate_tariff_switch_cost(
|
||||
current_tariff,
|
||||
new_tariff,
|
||||
remaining_days,
|
||||
user=user,
|
||||
)
|
||||
upgrade_cost = switch_result.upgrade_cost
|
||||
base_upgrade_cost = switch_result.raw_cost
|
||||
discount_value = switch_result.discount_value
|
||||
period_discount_percent = switch_result.effective_discount_pct
|
||||
new_period_days = switch_result.new_period_days
|
||||
|
||||
# Validate daily price for switching TO daily
|
||||
new_is_daily = getattr(new_tariff, 'is_daily', False)
|
||||
current_is_daily = getattr(current_tariff, 'is_daily', False) if current_tariff else False
|
||||
switching_to_daily = not current_is_daily and new_is_daily
|
||||
switching_from_daily = current_is_daily and not new_is_daily
|
||||
|
||||
if switching_to_daily and (getattr(new_tariff, 'daily_price_kopeks', 0) or 0) <= 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Daily tariff has invalid price',
|
||||
)
|
||||
|
||||
# Charge if upgrade
|
||||
switch_transaction = None
|
||||
if upgrade_cost > 0:
|
||||
if user.balance_kopeks < upgrade_cost:
|
||||
missing = upgrade_cost - user.balance_kopeks
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_402_PAYMENT_REQUIRED,
|
||||
detail={
|
||||
'code': 'insufficient_funds',
|
||||
'message': f'Insufficient funds. Missing {settings.format_price(missing)}',
|
||||
'missing_amount': missing,
|
||||
},
|
||||
)
|
||||
|
||||
if switching_to_daily:
|
||||
description = f"Переход на суточный тариф '{new_tariff.name}'"
|
||||
elif switching_from_daily:
|
||||
description = f"Переход с суточного на тариф '{new_tariff.name}' ({new_period_days} дней)"
|
||||
else:
|
||||
description = f"Переход на тариф '{new_tariff.name}' (доплата за {remaining_days} дней)"
|
||||
|
||||
# Add discount info to description if applicable
|
||||
if period_discount_percent > 0 and discount_value > 0:
|
||||
description += f' (скидка {period_discount_percent}%)'
|
||||
|
||||
success = await subtract_user_balance(
|
||||
db,
|
||||
user,
|
||||
upgrade_cost,
|
||||
description,
|
||||
consume_promo_offer=switch_result.offer_discount_pct > 0,
|
||||
mark_as_paid_subscription=True,
|
||||
commit=False,
|
||||
)
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to charge balance',
|
||||
)
|
||||
|
||||
# Create transaction (commit=False to keep FOR UPDATE lock held)
|
||||
switch_transaction = await create_transaction(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
amount_kopeks=upgrade_cost,
|
||||
description=description,
|
||||
payment_method=PaymentMethod.BALANCE,
|
||||
commit=False,
|
||||
)
|
||||
else:
|
||||
# Free switch (downgrade) — record in history
|
||||
description = f"Переход на тариф '{new_tariff.name}'"
|
||||
await create_transaction(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
amount_kopeks=0,
|
||||
description=description,
|
||||
commit=False,
|
||||
)
|
||||
|
||||
# Update subscription
|
||||
old_tariff_name = current_tariff.name if current_tariff else 'Unknown'
|
||||
|
||||
# Reset device limit to new tariff base (extra purchased devices are not carried over)
|
||||
from app.database.crud.subscription import calc_device_limit_on_tariff_switch
|
||||
|
||||
# Re-load subscription to avoid MissingGreenlet from expired lazy relationship
|
||||
# (subtract_user_balance re-selects User with populate_existing=True which expires relationships)
|
||||
await db.refresh(subscription)
|
||||
|
||||
subscription.tariff_id = new_tariff.id
|
||||
subscription.traffic_limit_gb = new_tariff.traffic_limit_gb
|
||||
subscription.device_limit = calc_device_limit_on_tariff_switch(
|
||||
current_device_limit=subscription.device_limit,
|
||||
old_tariff_device_limit=current_tariff.device_limit if current_tariff else None,
|
||||
new_tariff_device_limit=new_tariff.device_limit,
|
||||
max_device_limit=new_tariff.max_device_limit,
|
||||
)
|
||||
subscription.connected_squads = new_tariff.allowed_squads or []
|
||||
|
||||
# Reset purchased traffic and delete TrafficPurchase records on tariff switch
|
||||
from sqlalchemy import delete as sql_delete
|
||||
|
||||
from app.database.models import TrafficPurchase
|
||||
|
||||
await db.execute(sql_delete(TrafficPurchase).where(TrafficPurchase.subscription_id == subscription.id))
|
||||
subscription.purchased_traffic_gb = 0
|
||||
subscription.traffic_reset_at = None
|
||||
|
||||
if settings.RESET_TRAFFIC_ON_TARIFF_SWITCH:
|
||||
subscription.traffic_used_gb = 0.0
|
||||
|
||||
if switching_to_daily:
|
||||
# Switching TO daily - reset end_date to 1 day, set last_daily_charge_at
|
||||
subscription.end_date = datetime.now(UTC) + timedelta(days=1)
|
||||
subscription.last_daily_charge_at = datetime.now(UTC)
|
||||
subscription.is_daily_paused = False
|
||||
elif switching_from_daily:
|
||||
subscription.end_date = datetime.now(UTC) + timedelta(days=new_period_days)
|
||||
subscription.is_daily_paused = False
|
||||
|
||||
subscription.updated_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
|
||||
# Emit deferred side-effects after atomic commit
|
||||
if upgrade_cost > 0 and switch_transaction:
|
||||
from app.database.crud.transaction import emit_transaction_side_effects
|
||||
|
||||
await emit_transaction_side_effects(
|
||||
db,
|
||||
switch_transaction,
|
||||
amount_kopeks=upgrade_cost,
|
||||
user_id=user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
payment_method=PaymentMethod.BALANCE,
|
||||
)
|
||||
|
||||
# Sync with RemnaWave (optionally reset traffic based on admin setting)
|
||||
should_reset_traffic = settings.RESET_TRAFFIC_ON_TARIFF_SWITCH
|
||||
# Refresh subscription after commit (all objects are expired)
|
||||
await db.refresh(subscription)
|
||||
|
||||
try:
|
||||
subscription_service = SubscriptionService()
|
||||
_has_panel = (
|
||||
getattr(subscription, 'remnawave_uuid', None)
|
||||
if settings.is_multi_tariff_enabled()
|
||||
else getattr(user, 'remnawave_uuid', None)
|
||||
)
|
||||
if _has_panel:
|
||||
await subscription_service.update_remnawave_user(
|
||||
db,
|
||||
subscription,
|
||||
reset_traffic=should_reset_traffic,
|
||||
reset_reason='смена тарифа',
|
||||
sync_squads=True,
|
||||
)
|
||||
else:
|
||||
await subscription_service.create_remnawave_user(
|
||||
db,
|
||||
subscription,
|
||||
reset_traffic=should_reset_traffic,
|
||||
reset_reason='смена тарифа',
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error('Failed to sync tariff switch with RemnaWave', error=e)
|
||||
from app.services.remnawave_retry_queue import remnawave_retry_queue
|
||||
|
||||
remnawave_retry_queue.enqueue(
|
||||
subscription_id=subscription.id,
|
||||
user_id=user.id,
|
||||
action='update' if _has_panel else 'create',
|
||||
)
|
||||
|
||||
# Reset all devices on tariff switch
|
||||
devices_reset = False
|
||||
_switch_uuid = (
|
||||
subscription.remnawave_uuid
|
||||
if settings.is_multi_tariff_enabled() and subscription.remnawave_uuid
|
||||
else user.remnawave_uuid
|
||||
)
|
||||
if _switch_uuid:
|
||||
try:
|
||||
service = RemnaWaveService()
|
||||
async with service.get_api_client() as api:
|
||||
await api.reset_user_devices(_switch_uuid)
|
||||
devices_reset = True
|
||||
logger.info('Reset all devices for user on tariff switch', user_id=user.id)
|
||||
except Exception as e:
|
||||
logger.error('Failed to reset devices on tariff switch', error=e)
|
||||
|
||||
await db.refresh(user)
|
||||
await db.refresh(subscription)
|
||||
|
||||
# Отправляем уведомление админам о смене тарифа
|
||||
try:
|
||||
from aiogram import Bot
|
||||
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
if getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) and settings.BOT_TOKEN:
|
||||
bot = Bot(token=settings.BOT_TOKEN)
|
||||
try:
|
||||
notification_service = AdminNotificationService(bot)
|
||||
await notification_service.send_subscription_purchase_notification(
|
||||
db=db,
|
||||
user=user,
|
||||
subscription=subscription,
|
||||
transaction=switch_transaction if upgrade_cost > 0 else None,
|
||||
period_days=remaining_days if remaining_days > 0 else new_period_days,
|
||||
was_trial_conversion=False,
|
||||
amount_kopeks=upgrade_cost,
|
||||
purchase_type='tariff_switch',
|
||||
)
|
||||
finally:
|
||||
await bot.session.close()
|
||||
except Exception as e:
|
||||
logger.error('Failed to send admin notification for tariff switch', error=e)
|
||||
|
||||
# Refresh expired objects after db.commit() in _record_subscription_event
|
||||
await db.refresh(subscription)
|
||||
await db.refresh(user)
|
||||
|
||||
response: dict[str, Any] = {
|
||||
'success': True,
|
||||
'message': f"Switched from '{old_tariff_name}' to '{new_tariff.name}'"
|
||||
+ (' (devices reset)' if devices_reset else ''),
|
||||
'subscription': _subscription_to_response(subscription, user=user),
|
||||
'old_tariff_name': old_tariff_name,
|
||||
'new_tariff_id': new_tariff.id,
|
||||
'new_tariff_name': new_tariff.name,
|
||||
'charged_kopeks': upgrade_cost,
|
||||
'balance_kopeks': user.balance_kopeks,
|
||||
'balance_label': settings.format_price(user.balance_kopeks),
|
||||
}
|
||||
|
||||
# Add discount info if applicable
|
||||
if period_discount_percent > 0 and discount_value > 0:
|
||||
response['discount_percent'] = period_discount_percent
|
||||
response['discount_kopeks'] = discount_value
|
||||
response['base_charged_kopeks'] = base_upgrade_cost
|
||||
|
||||
return response
|
||||
@@ -0,0 +1,785 @@
|
||||
"""Traffic management endpoints.
|
||||
|
||||
GET /subscription/traffic-packages
|
||||
POST /subscription/traffic
|
||||
PUT /subscription/traffic
|
||||
POST /subscription/refresh-traffic
|
||||
POST /subscription/traffic/save-cart
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query as QueryParam, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.tariff import get_tariff_by_id
|
||||
from app.database.crud.transaction import create_transaction
|
||||
from app.database.crud.user import subtract_user_balance
|
||||
from app.database.models import TransactionType, User
|
||||
from app.services.pricing_engine import pricing_engine
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
from app.services.user_cart_service import user_cart_service
|
||||
from app.utils.cache import RateLimitCache, cache, cache_key
|
||||
|
||||
from ...dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
from ...schemas.subscription import (
|
||||
TrafficPackageResponse,
|
||||
TrafficPurchaseRequest,
|
||||
)
|
||||
from .helpers import _apply_addon_discount, resolve_subscription
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get('/traffic-packages', response_model=list[TrafficPackageResponse])
|
||||
async def get_traffic_packages(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
subscription_id: int | None = QueryParam(None, description='Subscription ID for multi-tariff'),
|
||||
):
|
||||
"""Get available traffic packages."""
|
||||
from app.database.crud.tariff import get_tariff_by_id
|
||||
|
||||
subscription = await resolve_subscription(db, user, subscription_id)
|
||||
if not subscription:
|
||||
return []
|
||||
|
||||
# Режим тарифов - берём пакеты из тарифа
|
||||
if settings.is_tariffs_mode() and subscription.tariff_id:
|
||||
tariff = await get_tariff_by_id(db, subscription.tariff_id)
|
||||
if not tariff:
|
||||
return []
|
||||
|
||||
# Проверяем, разрешена ли докупка для этого тарифа
|
||||
if not getattr(tariff, 'traffic_topup_enabled', False):
|
||||
return []
|
||||
|
||||
# Проверяем безлимит
|
||||
if tariff.traffic_limit_gb == 0:
|
||||
return []
|
||||
|
||||
packages = tariff.get_traffic_topup_packages() if hasattr(tariff, 'get_traffic_topup_packages') else {}
|
||||
result = []
|
||||
|
||||
for gb, price in packages.items():
|
||||
if price <= 0:
|
||||
continue
|
||||
result.append(
|
||||
TrafficPackageResponse(
|
||||
gb=gb,
|
||||
price_kopeks=price,
|
||||
price_rubles=price / 100,
|
||||
is_unlimited=False,
|
||||
)
|
||||
)
|
||||
|
||||
return sorted(result, key=lambda x: x.gb)
|
||||
|
||||
# Classic режим - глобальные настройки
|
||||
if not settings.is_traffic_topup_enabled():
|
||||
return []
|
||||
|
||||
# Проверяем настройку тарифа пользователя (allow_traffic_topup)
|
||||
if subscription.tariff_id:
|
||||
tariff = await get_tariff_by_id(db, subscription.tariff_id)
|
||||
if tariff and not tariff.allow_traffic_topup:
|
||||
return []
|
||||
|
||||
packages = settings.get_traffic_topup_packages()
|
||||
result = []
|
||||
|
||||
for pkg in packages:
|
||||
if not pkg.get('enabled', True):
|
||||
continue
|
||||
if pkg['price'] <= 0:
|
||||
continue
|
||||
|
||||
result.append(
|
||||
TrafficPackageResponse(
|
||||
gb=pkg['gb'],
|
||||
price_kopeks=pkg['price'],
|
||||
price_rubles=pkg['price'] / 100,
|
||||
is_unlimited=pkg['gb'] == 0,
|
||||
)
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.post('/traffic')
|
||||
async def purchase_traffic(
|
||||
request: TrafficPurchaseRequest,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
subscription_id: int | None = QueryParam(None, description='Subscription ID for multi-tariff'),
|
||||
):
|
||||
"""Purchase additional traffic."""
|
||||
if getattr(user, 'restriction_subscription', False):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Subscription purchases are restricted for this account',
|
||||
)
|
||||
|
||||
from app.database.crud.subscription import add_subscription_traffic
|
||||
from app.database.crud.tariff import get_tariff_by_id
|
||||
from app.utils.pricing_utils import calculate_prorated_price
|
||||
|
||||
subscription = await resolve_subscription(db, user, subscription_id)
|
||||
|
||||
if not subscription:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='No subscription found',
|
||||
)
|
||||
tariff = None
|
||||
base_price_kopeks = 0
|
||||
is_tariff_mode = settings.is_tariffs_mode() and subscription.tariff_id
|
||||
|
||||
# Режим тарифов
|
||||
if is_tariff_mode:
|
||||
tariff = await get_tariff_by_id(db, subscription.tariff_id)
|
||||
if not tariff:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Tariff not found',
|
||||
)
|
||||
|
||||
# Проверяем, разрешена ли докупка
|
||||
if not getattr(tariff, 'traffic_topup_enabled', False):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Traffic top-up is disabled for this tariff',
|
||||
)
|
||||
|
||||
# Проверяем безлимит
|
||||
if tariff.traffic_limit_gb == 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Cannot add traffic to unlimited subscription',
|
||||
)
|
||||
|
||||
# Проверяем лимит докупки
|
||||
max_topup_limit = getattr(tariff, 'max_topup_traffic_gb', 0) or 0
|
||||
if max_topup_limit > 0:
|
||||
current_traffic = subscription.traffic_limit_gb or 0
|
||||
new_traffic = current_traffic + request.gb
|
||||
if new_traffic > max_topup_limit:
|
||||
available_gb = max(0, max_topup_limit - current_traffic)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Traffic limit exceeded. Max: {max_topup_limit} GB, available: {available_gb} GB',
|
||||
)
|
||||
|
||||
# Получаем цену из тарифа
|
||||
packages = tariff.get_traffic_topup_packages() if hasattr(tariff, 'get_traffic_topup_packages') else {}
|
||||
if request.gb not in packages:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Traffic package {request.gb}GB is not available',
|
||||
)
|
||||
base_price_kopeks = packages[request.gb]
|
||||
if base_price_kopeks <= 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Traffic package {request.gb}GB has no price configured',
|
||||
)
|
||||
|
||||
else:
|
||||
# Classic режим
|
||||
if not settings.is_traffic_topup_enabled():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Traffic top-up feature is disabled',
|
||||
)
|
||||
|
||||
# Проверяем настройку тарифа (allow_traffic_topup)
|
||||
if subscription.tariff_id:
|
||||
tariff = await get_tariff_by_id(db, subscription.tariff_id)
|
||||
if tariff and not tariff.allow_traffic_topup:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Traffic top-up is not available for your tariff',
|
||||
)
|
||||
|
||||
# Получаем цену из глобальных настроек
|
||||
packages = settings.get_traffic_topup_packages()
|
||||
matching_pkg = next((pkg for pkg in packages if pkg['gb'] == request.gb and pkg.get('enabled', True)), None)
|
||||
if not matching_pkg:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Invalid traffic package',
|
||||
)
|
||||
base_price_kopeks = matching_pkg['price']
|
||||
if base_price_kopeks <= 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Traffic package has no price configured',
|
||||
)
|
||||
|
||||
# На тарифах пакеты трафика покупаются на 1 месяц (30 дней),
|
||||
# цена в тарифе уже месячная — не умножаем на оставшиеся месяцы подписки.
|
||||
# Пропорциональный расчёт применяем только в классическом режиме.
|
||||
if is_tariff_mode:
|
||||
prorated_price = base_price_kopeks
|
||||
days_charged = 30
|
||||
else:
|
||||
prorated_price, days_charged = calculate_prorated_price(
|
||||
base_price_kopeks,
|
||||
subscription.end_date,
|
||||
)
|
||||
|
||||
# Lock user row to prevent TOCTOU on promo-offer state
|
||||
from app.database.crud.user import lock_user_for_pricing
|
||||
|
||||
user = await lock_user_for_pricing(db, user.id)
|
||||
|
||||
# Apply discount from promo group using proper method
|
||||
period_hint_days = days_charged if days_charged > 0 else 30
|
||||
discount_result = _apply_addon_discount(user, 'traffic', prorated_price, period_hint_days)
|
||||
final_price = discount_result['discounted']
|
||||
traffic_discount_percent = discount_result['percent']
|
||||
discount_value = discount_result['discount']
|
||||
|
||||
# Ensure minimum price after discount (except for 100% discount)
|
||||
if traffic_discount_percent < 100 and final_price > 0:
|
||||
final_price = max(100, final_price)
|
||||
|
||||
# Проверяем баланс
|
||||
if final_price > 0 and user.balance_kopeks < final_price:
|
||||
missing = final_price - user.balance_kopeks
|
||||
|
||||
# Save cart for auto-purchase after balance top-up
|
||||
cart_data = {
|
||||
'cart_mode': 'add_traffic',
|
||||
'subscription_id': subscription.id,
|
||||
'traffic_gb': request.gb,
|
||||
'price_kopeks': final_price,
|
||||
'base_price_kopeks': prorated_price,
|
||||
'discount_percent': traffic_discount_percent,
|
||||
'source': 'cabinet',
|
||||
'description': f'Докупка {request.gb} ГБ трафика',
|
||||
}
|
||||
|
||||
try:
|
||||
await user_cart_service.save_user_cart(user.id, cart_data)
|
||||
logger.info(
|
||||
'Cart saved for traffic purchase (cabinet) user + discount',
|
||||
user_id=user.id,
|
||||
gb=request.gb,
|
||||
traffic_discount_percent=traffic_discount_percent,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error('Error saving cart for traffic purchase (cabinet)', error=e)
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_402_PAYMENT_REQUIRED,
|
||||
detail={
|
||||
'code': 'insufficient_funds',
|
||||
'message': f'Недостаточно средств. Не хватает {settings.format_price(missing)}',
|
||||
'missing_amount': missing,
|
||||
'cart_saved': True,
|
||||
'cart_mode': 'add_traffic',
|
||||
},
|
||||
)
|
||||
|
||||
# Формируем описание
|
||||
if traffic_discount_percent > 0:
|
||||
traffic_description = f'Докупка {request.gb} ГБ трафика (скидка {traffic_discount_percent}%)'
|
||||
else:
|
||||
traffic_description = f'Докупка {request.gb} ГБ трафика'
|
||||
|
||||
# Списываем баланс
|
||||
success = await subtract_user_balance(db, user, final_price, traffic_description)
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to charge balance',
|
||||
)
|
||||
|
||||
# Добавляем трафик (add_subscription_traffic обновляет purchased_traffic_gb, traffic_reset_at и коммитит)
|
||||
await add_subscription_traffic(db, subscription, request.gb)
|
||||
|
||||
# Реактивируем подписку если она была DISABLED/EXPIRED (например, после LIMITED/EXPIRED в RemnaWave)
|
||||
from app.database.crud.subscription import reactivate_subscription
|
||||
|
||||
await reactivate_subscription(db, subscription)
|
||||
|
||||
# Синхронизируем с RemnaWave
|
||||
try:
|
||||
subscription_service = SubscriptionService()
|
||||
_panel_uuid = (
|
||||
subscription.remnawave_uuid
|
||||
if settings.is_multi_tariff_enabled() and subscription.remnawave_uuid
|
||||
else getattr(user, 'remnawave_uuid', None)
|
||||
)
|
||||
if _panel_uuid:
|
||||
await subscription_service.update_remnawave_user(db, subscription)
|
||||
if subscription.status == 'active':
|
||||
await subscription_service.enable_remnawave_user(_panel_uuid)
|
||||
else:
|
||||
await subscription_service.create_remnawave_user(db, subscription)
|
||||
except Exception as e:
|
||||
logger.error('Failed to sync traffic with RemnaWave', error=e)
|
||||
from app.services.remnawave_retry_queue import remnawave_retry_queue
|
||||
|
||||
remnawave_retry_queue.enqueue(
|
||||
subscription_id=subscription.id,
|
||||
user_id=user.id,
|
||||
action='update' if _panel_uuid else 'create',
|
||||
)
|
||||
|
||||
# Создаём транзакцию
|
||||
await create_transaction(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
amount_kopeks=final_price,
|
||||
description=traffic_description,
|
||||
)
|
||||
|
||||
await db.refresh(user)
|
||||
await db.refresh(subscription)
|
||||
|
||||
# Отправляем уведомление админам
|
||||
try:
|
||||
from aiogram import Bot
|
||||
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
if getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) and settings.BOT_TOKEN:
|
||||
bot = Bot(token=settings.BOT_TOKEN)
|
||||
try:
|
||||
notification_service = AdminNotificationService(bot)
|
||||
old_traffic = subscription.traffic_limit_gb - request.gb
|
||||
await notification_service.send_subscription_update_notification(
|
||||
db=db,
|
||||
user=user,
|
||||
subscription=subscription,
|
||||
update_type='traffic',
|
||||
old_value=old_traffic,
|
||||
new_value=subscription.traffic_limit_gb,
|
||||
price_paid=final_price,
|
||||
)
|
||||
finally:
|
||||
await bot.session.close()
|
||||
except Exception as e:
|
||||
logger.error('Failed to send admin notification for traffic purchase', error=e)
|
||||
|
||||
response: dict[str, Any] = {
|
||||
'success': True,
|
||||
'message': 'Traffic purchased successfully',
|
||||
'gb_added': request.gb,
|
||||
'new_traffic_limit_gb': subscription.traffic_limit_gb,
|
||||
'amount_paid_kopeks': final_price,
|
||||
'new_balance_kopeks': user.balance_kopeks,
|
||||
}
|
||||
|
||||
if traffic_discount_percent > 0:
|
||||
response['discount_percent'] = traffic_discount_percent
|
||||
response['discount_kopeks'] = discount_value
|
||||
response['base_price_kopeks'] = prorated_price
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@router.post('/traffic/save-cart')
|
||||
async def save_traffic_cart(
|
||||
request: TrafficPurchaseRequest,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
subscription_id: int | None = QueryParam(None, description='Subscription ID for multi-tariff'),
|
||||
) -> dict[str, bool]:
|
||||
"""Save cart for traffic purchase (for insufficient balance flow)."""
|
||||
|
||||
subscription = await resolve_subscription(db, user, subscription_id)
|
||||
|
||||
if not subscription:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='У вас нет активной подписки',
|
||||
)
|
||||
|
||||
if subscription.status not in ['active', 'trial']:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Ваша подписка неактивна',
|
||||
)
|
||||
|
||||
if subscription.is_trial:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Докупка трафика недоступна на пробном периоде',
|
||||
)
|
||||
|
||||
if subscription.traffic_limit_gb == 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='У вас уже безлимитный трафик',
|
||||
)
|
||||
|
||||
# Get traffic price from tariff or settings
|
||||
tariff = None
|
||||
base_price_kopeks = 0
|
||||
is_tariff_mode = settings.is_tariffs_mode() and subscription.tariff_id
|
||||
|
||||
if is_tariff_mode:
|
||||
tariff = await get_tariff_by_id(db, subscription.tariff_id)
|
||||
if not tariff:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Тариф не найден',
|
||||
)
|
||||
|
||||
if not getattr(tariff, 'traffic_topup_enabled', False):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Докупка трафика недоступна на вашем тарифе',
|
||||
)
|
||||
|
||||
packages = tariff.get_traffic_topup_packages() if hasattr(tariff, 'get_traffic_topup_packages') else {}
|
||||
if request.gb not in packages:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Пакет трафика {request.gb} ГБ недоступен',
|
||||
)
|
||||
base_price_kopeks = packages[request.gb]
|
||||
else:
|
||||
if not settings.is_traffic_topup_enabled():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Докупка трафика отключена',
|
||||
)
|
||||
|
||||
packages = settings.get_traffic_topup_packages()
|
||||
matching_pkg = next((pkg for pkg in packages if pkg['gb'] == request.gb and pkg.get('enabled', True)), None)
|
||||
if not matching_pkg:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Недоступный пакет трафика',
|
||||
)
|
||||
base_price_kopeks = matching_pkg['price']
|
||||
|
||||
# Calculate prorated price (days-based), then apply discount
|
||||
from app.utils.pricing_utils import calculate_prorated_price as _calc_prorated
|
||||
|
||||
now = datetime.now(UTC)
|
||||
days_left = max(1, (subscription.end_date - now).days)
|
||||
prorated_price, _ = _calc_prorated(
|
||||
base_price_kopeks,
|
||||
subscription.end_date,
|
||||
)
|
||||
discount_result = _apply_addon_discount(user, 'traffic', prorated_price, days_left)
|
||||
final_price = discount_result['discounted']
|
||||
traffic_discount_percent = discount_result['percent']
|
||||
|
||||
# Save cart for auto-purchase after balance top-up
|
||||
cart_data = {
|
||||
'cart_mode': 'add_traffic',
|
||||
'subscription_id': subscription.id,
|
||||
'traffic_gb': request.gb,
|
||||
'price_kopeks': final_price,
|
||||
'base_price_kopeks': base_price_kopeks,
|
||||
'discount_percent': traffic_discount_percent,
|
||||
'source': 'cabinet',
|
||||
'description': f'Докупка {request.gb} ГБ трафика',
|
||||
}
|
||||
await user_cart_service.save_user_cart(user.id, cart_data)
|
||||
logger.info('Cart saved for traffic purchase (cabinet save-cart) user +', user_id=user.id, gb=request.gb)
|
||||
|
||||
return {'success': True, 'cart_saved': True}
|
||||
|
||||
|
||||
# ============ Traffic Switch (Change Traffic Package) ============
|
||||
|
||||
|
||||
@router.put('/traffic')
|
||||
async def switch_traffic_package(
|
||||
request: TrafficPurchaseRequest,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
subscription_id: int | None = QueryParam(None, description='Subscription ID for multi-tariff'),
|
||||
) -> dict[str, Any]:
|
||||
"""Switch to a different traffic package (change limit)."""
|
||||
from app.utils.pricing_utils import calculate_prorated_price
|
||||
|
||||
subscription = await resolve_subscription(db, user, subscription_id)
|
||||
|
||||
if not subscription:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='No subscription found',
|
||||
)
|
||||
|
||||
if subscription.is_trial:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Traffic management is only available for paid subscriptions',
|
||||
)
|
||||
|
||||
current_traffic = subscription.traffic_limit_gb or 0
|
||||
new_traffic = request.gb
|
||||
|
||||
if current_traffic == new_traffic:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Already on this traffic package',
|
||||
)
|
||||
|
||||
# Get available packages
|
||||
packages = settings.get_traffic_packages()
|
||||
current_pkg = next((p for p in packages if p['gb'] == current_traffic and p.get('enabled', True)), None)
|
||||
new_pkg = next((p for p in packages if p['gb'] == new_traffic and p.get('enabled', True)), None)
|
||||
|
||||
if not new_pkg:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Invalid traffic package',
|
||||
)
|
||||
|
||||
# Calculate price difference (only charge for upgrade)
|
||||
current_price = current_pkg['price'] if current_pkg else 0
|
||||
new_price = new_pkg['price']
|
||||
|
||||
if new_price > current_price:
|
||||
# Upgrade - charge difference
|
||||
price_diff = new_price - current_price
|
||||
|
||||
# Lock user row to prevent TOCTOU on promo-offer state
|
||||
from app.database.crud.user import lock_user_for_pricing
|
||||
|
||||
user = await lock_user_for_pricing(db, user.id)
|
||||
|
||||
# Apply promo discount via PricingEngine
|
||||
price_diff, _discount_val, traffic_discount_percent = pricing_engine.calculate_traffic_discount(
|
||||
price_diff,
|
||||
user,
|
||||
)
|
||||
|
||||
# Prorated calculation
|
||||
final_price, days_charged = calculate_prorated_price(price_diff, subscription.end_date)
|
||||
|
||||
if final_price > 0 and user.balance_kopeks < final_price:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_402_PAYMENT_REQUIRED,
|
||||
detail=f'Insufficient balance. Need {final_price / 100:.2f} RUB',
|
||||
)
|
||||
|
||||
# Charge balance
|
||||
description = f'Traffic upgrade from {current_traffic}GB to {new_traffic}GB'
|
||||
success = await subtract_user_balance(db, user, final_price, description)
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to charge balance',
|
||||
)
|
||||
|
||||
# Create transaction
|
||||
await create_transaction(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
amount_kopeks=final_price,
|
||||
description=description,
|
||||
)
|
||||
|
||||
charged = final_price
|
||||
else:
|
||||
# Downgrade - no charge, no refund
|
||||
charged = 0
|
||||
|
||||
# Update subscription — delete TrafficPurchase records before resetting purchased_traffic_gb
|
||||
from sqlalchemy import delete as sql_delete
|
||||
|
||||
from app.database.models import TrafficPurchase
|
||||
|
||||
await db.execute(sql_delete(TrafficPurchase).where(TrafficPurchase.subscription_id == subscription.id))
|
||||
subscription.traffic_limit_gb = new_traffic
|
||||
subscription.purchased_traffic_gb = 0 # Reset purchased traffic on switch
|
||||
subscription.traffic_reset_at = None # Reset traffic reset date
|
||||
subscription.updated_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
|
||||
# Sync with RemnaWave
|
||||
try:
|
||||
subscription_service = SubscriptionService()
|
||||
_panel_uuid2 = (
|
||||
subscription.remnawave_uuid
|
||||
if settings.is_multi_tariff_enabled() and subscription.remnawave_uuid
|
||||
else getattr(user, 'remnawave_uuid', None)
|
||||
)
|
||||
if _panel_uuid2:
|
||||
await subscription_service.update_remnawave_user(db, subscription)
|
||||
else:
|
||||
await subscription_service.create_remnawave_user(db, subscription)
|
||||
except Exception as e:
|
||||
logger.error('Failed to sync traffic switch with RemnaWave', error=e)
|
||||
from app.services.remnawave_retry_queue import remnawave_retry_queue
|
||||
|
||||
if hasattr(subscription, 'id') and hasattr(subscription, 'user_id'):
|
||||
remnawave_retry_queue.enqueue(
|
||||
subscription_id=subscription.id,
|
||||
user_id=subscription.user_id,
|
||||
action='update' if _panel_uuid2 else 'create',
|
||||
)
|
||||
|
||||
await db.refresh(user)
|
||||
await db.refresh(subscription)
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'message': f'Traffic changed from {current_traffic}GB to {new_traffic}GB',
|
||||
'old_traffic_gb': current_traffic,
|
||||
'new_traffic_gb': new_traffic,
|
||||
'charged_kopeks': charged,
|
||||
'balance_kopeks': user.balance_kopeks,
|
||||
'balance_label': settings.format_price(user.balance_kopeks),
|
||||
}
|
||||
|
||||
|
||||
# ============ Traffic Refresh ============
|
||||
|
||||
# Rate limit: 1 request per 60 seconds per user
|
||||
TRAFFIC_REFRESH_RATE_LIMIT = 1
|
||||
TRAFFIC_REFRESH_RATE_WINDOW = 60 # seconds
|
||||
TRAFFIC_CACHE_TTL = 60 # Cache traffic data for 60 seconds
|
||||
|
||||
|
||||
@router.post('/refresh-traffic')
|
||||
async def refresh_traffic(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
subscription_id: int | None = QueryParam(None, description='Subscription ID for multi-tariff'),
|
||||
):
|
||||
"""
|
||||
Refresh traffic usage from RemnaWave panel.
|
||||
Rate limited to 1 request per 60 seconds.
|
||||
"""
|
||||
subscription = await resolve_subscription(db, user, subscription_id)
|
||||
if not subscription:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='No active subscription',
|
||||
)
|
||||
|
||||
# Use per-subscription key when subscription_id is available so that refreshing
|
||||
# Sub B is not blocked by a previous refresh of Sub A (multi-tariff mode).
|
||||
cache_suffix = f'{user.id}_{subscription_id}' if subscription_id is not None else str(user.id)
|
||||
|
||||
# Check rate limit
|
||||
is_limited = await RateLimitCache.is_rate_limited(
|
||||
cache_suffix,
|
||||
'traffic_refresh',
|
||||
TRAFFIC_REFRESH_RATE_LIMIT,
|
||||
TRAFFIC_REFRESH_RATE_WINDOW,
|
||||
)
|
||||
|
||||
if is_limited:
|
||||
# Check if we have cached data
|
||||
traffic_cache_key = cache_key('traffic', cache_suffix)
|
||||
cached_data = await cache.get(traffic_cache_key)
|
||||
|
||||
if cached_data:
|
||||
return {
|
||||
'success': True,
|
||||
'cached': True,
|
||||
'rate_limited': True,
|
||||
'retry_after_seconds': TRAFFIC_REFRESH_RATE_WINDOW,
|
||||
**cached_data,
|
||||
}
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail=f'Rate limited. Try again in {TRAFFIC_REFRESH_RATE_WINDOW} seconds.',
|
||||
headers={'Retry-After': str(TRAFFIC_REFRESH_RATE_WINDOW)},
|
||||
)
|
||||
|
||||
# Fetch traffic from RemnaWave
|
||||
try:
|
||||
remnawave_service = RemnaWaveService()
|
||||
|
||||
# Resolve panel UUID for traffic lookup
|
||||
_traffic_uuid = (
|
||||
subscription.remnawave_uuid
|
||||
if settings.is_multi_tariff_enabled() and subscription.remnawave_uuid
|
||||
else user.remnawave_uuid
|
||||
)
|
||||
if user.telegram_id and not settings.is_multi_tariff_enabled():
|
||||
traffic_stats = await remnawave_service.get_user_traffic_stats(user.telegram_id)
|
||||
elif _traffic_uuid:
|
||||
traffic_stats = await remnawave_service.get_user_traffic_stats_by_uuid(_traffic_uuid)
|
||||
else:
|
||||
traffic_stats = None
|
||||
|
||||
if not traffic_stats:
|
||||
# Return current database values if RemnaWave unavailable
|
||||
traffic_data = {
|
||||
'traffic_used_bytes': int((subscription.traffic_used_gb or 0) * (1024**3)),
|
||||
'traffic_used_gb': round(subscription.traffic_used_gb or 0, 2),
|
||||
'traffic_limit_bytes': int((subscription.traffic_limit_gb or 0) * (1024**3)),
|
||||
'traffic_limit_gb': subscription.traffic_limit_gb or 0,
|
||||
'traffic_used_percent': round(
|
||||
((subscription.traffic_used_gb or 0) / (subscription.traffic_limit_gb or 1)) * 100
|
||||
if subscription.traffic_limit_gb
|
||||
else 0,
|
||||
1,
|
||||
),
|
||||
'is_unlimited': (subscription.traffic_limit_gb or 0) == 0,
|
||||
}
|
||||
return {
|
||||
'success': True,
|
||||
'cached': False,
|
||||
'source': 'database',
|
||||
**traffic_data,
|
||||
}
|
||||
|
||||
# Update subscription with fresh data
|
||||
used_gb = traffic_stats.get('used_traffic_gb', 0)
|
||||
if abs((subscription.traffic_used_gb or 0) - used_gb) > 0.01:
|
||||
subscription.traffic_used_gb = used_gb
|
||||
subscription.updated_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
|
||||
# Calculate percentage
|
||||
limit_gb = subscription.traffic_limit_gb or 0
|
||||
if limit_gb > 0:
|
||||
percent = min(100, (used_gb / limit_gb) * 100)
|
||||
else:
|
||||
percent = 0
|
||||
|
||||
traffic_data = {
|
||||
'traffic_used_bytes': traffic_stats.get('used_traffic_bytes', 0),
|
||||
'traffic_used_gb': round(used_gb, 2),
|
||||
'traffic_limit_bytes': traffic_stats.get('traffic_limit_bytes', 0),
|
||||
'traffic_limit_gb': limit_gb,
|
||||
'traffic_used_percent': round(percent, 1),
|
||||
'is_unlimited': limit_gb == 0,
|
||||
'lifetime_used_bytes': traffic_stats.get('lifetime_used_traffic_bytes', 0),
|
||||
'lifetime_used_gb': round(traffic_stats.get('lifetime_used_traffic_gb', 0), 2),
|
||||
}
|
||||
|
||||
# Cache the result
|
||||
traffic_cache_key = cache_key('traffic', cache_suffix)
|
||||
await cache.set(traffic_cache_key, traffic_data, TRAFFIC_CACHE_TTL)
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'cached': False,
|
||||
'source': 'remnawave',
|
||||
**traffic_data,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Error refreshing traffic for user', user_id=user.id, error=e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to refresh traffic data',
|
||||
)
|
||||
@@ -317,7 +317,8 @@ async def notify_user_balance_change(
|
||||
|
||||
async def notify_user_subscription_activated(
|
||||
user_id: int,
|
||||
expires_at: str,
|
||||
subscription_id: int | None = None,
|
||||
expires_at: str = '',
|
||||
tariff_name: str = '',
|
||||
) -> None:
|
||||
"""Уведомить пользователя об активации подписки."""
|
||||
@@ -325,6 +326,7 @@ async def notify_user_subscription_activated(
|
||||
user_id,
|
||||
{
|
||||
'type': 'subscription.activated',
|
||||
'subscription_id': subscription_id,
|
||||
'expires_at': expires_at,
|
||||
'tariff_name': tariff_name,
|
||||
},
|
||||
@@ -359,7 +361,8 @@ async def notify_user_subscription_expired(user_id: int) -> None:
|
||||
|
||||
async def notify_user_subscription_renewed(
|
||||
user_id: int,
|
||||
new_expires_at: str,
|
||||
subscription_id: int | None = None,
|
||||
new_expires_at: str = '',
|
||||
amount_kopeks: int = 0,
|
||||
) -> None:
|
||||
"""Уведомить пользователя о продлении подписки."""
|
||||
@@ -367,6 +370,7 @@ async def notify_user_subscription_renewed(
|
||||
user_id,
|
||||
{
|
||||
'type': 'subscription.renewed',
|
||||
'subscription_id': subscription_id,
|
||||
'new_expires_at': new_expires_at,
|
||||
'amount_kopeks': amount_kopeks,
|
||||
'amount_rubles': amount_kopeks / 100,
|
||||
|
||||
@@ -20,6 +20,7 @@ from app.cabinet.schemas.wheel import (
|
||||
WheelConfigResponse,
|
||||
WheelPrizeDisplay,
|
||||
)
|
||||
from app.config import settings
|
||||
from app.database.crud.wheel import (
|
||||
get_or_create_wheel_config,
|
||||
get_user_spin_history,
|
||||
@@ -48,10 +49,22 @@ async def get_wheel_config(
|
||||
# Проверяем доступность
|
||||
availability = await wheel_service.check_availability(db, user)
|
||||
|
||||
# Проверяем наличие подписки
|
||||
from app.database.crud.subscription import get_subscription_by_user_id
|
||||
# Проверяем наличие подписки (multi-tariff aware)
|
||||
if settings.is_multi_tariff_enabled():
|
||||
from app.database.crud.subscription import get_active_subscriptions_by_user_id
|
||||
|
||||
subscription = await get_subscription_by_user_id(db, user.id)
|
||||
active_subs = await get_active_subscriptions_by_user_id(db, user.id)
|
||||
# Check if user has any active subscription for wheel access
|
||||
if active_subs:
|
||||
_non_daily = [s for s in active_subs if not getattr(s, 'is_daily_tariff', False)]
|
||||
_pool = _non_daily or active_subs
|
||||
subscription = max(_pool, key=lambda s: s.days_left)
|
||||
else:
|
||||
subscription = None
|
||||
else:
|
||||
from app.database.crud.subscription import get_subscription_by_user_id
|
||||
|
||||
subscription = await get_subscription_by_user_id(db, user.id)
|
||||
has_subscription = subscription is not None and subscription.is_active
|
||||
|
||||
prizes_display = [
|
||||
@@ -65,6 +78,14 @@ async def get_wheel_config(
|
||||
for p in prizes
|
||||
]
|
||||
|
||||
# Build eligible subscriptions for frontend picker
|
||||
eligible_subs_display = None
|
||||
if availability.eligible_subscriptions:
|
||||
eligible_subs_display = [
|
||||
{'id': s.id, 'tariff_name': s.tariff_name, 'days_left': s.days_left}
|
||||
for s in availability.eligible_subscriptions
|
||||
]
|
||||
|
||||
return WheelConfigResponse(
|
||||
is_enabled=config.is_enabled,
|
||||
name=config.name,
|
||||
@@ -82,6 +103,7 @@ async def get_wheel_config(
|
||||
user_balance_kopeks=availability.user_balance_kopeks,
|
||||
required_balance_kopeks=availability.required_balance_kopeks,
|
||||
has_subscription=has_subscription,
|
||||
eligible_subscriptions=eligible_subs_display,
|
||||
)
|
||||
|
||||
|
||||
@@ -113,7 +135,7 @@ async def spin_wheel(
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Крутить колесо удачи."""
|
||||
result = await wheel_service.spin(db, user, request.payment_type.value)
|
||||
result = await wheel_service.spin(db, user, request.payment_type.value, subscription_id=request.subscription_id)
|
||||
|
||||
if not result.success:
|
||||
# Возвращаем ошибку в теле ответа, а не HTTP exception
|
||||
@@ -218,10 +240,22 @@ async def create_stars_invoice(
|
||||
detail='Оплата Stars не включена',
|
||||
)
|
||||
|
||||
# Проверяем наличие активной подписки
|
||||
from app.database.crud.subscription import get_subscription_by_user_id
|
||||
# Проверяем наличие активной подписки (multi-tariff aware)
|
||||
if settings.is_multi_tariff_enabled():
|
||||
from app.database.crud.subscription import get_active_subscriptions_by_user_id
|
||||
|
||||
subscription = await get_subscription_by_user_id(db, user.id)
|
||||
active_subs = await get_active_subscriptions_by_user_id(db, user.id)
|
||||
# Check if user has any active subscription for Stars invoice
|
||||
if active_subs:
|
||||
_non_daily = [s for s in active_subs if not getattr(s, 'is_daily_tariff', False)]
|
||||
_pool = _non_daily or active_subs
|
||||
subscription = max(_pool, key=lambda s: s.days_left)
|
||||
else:
|
||||
subscription = None
|
||||
else:
|
||||
from app.database.crud.subscription import get_subscription_by_user_id
|
||||
|
||||
subscription = await get_subscription_by_user_id(db, user.id)
|
||||
if not subscription or not subscription.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
|
||||
@@ -138,6 +138,9 @@ class EmailRegisterStandaloneRequest(BaseModel):
|
||||
referral_code: str | None = Field(
|
||||
None, max_length=32, pattern=r'^[a-zA-Z0-9_-]+$', description='Referral code of inviter'
|
||||
)
|
||||
campaign_slug: str | None = Field(
|
||||
None, min_length=1, max_length=64, pattern=r'^[a-zA-Z0-9_-]+$', description='Campaign slug from web link'
|
||||
)
|
||||
|
||||
|
||||
class CampaignBonusInfo(BaseModel):
|
||||
@@ -198,6 +201,17 @@ class DeepLinkTokenResponse(BaseModel):
|
||||
|
||||
|
||||
class DeepLinkPollRequest(BaseModel):
|
||||
"""Request to poll deep link auth status."""
|
||||
"""Request to poll deep link auth status.
|
||||
|
||||
Deep link auth is always for existing bot users — referral codes are not applicable here.
|
||||
Only campaign_slug is supported (campaign bonus can apply to existing users).
|
||||
"""
|
||||
|
||||
token: str = Field(..., min_length=16, max_length=128, description='Deep link auth token')
|
||||
campaign_slug: str | None = Field(
|
||||
None,
|
||||
min_length=1,
|
||||
max_length=64,
|
||||
pattern=r'^[a-zA-Z0-9_-]+$',
|
||||
description='Campaign slug captured from cabinet URL',
|
||||
)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
|
||||
# ============ Channel Types ============
|
||||
@@ -75,6 +75,27 @@ class BroadcastButtonsResponse(BaseModel):
|
||||
buttons: list[BroadcastButton]
|
||||
|
||||
|
||||
class CustomBroadcastButton(BaseModel):
|
||||
"""Custom button for broadcast message."""
|
||||
|
||||
label: str = Field(..., min_length=1, max_length=64)
|
||||
action_type: Literal['callback', 'url'] = 'callback'
|
||||
action_value: str = Field(..., min_length=1, max_length=256)
|
||||
|
||||
@field_validator('action_value')
|
||||
@classmethod
|
||||
def validate_action_value(cls, v: str, info) -> str:
|
||||
action_type = info.data.get('action_type', 'callback')
|
||||
if action_type == 'url':
|
||||
if not v.startswith(('https://', 'tg://')):
|
||||
raise ValueError('URL must start with https:// or tg://')
|
||||
elif action_type == 'callback':
|
||||
# Telegram API limits callback_data to 64 bytes
|
||||
if len(v.encode('utf-8')) > 64:
|
||||
raise ValueError('Callback data must be at most 64 bytes')
|
||||
return v
|
||||
|
||||
|
||||
# ============ Media ============
|
||||
|
||||
|
||||
@@ -95,7 +116,9 @@ class BroadcastCreateRequest(BaseModel):
|
||||
target: str
|
||||
message_text: str = Field(..., min_length=1, max_length=4000)
|
||||
selected_buttons: list[str] = Field(default_factory=lambda: ['home'])
|
||||
custom_buttons: list[CustomBroadcastButton] = Field(default_factory=list, max_length=10)
|
||||
media: BroadcastMediaRequest | None = None
|
||||
category: str = Field(default='system', pattern='^(system|news|promo)$')
|
||||
|
||||
|
||||
# ============ Response ============
|
||||
@@ -122,6 +145,9 @@ class BroadcastResponse(BaseModel):
|
||||
completed_at: datetime | None = None
|
||||
progress_percent: float = 0.0
|
||||
|
||||
# Category for user notification preference filtering
|
||||
category: str = 'system' # system|news|promo
|
||||
|
||||
# Email/channel fields
|
||||
channel: str = 'telegram' # telegram|email|both
|
||||
email_subject: str | None = None
|
||||
@@ -187,8 +213,12 @@ class CombinedBroadcastCreateRequest(BaseModel):
|
||||
# Telegram-specific fields
|
||||
message_text: str | None = Field(default=None, max_length=4000)
|
||||
selected_buttons: list[str] = Field(default_factory=lambda: ['home'])
|
||||
custom_buttons: list[CustomBroadcastButton] = Field(default_factory=list, max_length=10)
|
||||
media: BroadcastMediaRequest | None = None
|
||||
|
||||
# Broadcast category for user notification preference filtering
|
||||
category: str = Field(default='system', pattern='^(system|news|promo)$')
|
||||
|
||||
# Email-specific fields
|
||||
email_subject: str | None = Field(default=None, max_length=255)
|
||||
email_html_content: str | None = Field(default=None, max_length=100000)
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
"""Schemas for news articles in cabinet.
|
||||
|
||||
Security notes:
|
||||
- featured_image_url is validated to only accept http/https schemes.
|
||||
- category_color is validated as a strict hex color (#RGB, #RRGGBB, etc.).
|
||||
- Slug is sanitized to only allow [a-zA-Z0-9_-].
|
||||
- Content is server-side sanitized to strip <script>, event handlers, and
|
||||
dangerous URI schemes as a defense-in-depth measure. The frontend also
|
||||
sanitizes via DOMPurify, but server-side sanitization protects against
|
||||
alternative consumers (mobile apps, RSS, email digests) and compromised
|
||||
frontends.
|
||||
"""
|
||||
|
||||
import re
|
||||
from datetime import datetime
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
|
||||
|
||||
# Pre-compiled regex for hex color validation (reused across validators)
|
||||
_HEX_COLOR_RE: re.Pattern[str] = re.compile(r'^#([0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$')
|
||||
|
||||
# Pre-compiled regex for collapsing repeated hyphens in slugs
|
||||
_MULTI_HYPHEN_RE: re.Pattern[str] = re.compile(r'-+')
|
||||
|
||||
# Maximum slug length (matches DB column constraint)
|
||||
_MAX_SLUG_LENGTH: int = 500
|
||||
|
||||
# Allowed URL schemes for user-supplied URLs (featured_image_url)
|
||||
_SAFE_URL_SCHEMES: frozenset[str] = frozenset({'http', 'https'})
|
||||
|
||||
# Cyrillic-to-Latin transliteration map for slug generation
|
||||
_TRANSLIT_MAP: dict[str, str] = {
|
||||
'а': 'a',
|
||||
'б': 'b',
|
||||
'в': 'v',
|
||||
'г': 'g',
|
||||
'д': 'd',
|
||||
'е': 'e',
|
||||
'ё': 'yo',
|
||||
'ж': 'zh',
|
||||
'з': 'z',
|
||||
'и': 'i',
|
||||
'й': 'y',
|
||||
'к': 'k',
|
||||
'л': 'l',
|
||||
'м': 'm',
|
||||
'н': 'n',
|
||||
'о': 'o',
|
||||
'п': 'p',
|
||||
'р': 'r',
|
||||
'с': 's',
|
||||
'т': 't',
|
||||
'у': 'u',
|
||||
'ф': 'f',
|
||||
'х': 'kh',
|
||||
'ц': 'ts',
|
||||
'ч': 'ch',
|
||||
'ш': 'sh',
|
||||
'щ': 'shch',
|
||||
'ъ': '',
|
||||
'ы': 'y',
|
||||
'ь': '',
|
||||
'э': 'e',
|
||||
'ю': 'yu',
|
||||
'я': 'ya',
|
||||
}
|
||||
|
||||
|
||||
def _slugify(title: str) -> str:
|
||||
"""Generate a URL-safe slug from a title, transliterating Cyrillic."""
|
||||
slug = title.lower()
|
||||
result: list[str] = []
|
||||
for ch in slug:
|
||||
if ch in _TRANSLIT_MAP:
|
||||
result.append(_TRANSLIT_MAP[ch])
|
||||
elif ch.isascii() and (ch.isalnum() or ch in '-_'):
|
||||
result.append(ch)
|
||||
elif ch == ' ':
|
||||
result.append('-')
|
||||
slug = ''.join(result)
|
||||
slug = _MULTI_HYPHEN_RE.sub('-', slug).strip('-')
|
||||
return slug[:_MAX_SLUG_LENGTH] or 'untitled'
|
||||
|
||||
|
||||
def _validate_hex_color(v: str) -> str:
|
||||
"""Validate a hex color string. Raises ValueError on invalid input."""
|
||||
if not _HEX_COLOR_RE.match(v):
|
||||
msg = 'category_color must be a valid hex color (e.g. #00e5a0)'
|
||||
raise ValueError(msg)
|
||||
return v
|
||||
|
||||
|
||||
def _validate_safe_url(v: str) -> str:
|
||||
"""Validate that a URL uses http or https scheme only.
|
||||
|
||||
Prevents javascript:, data:, vbscript:, and other dangerous URI schemes
|
||||
from being stored in the database and later rendered in <img> or <a> tags.
|
||||
"""
|
||||
try:
|
||||
parsed = urlparse(v)
|
||||
except Exception:
|
||||
msg = 'Invalid URL format'
|
||||
raise ValueError(msg)
|
||||
|
||||
if parsed.scheme not in _SAFE_URL_SCHEMES:
|
||||
msg = f'URL scheme must be http or https, got: {parsed.scheme!r}'
|
||||
raise ValueError(msg)
|
||||
|
||||
if not parsed.netloc:
|
||||
msg = 'URL must have a valid host'
|
||||
raise ValueError(msg)
|
||||
|
||||
return v
|
||||
|
||||
|
||||
# --- Server-side HTML content sanitization ---
|
||||
# Pre-compiled patterns for stripping the most dangerous HTML constructs.
|
||||
# This is a defense-in-depth measure: the frontend also sanitizes via DOMPurify.
|
||||
# Uses regex rather than a full HTML parser to avoid adding a new dependency.
|
||||
# Strips: <script>, <style>, <object>, <embed>, <applet>, <base>, <form>,
|
||||
# <link>, <meta> tags and all on* event handler attributes.
|
||||
_DANGEROUS_TAGS_RE: re.Pattern[str] = re.compile(
|
||||
r'<\s*/?\s*(script|style|object|embed|applet|base|form|link(?:\s)|meta)\b[^>]*>',
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
# Match on* event handler attributes, e.g. onclick="...", onerror='...'
|
||||
_EVENT_HANDLER_RE: re.Pattern[str] = re.compile(
|
||||
r'\s+on[a-z]+\s*=\s*(?:"[^"]*"|\'[^\']*\'|[^\s>]+)',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
# Match javascript:, vbscript:, data: in href/src attributes
|
||||
_DANGEROUS_URI_RE: re.Pattern[str] = re.compile(
|
||||
r'((?:href|src)\s*=\s*["\'])\s*(javascript|vbscript|data)\s*:',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _sanitize_html_content(html: str) -> str:
|
||||
"""Strip dangerous HTML constructs from article content.
|
||||
|
||||
This is NOT a replacement for DOMPurify on the frontend. It is a
|
||||
defense-in-depth layer that removes the most obvious XSS vectors
|
||||
at the storage boundary. A full HTML sanitizer (nh3, bleach) would
|
||||
be stronger, but this avoids adding a new dependency.
|
||||
"""
|
||||
if not html:
|
||||
return html
|
||||
|
||||
# 1. Remove dangerous tags and their content
|
||||
result = _DANGEROUS_TAGS_RE.sub('', html)
|
||||
|
||||
# Also strip <script>...</script> content (tag + body)
|
||||
result = re.sub(r'<script\b[^>]*>[\s\S]*?</script>', '', result, flags=re.IGNORECASE)
|
||||
result = re.sub(r'<style\b[^>]*>[\s\S]*?</style>', '', result, flags=re.IGNORECASE)
|
||||
|
||||
# 2. Remove event handler attributes
|
||||
result = _EVENT_HANDLER_RE.sub('', result)
|
||||
|
||||
# 3. Neutralize dangerous URI schemes in href/src
|
||||
result = _DANGEROUS_URI_RE.sub(r'\1about:', result)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
class NewsArticleResponse(BaseModel):
|
||||
"""Full news article response (detail view)."""
|
||||
|
||||
id: int
|
||||
title: str
|
||||
slug: str
|
||||
content: str
|
||||
excerpt: str | None
|
||||
category: str
|
||||
category_color: str
|
||||
tag: str | None
|
||||
category_id: int | None = None
|
||||
tag_id: int | None = None
|
||||
featured_image_url: str | None
|
||||
is_published: bool
|
||||
is_featured: bool
|
||||
published_at: datetime | None
|
||||
read_time_minutes: int
|
||||
views_count: int
|
||||
author_name: str | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime | None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class NewsArticleListItem(BaseModel):
|
||||
"""Compact news article for list views."""
|
||||
|
||||
id: int
|
||||
title: str
|
||||
slug: str
|
||||
excerpt: str | None
|
||||
category: str
|
||||
category_color: str
|
||||
tag: str | None
|
||||
category_id: int | None = None
|
||||
tag_id: int | None = None
|
||||
featured_image_url: str | None
|
||||
is_published: bool
|
||||
is_featured: bool
|
||||
published_at: datetime | None
|
||||
read_time_minutes: int
|
||||
views_count: int
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class NewsListResponse(BaseModel):
|
||||
"""Paginated list of news articles."""
|
||||
|
||||
items: list[NewsArticleListItem]
|
||||
total: int
|
||||
categories: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class NewsCreateRequest(BaseModel):
|
||||
"""Request to create a news article."""
|
||||
|
||||
title: str = Field(..., min_length=1, max_length=500)
|
||||
slug: str | None = Field(None, min_length=1, max_length=500)
|
||||
content: str = Field(default='', max_length=500_000)
|
||||
excerpt: str | None = Field(None, max_length=1000)
|
||||
category: str = Field(..., min_length=1, max_length=100)
|
||||
category_color: str = Field(default='#00e5a0', max_length=20)
|
||||
tag: str | None = Field(None, max_length=50)
|
||||
category_id: int | None = None
|
||||
tag_id: int | None = None
|
||||
featured_image_url: str | None = Field(None, max_length=2000)
|
||||
is_published: bool = False
|
||||
is_featured: bool = False
|
||||
read_time_minutes: int = Field(default=1, ge=1, le=60)
|
||||
|
||||
@field_validator('content')
|
||||
@classmethod
|
||||
def sanitize_content(cls, v: str) -> str:
|
||||
"""Strip dangerous HTML from article content (defense-in-depth)."""
|
||||
return _sanitize_html_content(v)
|
||||
|
||||
@field_validator('category_color')
|
||||
@classmethod
|
||||
def validate_hex_color(cls, v: str) -> str:
|
||||
return _validate_hex_color(v)
|
||||
|
||||
@field_validator('featured_image_url')
|
||||
@classmethod
|
||||
def validate_featured_image_url(cls, v: str | None) -> str | None:
|
||||
"""Reject javascript:, data:, and other dangerous URL schemes."""
|
||||
if v is not None:
|
||||
return _validate_safe_url(v)
|
||||
return v
|
||||
|
||||
@model_validator(mode='before')
|
||||
@classmethod
|
||||
def auto_generate_slug(cls, data: dict) -> dict: # type: ignore[type-arg]
|
||||
"""Generate slug from title when not explicitly provided."""
|
||||
if isinstance(data, dict) and not data.get('slug'):
|
||||
title = data.get('title', '')
|
||||
data['slug'] = _slugify(title) if isinstance(title, str) else 'untitled'
|
||||
return data
|
||||
|
||||
@field_validator('slug')
|
||||
@classmethod
|
||||
def sanitize_slug(cls, v: str | None) -> str | None:
|
||||
"""Ensure slug contains only URL-safe characters, transliterating Cyrillic."""
|
||||
if v is not None:
|
||||
return _slugify(v)
|
||||
return v
|
||||
|
||||
|
||||
class NewsUpdateRequest(BaseModel):
|
||||
"""Request to update a news article."""
|
||||
|
||||
title: str | None = Field(None, min_length=1, max_length=500)
|
||||
slug: str | None = Field(None, min_length=1, max_length=500)
|
||||
content: str | None = Field(None, max_length=500_000)
|
||||
excerpt: str | None = None
|
||||
category: str | None = Field(None, min_length=1, max_length=100)
|
||||
category_color: str | None = Field(None, max_length=20)
|
||||
tag: str | None = None
|
||||
category_id: int | None = None
|
||||
tag_id: int | None = None
|
||||
featured_image_url: str | None = Field(None, max_length=2000)
|
||||
is_published: bool | None = None
|
||||
is_featured: bool | None = None
|
||||
read_time_minutes: int | None = Field(None, ge=1, le=60)
|
||||
|
||||
@field_validator('content')
|
||||
@classmethod
|
||||
def sanitize_content(cls, v: str | None) -> str | None:
|
||||
"""Strip dangerous HTML from article content (defense-in-depth)."""
|
||||
if v is not None:
|
||||
return _sanitize_html_content(v)
|
||||
return v
|
||||
|
||||
@field_validator('category_color')
|
||||
@classmethod
|
||||
def validate_hex_color(cls, v: str | None) -> str | None:
|
||||
if v is not None:
|
||||
return _validate_hex_color(v)
|
||||
return v
|
||||
|
||||
@field_validator('featured_image_url')
|
||||
@classmethod
|
||||
def validate_featured_image_url(cls, v: str | None) -> str | None:
|
||||
"""Reject javascript:, data:, and other dangerous URL schemes."""
|
||||
if v is not None:
|
||||
return _validate_safe_url(v)
|
||||
return v
|
||||
|
||||
@field_validator('slug')
|
||||
@classmethod
|
||||
def sanitize_slug(cls, v: str | None) -> str | None:
|
||||
"""Ensure slug contains only URL-safe characters, transliterating Cyrillic."""
|
||||
if v is not None:
|
||||
return _slugify(v)
|
||||
return v
|
||||
|
||||
|
||||
class NewsToggleResponse(BaseModel):
|
||||
"""Response after toggling publish/featured status."""
|
||||
|
||||
id: int
|
||||
is_published: bool
|
||||
is_featured: bool
|
||||
published_at: datetime | None
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Schemas for news categories."""
|
||||
|
||||
import re
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
|
||||
_HEX_COLOR_RE: re.Pattern[str] = re.compile(r'^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$')
|
||||
|
||||
|
||||
class NewsCategoryCreate(BaseModel):
|
||||
"""Request to create a news category."""
|
||||
|
||||
name: str = Field(..., min_length=1, max_length=100)
|
||||
color: str = Field(default='#00e5a0', max_length=20)
|
||||
|
||||
@field_validator('color')
|
||||
@classmethod
|
||||
def validate_color(cls, v: str) -> str:
|
||||
if not _HEX_COLOR_RE.match(v):
|
||||
msg = 'Invalid hex color'
|
||||
raise ValueError(msg)
|
||||
return v
|
||||
|
||||
|
||||
class NewsCategoryUpdate(BaseModel):
|
||||
"""Request to update a news category."""
|
||||
|
||||
name: str | None = Field(None, min_length=1, max_length=100)
|
||||
color: str | None = Field(None, max_length=20)
|
||||
|
||||
@field_validator('color')
|
||||
@classmethod
|
||||
def validate_color(cls, v: str | None) -> str | None:
|
||||
if v is not None and not _HEX_COLOR_RE.match(v):
|
||||
msg = 'Invalid hex color'
|
||||
raise ValueError(msg)
|
||||
return v
|
||||
|
||||
|
||||
class NewsCategoryResponse(BaseModel):
|
||||
"""News category response."""
|
||||
|
||||
id: int
|
||||
name: str
|
||||
color: str
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
@@ -0,0 +1,17 @@
|
||||
"""Schemas for news media upload responses."""
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class NewsMediaUploadResponse(BaseModel):
|
||||
"""Response returned after a successful media upload."""
|
||||
|
||||
url: str
|
||||
thumbnail_url: str | None = None
|
||||
media_type: Literal['image', 'video']
|
||||
filename: str
|
||||
size_bytes: int
|
||||
width: int | None = None
|
||||
height: int | None = None
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Schemas for news tags."""
|
||||
|
||||
import re
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
|
||||
_HEX_COLOR_RE: re.Pattern[str] = re.compile(r'^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$')
|
||||
|
||||
|
||||
class NewsTagCreate(BaseModel):
|
||||
"""Request to create a news tag."""
|
||||
|
||||
name: str = Field(..., min_length=1, max_length=50)
|
||||
color: str = Field(default='#94a3b8', max_length=20)
|
||||
|
||||
@field_validator('color')
|
||||
@classmethod
|
||||
def validate_color(cls, v: str) -> str:
|
||||
if not _HEX_COLOR_RE.match(v):
|
||||
msg = 'Invalid hex color'
|
||||
raise ValueError(msg)
|
||||
return v
|
||||
|
||||
|
||||
class NewsTagUpdate(BaseModel):
|
||||
"""Request to update a news tag."""
|
||||
|
||||
name: str | None = Field(None, min_length=1, max_length=50)
|
||||
color: str | None = Field(None, max_length=20)
|
||||
|
||||
@field_validator('color')
|
||||
@classmethod
|
||||
def validate_color(cls, v: str | None) -> str | None:
|
||||
if v is not None and not _HEX_COLOR_RE.match(v):
|
||||
msg = 'Invalid hex color'
|
||||
raise ValueError(msg)
|
||||
return v
|
||||
|
||||
|
||||
class NewsTagResponse(BaseModel):
|
||||
"""News tag response."""
|
||||
|
||||
id: int
|
||||
name: str
|
||||
color: str
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
@@ -47,11 +47,9 @@ class ServerInfo(BaseModel):
|
||||
"""Server hardware info."""
|
||||
|
||||
cpu_cores: int
|
||||
cpu_physical_cores: int
|
||||
memory_total: int
|
||||
memory_used: int
|
||||
memory_free: int
|
||||
memory_available: int
|
||||
uptime_seconds: int
|
||||
|
||||
|
||||
@@ -108,22 +106,22 @@ class NodeInfo(BaseModel):
|
||||
is_disabled: bool
|
||||
is_node_online: bool
|
||||
is_xray_running: bool
|
||||
users_online: int | None = None
|
||||
users_online: int = 0
|
||||
traffic_used_bytes: int | None = None
|
||||
traffic_limit_bytes: int | None = None
|
||||
last_status_change: datetime | None = None
|
||||
last_status_message: str | None = None
|
||||
xray_uptime: str | None = None
|
||||
xray_uptime: int = 0
|
||||
is_traffic_tracking_active: bool = False
|
||||
traffic_reset_day: int | None = None
|
||||
notify_percent: int | None = None
|
||||
consumption_multiplier: float = 1.0
|
||||
cpu_count: int | None = None
|
||||
cpu_model: str | None = None
|
||||
total_ram: str | None = None
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
provider_uuid: str | None = None
|
||||
versions: dict[str, str] | None = None
|
||||
system: dict[str, Any] | None = None
|
||||
active_plugin_uuid: str | None = None
|
||||
|
||||
|
||||
class NodesListResponse(BaseModel):
|
||||
|
||||
@@ -88,6 +88,10 @@ class RenewalRequest(BaseModel):
|
||||
"""Request to renew subscription."""
|
||||
|
||||
period_days: int = Field(..., ge=1, le=3650, description='Renewal period in days')
|
||||
subscription_id: int | None = Field(
|
||||
default=None,
|
||||
description='ID of subscription to renew (required in multi-tariff mode)',
|
||||
)
|
||||
|
||||
|
||||
class TrafficPackageResponse(BaseModel):
|
||||
|
||||
@@ -112,7 +112,7 @@ class TariffDetailResponse(BaseModel):
|
||||
is_daily: bool = False
|
||||
daily_price_kopeks: int = 0
|
||||
# Режим сброса трафика
|
||||
traffic_reset_mode: str | None = None # DAY, WEEK, MONTH, NO_RESET, None = глобальная настройка
|
||||
traffic_reset_mode: str | None = None # DAY, WEEK, MONTH, MONTH_ROLLING, NO_RESET, None = глобальная настройка
|
||||
# Внешний сквад RemnaWave
|
||||
external_squad_uuid: str | None = None
|
||||
# Показывать в подарках
|
||||
@@ -170,7 +170,7 @@ class TariffCreateRequest(BaseModel):
|
||||
is_daily: bool = False
|
||||
daily_price_kopeks: int = Field(0, ge=0)
|
||||
# Режим сброса трафика
|
||||
traffic_reset_mode: str | None = None # DAY, WEEK, MONTH, NO_RESET, None = глобальная настройка
|
||||
traffic_reset_mode: str | None = None # DAY, WEEK, MONTH, MONTH_ROLLING, NO_RESET, None = глобальная настройка
|
||||
# Внешний сквад RemnaWave
|
||||
external_squad_uuid: str | None = Field(None, pattern=UUID_PATTERN)
|
||||
# Показывать в подарках
|
||||
@@ -211,7 +211,7 @@ class TariffUpdateRequest(BaseModel):
|
||||
is_daily: bool | None = None
|
||||
daily_price_kopeks: int | None = Field(None, ge=0)
|
||||
# Режим сброса трафика
|
||||
traffic_reset_mode: str | None = None # DAY, WEEK, MONTH, NO_RESET, None = глобальная настройка
|
||||
traffic_reset_mode: str | None = None # DAY, WEEK, MONTH, MONTH_ROLLING, NO_RESET, None = глобальная настройка
|
||||
# Внешний сквад RemnaWave
|
||||
external_squad_uuid: str | None = Field(None, pattern=UUID_PATTERN)
|
||||
# Показывать в подарках
|
||||
|
||||
@@ -9,18 +9,31 @@ class TrafficNodeInfo(BaseModel):
|
||||
country_code: str
|
||||
|
||||
|
||||
class SubscriptionTrafficInfo(BaseModel):
|
||||
"""Per-subscription traffic metadata for multi-subscription display."""
|
||||
|
||||
subscription_id: int
|
||||
tariff_name: str | None
|
||||
status: str | None
|
||||
traffic_limit_gb: float
|
||||
device_limit: int
|
||||
|
||||
|
||||
class UserTrafficItem(BaseModel):
|
||||
user_id: int
|
||||
telegram_id: int | None
|
||||
username: str | None
|
||||
email: str | None
|
||||
full_name: str
|
||||
# Primary subscription fields (backward compat — reflect the active/first sub)
|
||||
tariff_name: str | None
|
||||
subscription_status: str | None
|
||||
traffic_limit_gb: float
|
||||
device_limit: int
|
||||
node_traffic: dict[str, int] # {node_uuid: total_bytes}
|
||||
total_bytes: int
|
||||
# All subscriptions for multi-subscription display
|
||||
subscriptions: list[SubscriptionTrafficInfo] = Field(default_factory=list)
|
||||
|
||||
|
||||
class TrafficUsageResponse(BaseModel):
|
||||
@@ -34,12 +47,24 @@ class TrafficUsageResponse(BaseModel):
|
||||
available_statuses: list[str]
|
||||
|
||||
|
||||
class SubscriptionEnrichmentInfo(BaseModel):
|
||||
"""Per-subscription enrichment (dates) for multi-subscription display."""
|
||||
|
||||
subscription_id: int
|
||||
tariff_name: str | None
|
||||
start_date: str | None
|
||||
end_date: str | None
|
||||
|
||||
|
||||
class UserTrafficEnrichment(BaseModel):
|
||||
devices_connected: int = 0
|
||||
total_spent_kopeks: int = 0
|
||||
# Primary subscription dates (backward compat — reflect the active/first sub)
|
||||
subscription_start_date: str | None = None
|
||||
subscription_end_date: str | None = None
|
||||
last_node_name: str | None = None
|
||||
# All subscriptions for multi-subscription display
|
||||
subscriptions: list[SubscriptionEnrichmentInfo] = Field(default_factory=list)
|
||||
|
||||
|
||||
class TrafficEnrichmentResponse(BaseModel):
|
||||
|
||||
@@ -177,9 +177,12 @@ class UserDetailResponse(BaseModel):
|
||||
last_activity: datetime | None = None
|
||||
cabinet_last_login: datetime | None = None
|
||||
|
||||
# Subscription
|
||||
# Subscription (legacy single, kept for backward compat)
|
||||
subscription: UserSubscriptionInfo | None = None
|
||||
|
||||
# All subscriptions (multi-tariff)
|
||||
subscriptions: list[UserSubscriptionInfo] = []
|
||||
|
||||
# Promo group
|
||||
promo_group: UserPromoGroupInfo | None = None
|
||||
|
||||
@@ -285,6 +288,9 @@ class UpdateSubscriptionRequest(BaseModel):
|
||||
..., description='Action: extend, shorten, set_end_date, change_tariff, set_traffic, toggle_autopay, cancel'
|
||||
)
|
||||
|
||||
# Target subscription (required in multi-tariff mode for non-create actions)
|
||||
subscription_id: int | None = Field(None, description='Subscription ID to target (multi-tariff)')
|
||||
|
||||
# For extend action
|
||||
days: int | None = Field(None, ge=1, le=3650, description='Days to extend')
|
||||
|
||||
@@ -387,6 +393,37 @@ class UpdateReferralCommissionResponse(BaseModel):
|
||||
message: str
|
||||
|
||||
|
||||
class AssignReferrerRequest(BaseModel):
|
||||
"""Request to manually assign a referrer to a user."""
|
||||
|
||||
referrer_id: int = Field(..., gt=0, description='ID of the referrer user')
|
||||
|
||||
|
||||
class AssignReferrerResponse(BaseModel):
|
||||
"""Response after referrer assignment."""
|
||||
|
||||
success: bool
|
||||
old_referrer_id: int | None = None
|
||||
new_referrer_id: int | None = None
|
||||
message: str
|
||||
|
||||
|
||||
class RemoveReferrerResponse(BaseModel):
|
||||
"""Response after removing a user's referrer."""
|
||||
|
||||
success: bool
|
||||
old_referrer_id: int | None = None
|
||||
message: str
|
||||
|
||||
|
||||
class RemoveReferralResponse(BaseModel):
|
||||
"""Response after removing a specific referral from a user."""
|
||||
|
||||
success: bool
|
||||
removed_user_id: int
|
||||
message: str
|
||||
|
||||
|
||||
class DeviceInfo(BaseModel):
|
||||
"""Individual device info."""
|
||||
|
||||
@@ -608,6 +645,10 @@ class PanelSyncStatusResponse(BaseModel):
|
||||
remnawave_uuid: str | None = None
|
||||
last_sync: datetime | None = None
|
||||
|
||||
# Multi-tariff context
|
||||
subscription_id: int | None = None
|
||||
subscription_tariff_name: str | None = None
|
||||
|
||||
# Bot data
|
||||
bot_subscription_status: str | None = None
|
||||
bot_subscription_end_date: datetime | None = None
|
||||
|
||||
@@ -61,6 +61,7 @@ class WheelConfigResponse(BaseModel):
|
||||
user_balance_kopeks: int = 0
|
||||
required_balance_kopeks: int = 0
|
||||
has_subscription: bool = False
|
||||
eligible_subscriptions: list[dict] | None = None
|
||||
|
||||
|
||||
class SpinAvailabilityResponse(BaseModel):
|
||||
@@ -81,6 +82,7 @@ class SpinRequest(BaseModel):
|
||||
"""Запрос на спин."""
|
||||
|
||||
payment_type: WheelPaymentType
|
||||
subscription_id: int | None = None
|
||||
|
||||
|
||||
class SpinResultResponse(BaseModel):
|
||||
|
||||
@@ -351,18 +351,26 @@ class EmailNotificationTemplates:
|
||||
"""Template for subscription expiring notification."""
|
||||
days_left = context.get('days_left', 0)
|
||||
expires_at = context.get('expires_at', '')
|
||||
tariff_name = html.escape(context.get('tariff_name', ''))
|
||||
tariff_suffix_ru = f' «{tariff_name}»' if tariff_name else ''
|
||||
tariff_suffix_en = f' "{tariff_name}"' if tariff_name else ''
|
||||
tariff_line_ru = f'<p>Тариф: <strong>{tariff_name}</strong></p>' if tariff_name else ''
|
||||
tariff_line_en = f'<p>Plan: <strong>{tariff_name}</strong></p>' if tariff_name else ''
|
||||
tariff_line_zh = f'<p>套餐: <strong>{tariff_name}</strong></p>' if tariff_name else ''
|
||||
tariff_line_ua = f'<p>Тариф: <strong>{tariff_name}</strong></p>' if tariff_name else ''
|
||||
|
||||
subjects = {
|
||||
'ru': f'Подписка истекает через {days_left} дн.',
|
||||
'en': f'Subscription expires in {days_left} day(s)',
|
||||
'ru': f'Подписка{tariff_suffix_ru} истекает через {days_left} дн.',
|
||||
'en': f'Subscription{tariff_suffix_en} expires in {days_left} day(s)',
|
||||
'zh': f'订阅将在 {days_left} 天后到期',
|
||||
'ua': f'Підписка закінчується через {days_left} дн.',
|
||||
'ua': f'Підписка{tariff_suffix_ru} закінчується через {days_left} дн.',
|
||||
}
|
||||
|
||||
bodies = {
|
||||
'ru': f"""
|
||||
<h2>Подписка скоро истекает</h2>
|
||||
<div class="highlight warning">
|
||||
{tariff_line_ru}
|
||||
<p>Ваша подписка истекает через <strong>{days_left}</strong> дн.</p>
|
||||
<p>Дата истечения: <strong>{expires_at}</strong></p>
|
||||
</div>
|
||||
@@ -372,6 +380,7 @@ class EmailNotificationTemplates:
|
||||
'en': f"""
|
||||
<h2>Subscription Expiring Soon</h2>
|
||||
<div class="highlight warning">
|
||||
{tariff_line_en}
|
||||
<p>Your subscription expires in <strong>{days_left}</strong> day(s).</p>
|
||||
<p>Expiration date: <strong>{expires_at}</strong></p>
|
||||
</div>
|
||||
@@ -381,6 +390,7 @@ class EmailNotificationTemplates:
|
||||
'zh': f"""
|
||||
<h2>订阅即将到期</h2>
|
||||
<div class="highlight warning">
|
||||
{tariff_line_zh}
|
||||
<p>您的订阅将在 <strong>{days_left}</strong> 天后到期。</p>
|
||||
<p>到期日期: <strong>{expires_at}</strong></p>
|
||||
</div>
|
||||
@@ -390,6 +400,7 @@ class EmailNotificationTemplates:
|
||||
'ua': f"""
|
||||
<h2>Підписка скоро закінчується</h2>
|
||||
<div class="highlight warning">
|
||||
{tariff_line_ua}
|
||||
<p>Ваша підписка закінчується через <strong>{days_left}</strong> дн.</p>
|
||||
<p>Дата закінчення: <strong>{expires_at}</strong></p>
|
||||
</div>
|
||||
@@ -405,17 +416,26 @@ class EmailNotificationTemplates:
|
||||
|
||||
def _subscription_expired_template(self, language: str, context: dict[str, Any]) -> dict[str, str]:
|
||||
"""Template for subscription expired notification."""
|
||||
tariff_name = html.escape(context.get('tariff_name', ''))
|
||||
tariff_suffix_ru = f' «{tariff_name}»' if tariff_name else ''
|
||||
tariff_suffix_en = f' "{tariff_name}"' if tariff_name else ''
|
||||
tariff_line_ru = f'<p>Тариф: <strong>{tariff_name}</strong></p>' if tariff_name else ''
|
||||
tariff_line_en = f'<p>Plan: <strong>{tariff_name}</strong></p>' if tariff_name else ''
|
||||
tariff_line_zh = f'<p>套餐: <strong>{tariff_name}</strong></p>' if tariff_name else ''
|
||||
tariff_line_ua = f'<p>Тариф: <strong>{tariff_name}</strong></p>' if tariff_name else ''
|
||||
|
||||
subjects = {
|
||||
'ru': 'Подписка истекла',
|
||||
'en': 'Subscription Expired',
|
||||
'ru': f'Подписка{tariff_suffix_ru} истекла',
|
||||
'en': f'Subscription{tariff_suffix_en} Expired',
|
||||
'zh': '订阅已到期',
|
||||
'ua': 'Підписка закінчилась',
|
||||
'ua': f'Підписка{tariff_suffix_ru} закінчилась',
|
||||
}
|
||||
|
||||
bodies = {
|
||||
'ru': f"""
|
||||
<h2>Подписка истекла</h2>
|
||||
<div class="highlight danger">
|
||||
{tariff_line_ru}
|
||||
<p>Ваша подписка истекла. Доступ к VPN отключён.</p>
|
||||
</div>
|
||||
<p>Оформите новую подписку, чтобы продолжить использование сервиса.</p>
|
||||
@@ -424,6 +444,7 @@ class EmailNotificationTemplates:
|
||||
'en': f"""
|
||||
<h2>Subscription Expired</h2>
|
||||
<div class="highlight danger">
|
||||
{tariff_line_en}
|
||||
<p>Your subscription has expired. VPN access has been disabled.</p>
|
||||
</div>
|
||||
<p>Purchase a new subscription to continue using our service.</p>
|
||||
@@ -432,6 +453,7 @@ class EmailNotificationTemplates:
|
||||
'zh': f"""
|
||||
<h2>订阅已到期</h2>
|
||||
<div class="highlight danger">
|
||||
{tariff_line_zh}
|
||||
<p>您的订阅已到期。VPN访问已被禁用。</p>
|
||||
</div>
|
||||
<p>请购买新订阅以继续使用我们的服务。</p>
|
||||
@@ -440,6 +462,7 @@ class EmailNotificationTemplates:
|
||||
'ua': f"""
|
||||
<h2>Підписка закінчилась</h2>
|
||||
<div class="highlight danger">
|
||||
{tariff_line_ua}
|
||||
<p>Ваша підписка закінчилась. Доступ до VPN вимкнено.</p>
|
||||
</div>
|
||||
<p>Оформіть нову підписку, щоб продовжити використання сервісу.</p>
|
||||
@@ -455,18 +478,24 @@ class EmailNotificationTemplates:
|
||||
def _subscription_renewed_template(self, language: str, context: dict[str, Any]) -> dict[str, str]:
|
||||
"""Template for subscription renewed notification."""
|
||||
new_expires_at = context.get('new_expires_at', '')
|
||||
tariff_name = html.escape(context.get('tariff_name', ''))
|
||||
tariff_suffix_ru = f' «{tariff_name}»' if tariff_name else ''
|
||||
tariff_suffix_en = f' "{tariff_name}"' if tariff_name else ''
|
||||
tariff_line_ru = f'<p>Тариф: <strong>{tariff_name}</strong></p>' if tariff_name else ''
|
||||
tariff_line_en = f'<p>Plan: <strong>{tariff_name}</strong></p>' if tariff_name else ''
|
||||
|
||||
subjects = {
|
||||
'ru': 'Подписка продлена',
|
||||
'en': 'Subscription Renewed',
|
||||
'ru': f'Подписка{tariff_suffix_ru} продлена',
|
||||
'en': f'Subscription{tariff_suffix_en} Renewed',
|
||||
'zh': '订阅已续订',
|
||||
'ua': 'Підписку продовжено',
|
||||
'ua': f'Підписку{tariff_suffix_ru} продовжено',
|
||||
}
|
||||
|
||||
bodies = {
|
||||
'ru': f"""
|
||||
<h2>Подписка успешно продлена!</h2>
|
||||
<div class="highlight success">
|
||||
{tariff_line_ru}
|
||||
<p>Ваша подписка была успешно продлена.</p>
|
||||
<p>Новая дата истечения: <strong>{new_expires_at}</strong></p>
|
||||
</div>
|
||||
@@ -476,6 +505,7 @@ class EmailNotificationTemplates:
|
||||
'en': f"""
|
||||
<h2>Subscription Successfully Renewed!</h2>
|
||||
<div class="highlight success">
|
||||
{tariff_line_en}
|
||||
<p>Your subscription has been successfully renewed.</p>
|
||||
<p>New expiration date: <strong>{new_expires_at}</strong></p>
|
||||
</div>
|
||||
@@ -492,18 +522,24 @@ class EmailNotificationTemplates:
|
||||
def _subscription_activated_template(self, language: str, context: dict[str, Any]) -> dict[str, str]:
|
||||
"""Template for subscription activated notification."""
|
||||
expires_at = context.get('expires_at', '')
|
||||
tariff_name = html.escape(context.get('tariff_name', ''))
|
||||
tariff_suffix_ru = f' «{tariff_name}»' if tariff_name else ''
|
||||
tariff_suffix_en = f' "{tariff_name}"' if tariff_name else ''
|
||||
tariff_line_ru = f'<p>Тариф: <strong>{tariff_name}</strong></p>' if tariff_name else ''
|
||||
tariff_line_en = f'<p>Plan: <strong>{tariff_name}</strong></p>' if tariff_name else ''
|
||||
|
||||
subjects = {
|
||||
'ru': 'Подписка активирована',
|
||||
'en': 'Subscription Activated',
|
||||
'ru': f'Подписка{tariff_suffix_ru} активирована',
|
||||
'en': f'Subscription{tariff_suffix_en} Activated',
|
||||
'zh': '订阅已激活',
|
||||
'ua': 'Підписку активовано',
|
||||
'ua': f'Підписку{tariff_suffix_ru} активовано',
|
||||
}
|
||||
|
||||
bodies = {
|
||||
'ru': f"""
|
||||
<h2>Подписка активирована!</h2>
|
||||
<div class="highlight success">
|
||||
{tariff_line_ru}
|
||||
<p>Ваша VPN подписка успешно активирована.</p>
|
||||
<p>Действует до: <strong>{expires_at}</strong></p>
|
||||
</div>
|
||||
@@ -513,6 +549,7 @@ class EmailNotificationTemplates:
|
||||
'en': f"""
|
||||
<h2>Subscription Activated!</h2>
|
||||
<div class="highlight success">
|
||||
{tariff_line_en}
|
||||
<p>Your VPN subscription has been successfully activated.</p>
|
||||
<p>Valid until: <strong>{expires_at}</strong></p>
|
||||
</div>
|
||||
|
||||
+71
-19
@@ -133,6 +133,7 @@ class Settings(BaseSettings):
|
||||
WEBHOOK_NOTIFY_NOT_CONNECTED: bool = True
|
||||
WEBHOOK_NOTIFY_BANDWIDTH_THRESHOLD: bool = True
|
||||
WEBHOOK_NOTIFY_DEVICES: bool = True
|
||||
WEBHOOK_NOTIFY_TORRENT_DETECTED: bool = True
|
||||
|
||||
TRIAL_DURATION_DAYS: int = 3
|
||||
TRIAL_TRAFFIC_LIMIT_GB: int = 10
|
||||
@@ -208,6 +209,11 @@ class Settings(BaseSettings):
|
||||
# - tariffs: режим тарифов (готовые пакеты с фиксированными параметрами)
|
||||
SALES_MODE: str = 'tariffs'
|
||||
|
||||
# Multi-tariff mode: allows users to purchase multiple tariffs simultaneously
|
||||
# Only works when SALES_MODE='tariffs'
|
||||
MULTI_TARIFF_ENABLED: bool = False
|
||||
MAX_ACTIVE_SUBSCRIPTIONS: int = 10
|
||||
|
||||
# ID тарифа для триала в режиме тарифов (0 = использовать стандартные настройки триала)
|
||||
# Если указан ID тарифа, параметры триала берутся из тарифа (traffic_limit_gb, device_limit, allowed_squads)
|
||||
# Длительность триала всё равно берётся из TRIAL_DURATION_DAYS
|
||||
@@ -367,6 +373,7 @@ class Settings(BaseSettings):
|
||||
YOOKASSA_MAX_AMOUNT_KOPEKS: int = 1000000
|
||||
YOOKASSA_RECURRENT_ENABLED: bool = False
|
||||
YOOKASSA_RECURRENT_REQUIRED: bool = False
|
||||
YOOKASSA_TEST_MODE: bool = False
|
||||
SUPPORT_TOPUP_ENABLED: bool = True
|
||||
PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED: bool = False
|
||||
PAYMENT_VERIFICATION_AUTO_CHECK_INTERVAL_MINUTES: int = 10
|
||||
@@ -376,6 +383,7 @@ class Settings(BaseSettings):
|
||||
NALOGO_PASSWORD: str | None = None
|
||||
NALOGO_DEVICE_ID: str | None = None
|
||||
NALOGO_STORAGE_PATH: str = './nalogo_tokens.json'
|
||||
NALOGO_PROXY_URL: str | None = None # SOCKS proxy for nalog.ru; falls back to PROXY_URL if not set
|
||||
|
||||
AUTO_PURCHASE_AFTER_TOPUP_ENABLED: bool = False
|
||||
|
||||
@@ -460,7 +468,8 @@ class Settings(BaseSettings):
|
||||
PLATEGA_RETURN_URL: str | None = None
|
||||
PLATEGA_FAILED_URL: str | None = None
|
||||
PLATEGA_CURRENCY: str = 'RUB'
|
||||
PLATEGA_ACTIVE_METHODS: str = '2,10,11,12,13'
|
||||
PLATEGA_ACTIVE_METHODS: str = '2,11,12,13'
|
||||
PLATEGA_INLINE_METHODS: bool = True
|
||||
PLATEGA_MIN_AMOUNT_KOPEKS: int = 10000
|
||||
PLATEGA_MAX_AMOUNT_KOPEKS: int = 100000000
|
||||
PLATEGA_WEBHOOK_PATH: str = '/platega-webhook'
|
||||
@@ -550,6 +559,8 @@ class Settings(BaseSettings):
|
||||
KASSA_AI_SBP_DISPLAY_NAME: str = 'СБП (KassaAI)'
|
||||
KASSA_AI_CARD_ENABLED: bool = False # Карты РФ — payment_system_id=36
|
||||
KASSA_AI_CARD_DISPLAY_NAME: str = 'Карта (KassaAI)'
|
||||
KASSA_AI_SBERPAY_ENABLED: bool = False # SberPay — payment_system_id=43
|
||||
KASSA_AI_SBERPAY_DISPLAY_NAME: str = 'SberPay (KassaAI)'
|
||||
|
||||
# RioPay (api.riopay.online) v2.0.1
|
||||
RIOPAY_ENABLED: bool = False
|
||||
@@ -581,6 +592,13 @@ class Settings(BaseSettings):
|
||||
CONNECT_BUTTON_MODE: str = 'miniapp_subscription'
|
||||
MINIAPP_CUSTOM_URL: str = ''
|
||||
MINIAPP_STATIC_PATH: str = 'miniapp'
|
||||
|
||||
# Media upload settings (news article images/videos)
|
||||
MEDIA_UPLOAD_DIR: str = './uploads'
|
||||
MEDIA_MAX_IMAGE_SIZE_MB: int = 10
|
||||
MEDIA_MAX_VIDEO_SIZE_MB: int = 50
|
||||
MEDIA_IMAGE_MAX_DIMENSION: int = 2048
|
||||
MEDIA_JPEG_QUALITY: int = 85
|
||||
MINIAPP_PURCHASE_URL: str = ''
|
||||
MINIAPP_SERVICE_NAME_EN: str = 'Bedolaga VPN'
|
||||
MINIAPP_SERVICE_NAME_RU: str = 'Bedolaga VPN'
|
||||
@@ -807,7 +825,7 @@ class Settings(BaseSettings):
|
||||
# Format: socks5://user:password@host:port or socks5://host:port
|
||||
PROXY_URL: str | None = None
|
||||
|
||||
@field_validator('PROXY_URL', mode='before')
|
||||
@field_validator('PROXY_URL', 'NALOGO_PROXY_URL', mode='before')
|
||||
@classmethod
|
||||
def validate_proxy_url(cls, value: str | None) -> str | None:
|
||||
if not value:
|
||||
@@ -815,13 +833,13 @@ class Settings(BaseSettings):
|
||||
from urllib.parse import urlparse
|
||||
|
||||
parsed = urlparse(value)
|
||||
if parsed.scheme not in ('socks5', 'socks4'):
|
||||
if parsed.scheme not in ('socks5', 'socks5h', 'socks4'):
|
||||
raise ValueError(
|
||||
f'PROXY_URL must use socks5:// or socks4:// scheme, got: {parsed.scheme!r}. '
|
||||
'HTTP proxies are not supported for security reasons (bot token would be exposed).'
|
||||
f'Proxy URL must use socks5://, socks5h://, or socks4:// scheme, got: {parsed.scheme!r}. '
|
||||
'HTTP proxies are not supported for security reasons.'
|
||||
)
|
||||
if not parsed.hostname:
|
||||
raise ValueError('PROXY_URL must contain a hostname')
|
||||
raise ValueError('Proxy URL must contain a hostname')
|
||||
return value
|
||||
|
||||
@field_validator('MAIN_MENU_MODE', mode='before')
|
||||
@@ -954,6 +972,13 @@ class Settings(BaseSettings):
|
||||
"""Return SOCKS5 proxy URL or None."""
|
||||
return self.PROXY_URL if self.PROXY_URL else None
|
||||
|
||||
def get_nalogo_proxy_url(self) -> str | None:
|
||||
"""Return SOCKS proxy URL for nalogo or None.
|
||||
|
||||
Uses NALOGO_PROXY_URL if set, otherwise falls back to PROXY_URL.
|
||||
"""
|
||||
return self.NALOGO_PROXY_URL or self.PROXY_URL
|
||||
|
||||
def is_admin(self, telegram_id: int | None = None, email: str | None = None) -> bool:
|
||||
"""
|
||||
Check if user is admin by telegram_id or email.
|
||||
@@ -1125,12 +1150,17 @@ class Settings(BaseSettings):
|
||||
username_clean = (username or '').lstrip('@')
|
||||
full_name_value = full_name or ''
|
||||
|
||||
# Remnawave разрешает только буквы, цифры, подчёркивания и дефисы
|
||||
def _sanitize(value: str) -> str:
|
||||
result = re.sub(r'[^0-9A-Za-z_-]+', '_', value)
|
||||
return re.sub(r'_+', '_', result).strip('_-')
|
||||
|
||||
# Для email-пользователей формируем уникальный identifier
|
||||
if telegram_id:
|
||||
identifier = str(telegram_id)
|
||||
elif email:
|
||||
email_prefix = email.split('@')[0][:10]
|
||||
identifier = f'email_{email_prefix}_{user_id}' if user_id else f'email_{email_prefix}'
|
||||
email_prefix = _sanitize(email.split('@')[0][:10])
|
||||
identifier = _sanitize(f'email_{email_prefix}_{user_id}' if user_id else f'email_{email_prefix}')
|
||||
elif user_id:
|
||||
identifier = f'id_{user_id}'
|
||||
else:
|
||||
@@ -1144,20 +1174,18 @@ class Settings(BaseSettings):
|
||||
'username_clean': username_clean,
|
||||
'telegram_id': str(telegram_id) if telegram_id else identifier,
|
||||
'identifier': identifier,
|
||||
'email': email.split('@')[0] if email else '',
|
||||
'email': _sanitize(email.split('@')[0]) if email else '',
|
||||
'user_id': str(user_id) if user_id else '',
|
||||
},
|
||||
)
|
||||
|
||||
raw_username = template.format_map(values).strip()
|
||||
# Remnawave разрешает только буквы, цифры, подчёркивания и дефисы
|
||||
sanitized_username = re.sub(r'[^0-9A-Za-z_-]+', '_', raw_username)
|
||||
sanitized_username = re.sub(r'_+', '_', sanitized_username).strip('_-')
|
||||
sanitized_username = _sanitize(raw_username)
|
||||
|
||||
if not sanitized_username:
|
||||
sanitized_username = f'user_{identifier}'
|
||||
sanitized_username = _sanitize(f'user_{identifier}')
|
||||
|
||||
return sanitized_username[:36]
|
||||
return sanitized_username[:36].strip('_-') or 'user'
|
||||
|
||||
@staticmethod
|
||||
def parse_daily_time_list(raw_value: str | None) -> list[time]:
|
||||
@@ -1656,6 +1684,14 @@ class Settings(BaseSettings):
|
||||
def get_disabled_mode_device_limit(self) -> int | None:
|
||||
return self.get_devices_selection_disabled_amount()
|
||||
|
||||
def is_multi_tariff_enabled(self) -> bool:
|
||||
"""Проверяет, включен ли мультитарифный режим."""
|
||||
return self.MULTI_TARIFF_ENABLED and self.SALES_MODE == 'tariffs'
|
||||
|
||||
def get_max_active_subscriptions(self) -> int:
|
||||
"""Максимальное число одновременных подписок (>1 только в multi-tariff)."""
|
||||
return self.MAX_ACTIVE_SUBSCRIPTIONS if self.is_multi_tariff_enabled() else 1
|
||||
|
||||
def is_tariffs_mode(self) -> bool:
|
||||
"""Проверяет, включен ли режим продаж 'Тарифы'."""
|
||||
return self.SALES_MODE == 'tariffs'
|
||||
@@ -1806,7 +1842,7 @@ class Settings(BaseSettings):
|
||||
except ValueError:
|
||||
logger.warning('Некорректный код метода Platega', part=part)
|
||||
continue
|
||||
if method_code in {2, 10, 11, 12, 13} and method_code not in seen:
|
||||
if method_code in {2, 11, 12, 13} and method_code not in seen:
|
||||
methods.append(method_code)
|
||||
seen.add(method_code)
|
||||
|
||||
@@ -1819,8 +1855,7 @@ class Settings(BaseSettings):
|
||||
def get_platega_method_definitions() -> dict[int, dict[str, str]]:
|
||||
return {
|
||||
2: {'name': 'СБП (QR)', 'title': '🏦 СБП (QR)'},
|
||||
10: {'name': 'Банковские карты (RUB)', 'title': '💳 Карты (RUB)'},
|
||||
11: {'name': 'Банковские карты', 'title': '💳 Банковские карты'},
|
||||
11: {'name': 'Карты (RUB)', 'title': '💳 Карты (RUB)'},
|
||||
12: {'name': 'Международные карты', 'title': '🌍 Международные карты'},
|
||||
13: {'name': 'Криптовалюта', 'title': '🪙 Криптовалюта'},
|
||||
}
|
||||
@@ -1948,6 +1983,16 @@ class Settings(BaseSettings):
|
||||
def get_kassa_ai_card_display_name_html(self) -> str:
|
||||
return html.escape(self.get_kassa_ai_card_display_name())
|
||||
|
||||
def is_kassa_ai_sberpay_enabled(self) -> bool:
|
||||
return self.KASSA_AI_SBERPAY_ENABLED and self.is_kassa_ai_enabled()
|
||||
|
||||
def get_kassa_ai_sberpay_display_name(self) -> str:
|
||||
name = (self.KASSA_AI_SBERPAY_DISPLAY_NAME or '').strip()
|
||||
return name if name else 'SberPay (KassaAI)'
|
||||
|
||||
def get_kassa_ai_sberpay_display_name_html(self) -> str:
|
||||
return html.escape(self.get_kassa_ai_sberpay_display_name())
|
||||
|
||||
def is_payment_verification_auto_check_enabled(self) -> bool:
|
||||
return self.PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED
|
||||
|
||||
@@ -2196,13 +2241,17 @@ class Settings(BaseSettings):
|
||||
except (ValueError, AttributeError):
|
||||
return [30, 60, 90, 180, 360]
|
||||
|
||||
def get_balance_payment_description(self, amount_kopeks: int, telegram_user_id: int | None = None) -> str:
|
||||
def get_balance_payment_description(
|
||||
self, amount_kopeks: int, telegram_user_id: int | None = None, user_db_id: int | None = None
|
||||
) -> str:
|
||||
# Базовое описание
|
||||
description = f'{self.PAYMENT_BALANCE_DESCRIPTION} на {self.format_price(amount_kopeks)}'
|
||||
|
||||
# Если передан user_id, добавляем его
|
||||
# Добавляем идентификатор пользователя (TG ID приоритет, fallback на DB ID)
|
||||
if telegram_user_id is not None:
|
||||
description += f' (ID {telegram_user_id})'
|
||||
elif user_db_id is not None:
|
||||
description += f' (U{user_db_id})'
|
||||
|
||||
# Формируем финальную строку по шаблону
|
||||
return self.PAYMENT_BALANCE_TEMPLATE.format(service_name=self.PAYMENT_SERVICE_NAME, description=description)
|
||||
@@ -2615,6 +2664,9 @@ class Settings(BaseSettings):
|
||||
raw_path = 'miniapp'
|
||||
return Path(raw_path)
|
||||
|
||||
def get_media_upload_path(self) -> Path:
|
||||
return Path(self.MEDIA_UPLOAD_DIR)
|
||||
|
||||
# Cabinet methods
|
||||
def is_cabinet_enabled(self) -> bool:
|
||||
return bool(self.CABINET_ENABLED)
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
"""CRUD operations for news articles."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import delete, func, nullslast, select, update
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import NewsArticle
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
# Fields that can be set via update_news_article
|
||||
_ALLOWED_UPDATE_FIELDS: frozenset[str] = frozenset(
|
||||
{
|
||||
'title',
|
||||
'slug',
|
||||
'content',
|
||||
'excerpt',
|
||||
'category',
|
||||
'category_color',
|
||||
'tag',
|
||||
'category_id',
|
||||
'tag_id',
|
||||
'featured_image_url',
|
||||
'is_published',
|
||||
'is_featured',
|
||||
'published_at',
|
||||
'read_time_minutes',
|
||||
}
|
||||
)
|
||||
|
||||
# Fields that can be explicitly set to None
|
||||
_NULLABLE_UPDATE_FIELDS: frozenset[str] = frozenset(
|
||||
{
|
||||
'excerpt',
|
||||
'tag',
|
||||
'category_id',
|
||||
'tag_id',
|
||||
'featured_image_url',
|
||||
'published_at',
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def create_news_article(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
title: str,
|
||||
slug: str,
|
||||
content: str = '',
|
||||
excerpt: str | None = None,
|
||||
category: str = '',
|
||||
category_color: str = '#00e5a0',
|
||||
tag: str | None = None,
|
||||
category_id: int | None = None,
|
||||
tag_id: int | None = None,
|
||||
featured_image_url: str | None = None,
|
||||
is_published: bool = False,
|
||||
is_featured: bool = False,
|
||||
published_at: datetime | None = None,
|
||||
read_time_minutes: int = 1,
|
||||
created_by: int | None = None,
|
||||
) -> NewsArticle:
|
||||
"""Create a new news article.
|
||||
|
||||
Raises:
|
||||
IntegrityError: if slug is not unique (caller must handle).
|
||||
"""
|
||||
# Auto-set published_at when publishing without explicit date
|
||||
if is_published and published_at is None:
|
||||
published_at = datetime.now(UTC)
|
||||
|
||||
article = NewsArticle(
|
||||
title=title,
|
||||
slug=slug,
|
||||
content=content,
|
||||
excerpt=excerpt,
|
||||
category=category,
|
||||
category_color=category_color,
|
||||
tag=tag,
|
||||
category_id=category_id,
|
||||
tag_id=tag_id,
|
||||
featured_image_url=featured_image_url,
|
||||
is_published=is_published,
|
||||
is_featured=is_featured,
|
||||
published_at=published_at,
|
||||
read_time_minutes=read_time_minutes,
|
||||
created_by=created_by,
|
||||
)
|
||||
|
||||
db.add(article)
|
||||
try:
|
||||
await db.commit()
|
||||
except IntegrityError:
|
||||
await db.rollback()
|
||||
raise
|
||||
await db.refresh(article)
|
||||
|
||||
logger.info(
|
||||
'Created news article',
|
||||
article_id=article.id,
|
||||
slug=article.slug,
|
||||
is_published=article.is_published,
|
||||
)
|
||||
return article
|
||||
|
||||
|
||||
async def get_news_article_by_id(db: AsyncSession, article_id: int) -> NewsArticle | None:
|
||||
"""Get a news article by ID with author, category, and tag relationships."""
|
||||
result = await db.execute(
|
||||
select(NewsArticle)
|
||||
.options(
|
||||
selectinload(NewsArticle.author),
|
||||
selectinload(NewsArticle.category_obj),
|
||||
selectinload(NewsArticle.tag_obj),
|
||||
)
|
||||
.where(NewsArticle.id == article_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_news_article_by_slug(db: AsyncSession, slug: str) -> NewsArticle | None:
|
||||
"""Get a news article by slug with author, category, and tag relationships."""
|
||||
result = await db.execute(
|
||||
select(NewsArticle)
|
||||
.options(
|
||||
selectinload(NewsArticle.author),
|
||||
selectinload(NewsArticle.category_obj),
|
||||
selectinload(NewsArticle.tag_obj),
|
||||
)
|
||||
.where(NewsArticle.slug == slug)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_published_news(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
category: str | None = None,
|
||||
limit: int = 20,
|
||||
offset: int = 0,
|
||||
) -> list[NewsArticle]:
|
||||
"""Get published news articles, ordered by published_at descending.
|
||||
|
||||
Does NOT load the author relationship -- list views do not need it.
|
||||
"""
|
||||
stmt = select(NewsArticle).where(NewsArticle.is_published.is_(True))
|
||||
if category:
|
||||
stmt = stmt.where(NewsArticle.category == category)
|
||||
|
||||
# NULLs last so articles without published_at don't float to the top in DESC
|
||||
stmt = stmt.order_by(nullslast(NewsArticle.published_at.desc())).offset(offset).limit(limit)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_published_news_count(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
category: str | None = None,
|
||||
) -> int:
|
||||
"""Get count of published news articles, optionally filtered by category."""
|
||||
stmt = select(func.count(NewsArticle.id)).where(NewsArticle.is_published.is_(True))
|
||||
if category:
|
||||
stmt = stmt.where(NewsArticle.category == category)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
return result.scalar_one() or 0
|
||||
|
||||
|
||||
async def get_all_news(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> list[NewsArticle]:
|
||||
"""Get all news articles (admin), ordered by created_at descending."""
|
||||
stmt = select(NewsArticle).order_by(NewsArticle.created_at.desc()).offset(offset).limit(limit)
|
||||
result = await db.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_all_news_count(db: AsyncSession) -> int:
|
||||
"""Get total count of all news articles."""
|
||||
result = await db.execute(select(func.count(NewsArticle.id)))
|
||||
return result.scalar_one() or 0
|
||||
|
||||
|
||||
async def get_news_categories(db: AsyncSession) -> list[str]:
|
||||
"""Get distinct categories from published articles."""
|
||||
result = await db.execute(
|
||||
select(NewsArticle.category)
|
||||
.where(NewsArticle.is_published.is_(True))
|
||||
.where(NewsArticle.category != '')
|
||||
.distinct()
|
||||
.order_by(NewsArticle.category)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def unfeature_all_news(db: AsyncSession) -> None:
|
||||
"""Remove featured flag from all articles (so only one can be featured).
|
||||
|
||||
Does NOT commit. The caller must commit the session to persist this change.
|
||||
This is intentional — the caller should commit both this operation and the
|
||||
subsequent feature operation atomically.
|
||||
"""
|
||||
await db.execute(update(NewsArticle).where(NewsArticle.is_featured.is_(True)).values(is_featured=False))
|
||||
|
||||
|
||||
async def update_news_article(
|
||||
db: AsyncSession,
|
||||
article: NewsArticle,
|
||||
**kwargs: Any,
|
||||
) -> NewsArticle:
|
||||
"""Update a news article. Only whitelisted fields are applied.
|
||||
|
||||
Raises:
|
||||
IntegrityError: if slug conflicts with another article (caller must handle).
|
||||
"""
|
||||
update_data: dict[str, Any] = {}
|
||||
for key, value in kwargs.items():
|
||||
if key not in _ALLOWED_UPDATE_FIELDS:
|
||||
continue
|
||||
if value is None and key not in _NULLABLE_UPDATE_FIELDS:
|
||||
continue
|
||||
update_data[key] = value
|
||||
|
||||
# Auto-set published_at when transitioning to published
|
||||
if update_data.get('is_published') and not article.is_published and not update_data.get('published_at'):
|
||||
if article.published_at is None:
|
||||
update_data['published_at'] = datetime.now(UTC)
|
||||
|
||||
if not update_data:
|
||||
return article
|
||||
|
||||
update_data['updated_at'] = datetime.now(UTC)
|
||||
|
||||
await db.execute(update(NewsArticle).where(NewsArticle.id == article.id).values(**update_data))
|
||||
try:
|
||||
await db.commit()
|
||||
except IntegrityError:
|
||||
await db.rollback()
|
||||
raise
|
||||
await db.refresh(article)
|
||||
|
||||
logger.info(
|
||||
'Updated news article',
|
||||
article_id=article.id,
|
||||
slug=article.slug,
|
||||
updated_fields=list(update_data.keys()),
|
||||
)
|
||||
return article
|
||||
|
||||
|
||||
async def delete_news_article(db: AsyncSession, article: NewsArticle) -> None:
|
||||
"""Delete a news article."""
|
||||
# Capture fields before commit expires the ORM instance attributes
|
||||
article_id = article.id
|
||||
article_slug = article.slug
|
||||
|
||||
await db.execute(delete(NewsArticle).where(NewsArticle.id == article_id))
|
||||
await db.commit()
|
||||
|
||||
logger.info('Deleted news article', article_id=article_id, slug=article_slug)
|
||||
|
||||
|
||||
async def increment_views(db: AsyncSession, article_id: int) -> int:
|
||||
"""Atomically increment the views counter and return the new count.
|
||||
|
||||
Uses UPDATE … RETURNING so the caller can patch the ORM instance directly
|
||||
without issuing a second SELECT (db.refresh).
|
||||
"""
|
||||
result = await db.execute(
|
||||
update(NewsArticle)
|
||||
.where(NewsArticle.id == article_id)
|
||||
.values(views_count=NewsArticle.views_count + 1)
|
||||
.returning(NewsArticle.views_count)
|
||||
)
|
||||
await db.commit()
|
||||
row = result.fetchone()
|
||||
return row[0] if row else 0
|
||||
@@ -0,0 +1,87 @@
|
||||
"""CRUD operations for news categories."""
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import NewsArticle, NewsCategory
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def get_all_categories(db: AsyncSession) -> list[NewsCategory]:
|
||||
"""Get all news categories ordered by name."""
|
||||
result = await db.execute(select(NewsCategory).order_by(NewsCategory.name))
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_category_by_id(db: AsyncSession, category_id: int) -> NewsCategory | None:
|
||||
"""Get a single news category by primary key."""
|
||||
result = await db.execute(select(NewsCategory).where(NewsCategory.id == category_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def create_category(db: AsyncSession, *, name: str, color: str = '#00e5a0') -> NewsCategory:
|
||||
"""Create a new news category.
|
||||
|
||||
Raises:
|
||||
IntegrityError: if a category with the same name already exists (caller must handle).
|
||||
"""
|
||||
category = NewsCategory(name=name.strip(), color=color)
|
||||
db.add(category)
|
||||
try:
|
||||
await db.commit()
|
||||
except IntegrityError:
|
||||
await db.rollback()
|
||||
raise
|
||||
await db.refresh(category)
|
||||
logger.info('Created news category', category_id=category.id, name=category.name)
|
||||
return category
|
||||
|
||||
|
||||
async def update_category(
|
||||
db: AsyncSession,
|
||||
category: NewsCategory,
|
||||
**kwargs: str | None,
|
||||
) -> NewsCategory:
|
||||
"""Update an existing news category.
|
||||
|
||||
Supported kwargs: name, color.
|
||||
|
||||
Raises:
|
||||
IntegrityError: if the new name conflicts with an existing category.
|
||||
"""
|
||||
update_data: dict[str, str] = {}
|
||||
if 'name' in kwargs and kwargs['name'] is not None:
|
||||
update_data['name'] = kwargs['name'].strip()
|
||||
if 'color' in kwargs and kwargs['color'] is not None:
|
||||
update_data['color'] = kwargs['color']
|
||||
|
||||
if not update_data:
|
||||
return category
|
||||
|
||||
await db.execute(update(NewsCategory).where(NewsCategory.id == category.id).values(**update_data))
|
||||
try:
|
||||
await db.commit()
|
||||
except IntegrityError:
|
||||
await db.rollback()
|
||||
raise
|
||||
await db.refresh(category)
|
||||
logger.info('Updated news category', category_id=category.id, updated_fields=list(update_data.keys()))
|
||||
return category
|
||||
|
||||
|
||||
async def delete_category(db: AsyncSession, category: NewsCategory) -> None:
|
||||
"""Delete a news category and clear category fields from all linked articles."""
|
||||
cat_id, cat_name = category.id, category.name
|
||||
# Clear legacy string fields on articles that reference this category
|
||||
await db.execute(
|
||||
update(NewsArticle)
|
||||
.where(NewsArticle.category_id == cat_id)
|
||||
.values(category='', category_color='#00e5a0', category_id=None)
|
||||
)
|
||||
await db.delete(category)
|
||||
await db.commit()
|
||||
logger.info('Deleted news category', category_id=cat_id, name=cat_name)
|
||||
@@ -0,0 +1,83 @@
|
||||
"""CRUD operations for news tags."""
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import NewsArticle, NewsTag
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def get_all_tags(db: AsyncSession) -> list[NewsTag]:
|
||||
"""Get all news tags ordered by name."""
|
||||
result = await db.execute(select(NewsTag).order_by(NewsTag.name))
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_tag_by_id(db: AsyncSession, tag_id: int) -> NewsTag | None:
|
||||
"""Get a single news tag by primary key."""
|
||||
result = await db.execute(select(NewsTag).where(NewsTag.id == tag_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def create_tag(db: AsyncSession, *, name: str, color: str = '#94a3b8') -> NewsTag:
|
||||
"""Create a new news tag.
|
||||
|
||||
Raises:
|
||||
IntegrityError: if a tag with the same name already exists (caller must handle).
|
||||
"""
|
||||
tag = NewsTag(name=name.strip(), color=color)
|
||||
db.add(tag)
|
||||
try:
|
||||
await db.commit()
|
||||
except IntegrityError:
|
||||
await db.rollback()
|
||||
raise
|
||||
await db.refresh(tag)
|
||||
logger.info('Created news tag', tag_id=tag.id, name=tag.name)
|
||||
return tag
|
||||
|
||||
|
||||
async def update_tag(
|
||||
db: AsyncSession,
|
||||
tag: NewsTag,
|
||||
**kwargs: str | None,
|
||||
) -> NewsTag:
|
||||
"""Update an existing news tag.
|
||||
|
||||
Supported kwargs: name, color.
|
||||
|
||||
Raises:
|
||||
IntegrityError: if the new name conflicts with an existing tag.
|
||||
"""
|
||||
update_data: dict[str, str] = {}
|
||||
if 'name' in kwargs and kwargs['name'] is not None:
|
||||
update_data['name'] = kwargs['name'].strip()
|
||||
if 'color' in kwargs and kwargs['color'] is not None:
|
||||
update_data['color'] = kwargs['color']
|
||||
|
||||
if not update_data:
|
||||
return tag
|
||||
|
||||
await db.execute(update(NewsTag).where(NewsTag.id == tag.id).values(**update_data))
|
||||
try:
|
||||
await db.commit()
|
||||
except IntegrityError:
|
||||
await db.rollback()
|
||||
raise
|
||||
await db.refresh(tag)
|
||||
logger.info('Updated news tag', tag_id=tag.id, updated_fields=list(update_data.keys()))
|
||||
return tag
|
||||
|
||||
|
||||
async def delete_tag(db: AsyncSession, tag: NewsTag) -> None:
|
||||
"""Delete a news tag and clear tag fields from all linked articles."""
|
||||
tag_id, tag_name = tag.id, tag.name
|
||||
# Clear legacy string field on articles that reference this tag
|
||||
await db.execute(update(NewsArticle).where(NewsArticle.tag_id == tag_id).values(tag=None, tag_id=None))
|
||||
await db.delete(tag)
|
||||
await db.commit()
|
||||
logger.info('Deleted news tag', tag_id=tag_id, name=tag_name)
|
||||
@@ -34,6 +34,8 @@ async def record_notification(
|
||||
subscription_id: int,
|
||||
notification_type: str,
|
||||
days_before: int | None = None,
|
||||
*,
|
||||
commit: bool = True,
|
||||
) -> None:
|
||||
already_exists = await notification_sent(db, user_id, subscription_id, notification_type, days_before)
|
||||
if already_exists:
|
||||
@@ -45,7 +47,8 @@ async def record_notification(
|
||||
days_before=days_before,
|
||||
)
|
||||
db.add(notification)
|
||||
await db.commit()
|
||||
if commit:
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def clear_notifications(db: AsyncSession, subscription_id: int, *, commit: bool = True) -> None:
|
||||
@@ -58,6 +61,8 @@ async def clear_notification_by_type(
|
||||
db: AsyncSession,
|
||||
subscription_id: int,
|
||||
notification_type: str,
|
||||
*,
|
||||
commit: bool = True,
|
||||
) -> None:
|
||||
await db.execute(
|
||||
delete(SentNotification).where(
|
||||
@@ -65,4 +70,5 @@ async def clear_notification_by_type(
|
||||
SentNotification.notification_type == notification_type,
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
if commit:
|
||||
await db.commit()
|
||||
|
||||
@@ -3,7 +3,7 @@ from sqlalchemy import func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import PromoGroup, User, UserPromoGroup
|
||||
from app.database.models import PromoGroup, Subscription, User, UserPromoGroup
|
||||
|
||||
|
||||
def _normalize_period_discounts(period_discounts: dict[int, int] | None) -> dict[int, int]:
|
||||
@@ -259,7 +259,7 @@ async def get_promo_group_members(
|
||||
) -> list[User]:
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.options(selectinload(User.subscription))
|
||||
.options(selectinload(User.subscriptions).selectinload(Subscription.tariff))
|
||||
.where(User.promo_group_id == group_id)
|
||||
.order_by(User.created_at.desc())
|
||||
.offset(offset)
|
||||
|
||||
@@ -430,7 +430,7 @@ async def get_server_connected_users(db: AsyncSession, server_id: int) -> list[U
|
||||
),
|
||||
)
|
||||
.where(or_(*connection_filters))
|
||||
.options(selectinload(User.subscription))
|
||||
.options(selectinload(User.subscriptions).selectinload(Subscription.tariff))
|
||||
.order_by(User.id)
|
||||
)
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import secrets
|
||||
from collections.abc import Iterable
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import and_, delete, func, select
|
||||
from sqlalchemy import and_, case, delete, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
from sqlalchemy.orm.exc import StaleDataError
|
||||
@@ -23,6 +24,18 @@ from app.utils.timezone import format_local_datetime
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def generate_unique_short_id(db: AsyncSession, max_attempts: int = 10) -> str:
|
||||
"""Generate a unique remnawave_short_id (6 hex chars) with collision check."""
|
||||
for _ in range(max_attempts):
|
||||
short_id = secrets.token_hex(3)
|
||||
existing = await db.execute(select(Subscription.id).where(Subscription.remnawave_short_id == short_id).limit(1))
|
||||
if existing.scalar_one_or_none() is None:
|
||||
return short_id
|
||||
# Fallback: 8 chars for extra entropy
|
||||
return secrets.token_hex(4)
|
||||
|
||||
|
||||
_WEBHOOK_GUARD_SECONDS = 60
|
||||
|
||||
|
||||
@@ -67,6 +80,14 @@ def is_active_paid_subscription(subscription: Subscription | None) -> bool:
|
||||
|
||||
|
||||
async def get_subscription_by_user_id(db: AsyncSession, user_id: int) -> Subscription | None:
|
||||
"""Get primary subscription for user.
|
||||
|
||||
Returns the first active/trial subscription, or the most recently created one.
|
||||
Multi-tariff compatible: prioritizes active subscriptions.
|
||||
For multi-tariff operations on a specific subscription, use get_subscription_by_id_for_user().
|
||||
"""
|
||||
from app.database.models import SubscriptionStatus
|
||||
|
||||
result = await db.execute(
|
||||
select(Subscription)
|
||||
.options(
|
||||
@@ -74,7 +95,16 @@ async def get_subscription_by_user_id(db: AsyncSession, user_id: int) -> Subscri
|
||||
selectinload(Subscription.tariff),
|
||||
)
|
||||
.where(Subscription.user_id == user_id)
|
||||
.order_by(Subscription.created_at.desc())
|
||||
.order_by(
|
||||
# Active/trial subscriptions first, then by end_date (most remaining time)
|
||||
case(
|
||||
(Subscription.status == SubscriptionStatus.ACTIVE.value, 0),
|
||||
(Subscription.status == SubscriptionStatus.TRIAL.value, 1),
|
||||
else_=2,
|
||||
),
|
||||
Subscription.end_date.desc().nulls_last(),
|
||||
Subscription.created_at.desc(),
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
subscription = result.scalar_one_or_none()
|
||||
@@ -135,7 +165,17 @@ async def create_trial_subscription(
|
||||
end_date = datetime.now(UTC) + timedelta(days=duration_days)
|
||||
|
||||
# Check for existing PENDING trial subscription (retry after failed payment)
|
||||
existing = await get_subscription_by_user_id(db, user_id)
|
||||
# In multi-tariff mode, only reuse a subscription for the SAME tariff to avoid
|
||||
# overwriting a paid subscription for a different tariff.
|
||||
existing = None
|
||||
if settings.is_multi_tariff_enabled() and tariff_id:
|
||||
for sub in await get_active_subscriptions_by_user_id(db, user_id):
|
||||
if sub.tariff_id == tariff_id:
|
||||
existing = sub
|
||||
break
|
||||
else:
|
||||
existing = await get_subscription_by_user_id(db, user_id)
|
||||
|
||||
if existing and existing.is_trial and existing.status == SubscriptionStatus.PENDING.value:
|
||||
existing.status = SubscriptionStatus.ACTIVE.value
|
||||
existing.start_date = datetime.now(UTC)
|
||||
@@ -144,6 +184,8 @@ async def create_trial_subscription(
|
||||
existing.device_limit = device_limit
|
||||
existing.connected_squads = final_squads
|
||||
existing.tariff_id = tariff_id
|
||||
if not existing.remnawave_short_id:
|
||||
existing.remnawave_short_id = await generate_unique_short_id(db)
|
||||
await db.commit()
|
||||
await db.refresh(existing)
|
||||
logger.info(
|
||||
@@ -151,6 +193,8 @@ async def create_trial_subscription(
|
||||
)
|
||||
return existing
|
||||
|
||||
short_id = await generate_unique_short_id(db)
|
||||
|
||||
subscription = Subscription(
|
||||
user_id=user_id,
|
||||
status=SubscriptionStatus.ACTIVE.value,
|
||||
@@ -160,9 +204,10 @@ async def create_trial_subscription(
|
||||
traffic_limit_gb=traffic_limit_gb,
|
||||
device_limit=device_limit,
|
||||
connected_squads=final_squads,
|
||||
autopay_enabled=settings.is_autopay_enabled_by_default(),
|
||||
autopay_enabled=False,
|
||||
autopay_days_before=settings.DEFAULT_AUTOPAY_DAYS_BEFORE,
|
||||
tariff_id=tariff_id,
|
||||
remnawave_short_id=short_id,
|
||||
)
|
||||
|
||||
db.add(subscription)
|
||||
@@ -230,6 +275,8 @@ async def create_paid_subscription(
|
||||
except Exception as error:
|
||||
logger.error('❌ Не удалось получить fallback сквад', user_id=user_id, error=error)
|
||||
|
||||
short_id = await generate_unique_short_id(db)
|
||||
|
||||
subscription = Subscription(
|
||||
user_id=user_id,
|
||||
status=SubscriptionStatus.ACTIVE.value,
|
||||
@@ -242,6 +289,7 @@ async def create_paid_subscription(
|
||||
autopay_enabled=settings.is_autopay_enabled_by_default(),
|
||||
autopay_days_before=settings.DEFAULT_AUTOPAY_DAYS_BEFORE,
|
||||
tariff_id=tariff_id,
|
||||
remnawave_short_id=short_id,
|
||||
)
|
||||
|
||||
db.add(subscription)
|
||||
@@ -251,6 +299,20 @@ async def create_paid_subscription(
|
||||
else:
|
||||
await db.flush()
|
||||
|
||||
# Kill all trial subscriptions when creating a paid subscription
|
||||
# Trial = probe, must die on any paid purchase (regardless of path: bot, cabinet, webhook)
|
||||
if not is_trial:
|
||||
try:
|
||||
killed = await deactivate_user_trial_subscriptions(db, user_id, exclude_subscription_id=subscription.id)
|
||||
if killed:
|
||||
logger.info(
|
||||
'Deactivated trial subscriptions on paid purchase',
|
||||
user_id=user_id,
|
||||
killed_count=len(killed),
|
||||
)
|
||||
except Exception as trial_err:
|
||||
logger.warning('Failed to deactivate trials on paid purchase', error=trial_err)
|
||||
|
||||
logger.info(
|
||||
'💎 Создана платная подписка для пользователя ID: статус',
|
||||
user_id=user_id,
|
||||
@@ -637,6 +699,21 @@ async def extend_subscription(
|
||||
|
||||
await clear_notifications(db, subscription.id, commit=commit)
|
||||
|
||||
# Kill other trial subscriptions if this extension converts trial to paid
|
||||
if not subscription.is_trial and days > 0:
|
||||
try:
|
||||
killed = await deactivate_user_trial_subscriptions(
|
||||
db, subscription.user_id, exclude_subscription_id=subscription.id
|
||||
)
|
||||
if killed:
|
||||
logger.info(
|
||||
'Deactivated trial subscriptions on extend',
|
||||
user_id=subscription.user_id,
|
||||
killed_count=len(killed),
|
||||
)
|
||||
except Exception as trial_err:
|
||||
logger.warning('Failed to deactivate trials on extend', error=trial_err)
|
||||
|
||||
logger.info('✅ Подписка продлена до', end_date=subscription.end_date)
|
||||
logger.info('📊 Новые параметры: статус=, окончание', status=subscription.status, end_date=subscription.end_date)
|
||||
|
||||
@@ -694,6 +771,7 @@ async def add_subscription_devices(db: AsyncSession, subscription: Subscription,
|
||||
locked_result = await db.execute(
|
||||
select(Subscription)
|
||||
.where(Subscription.id == subscription.id)
|
||||
.options(selectinload(Subscription.tariff))
|
||||
.with_for_update()
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
@@ -712,6 +790,18 @@ async def add_subscription_devices(db: AsyncSession, subscription: Subscription,
|
||||
)
|
||||
new_limit = max_devices
|
||||
|
||||
# Check tariff max device limit
|
||||
tariff_max = subscription.tariff.max_device_limit if subscription.tariff else None
|
||||
if tariff_max is not None and tariff_max > 0 and new_limit > tariff_max:
|
||||
logger.warning(
|
||||
'📱 Попытка превысить лимит устройств тарифа',
|
||||
user_id=subscription.user_id,
|
||||
current=subscription.device_limit,
|
||||
requested=devices,
|
||||
tariff_max_devices=tariff_max,
|
||||
)
|
||||
new_limit = tariff_max
|
||||
|
||||
subscription.device_limit = new_limit
|
||||
subscription.updated_at = datetime.now(UTC)
|
||||
|
||||
@@ -812,10 +902,11 @@ async def decrement_subscription_server_counts(
|
||||
|
||||
|
||||
async def update_subscription_autopay(
|
||||
db: AsyncSession, subscription: Subscription, enabled: bool, days_before: int = 3
|
||||
db: AsyncSession, subscription: Subscription, enabled: bool, days_before: int | None = None
|
||||
) -> Subscription:
|
||||
subscription.autopay_enabled = enabled
|
||||
subscription.autopay_days_before = days_before
|
||||
if days_before is not None:
|
||||
subscription.autopay_days_before = days_before
|
||||
subscription.updated_at = datetime.now(UTC)
|
||||
|
||||
await db.commit()
|
||||
@@ -826,18 +917,19 @@ async def update_subscription_autopay(
|
||||
return subscription
|
||||
|
||||
|
||||
async def deactivate_subscription(db: AsyncSession, subscription: Subscription) -> Subscription:
|
||||
async def deactivate_subscription(db: AsyncSession, subscription: Subscription, *, commit: bool = True) -> Subscription:
|
||||
subscription.status = SubscriptionStatus.DISABLED.value
|
||||
subscription.updated_at = datetime.now(UTC)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
if commit:
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
logger.info('❌ Подписка пользователя деактивирована', user_id=subscription.user_id)
|
||||
return subscription
|
||||
|
||||
|
||||
async def reactivate_subscription(db: AsyncSession, subscription: Subscription) -> Subscription:
|
||||
async def reactivate_subscription(db: AsyncSession, subscription: Subscription, *, commit: bool = True) -> Subscription:
|
||||
"""Реактивация подписки (например, после повторной подписки на канал или докупки трафика).
|
||||
|
||||
Активирует если подписка была DISABLED или EXPIRED и ещё не истекла по времени.
|
||||
@@ -861,8 +953,9 @@ async def reactivate_subscription(db: AsyncSession, subscription: Subscription)
|
||||
subscription.status = SubscriptionStatus.ACTIVE.value
|
||||
subscription.updated_at = now
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
if commit:
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
logger.info(
|
||||
'✅ Подписка реактивирована',
|
||||
@@ -1356,6 +1449,7 @@ async def create_subscription_no_commit(
|
||||
if connected_squads is None:
|
||||
connected_squads = []
|
||||
|
||||
short_id = await generate_unique_short_id(db)
|
||||
subscription = Subscription(
|
||||
user_id=user_id,
|
||||
status=status,
|
||||
@@ -1366,6 +1460,7 @@ async def create_subscription_no_commit(
|
||||
device_limit=device_limit,
|
||||
connected_squads=connected_squads,
|
||||
remnawave_short_uuid=remnawave_short_uuid,
|
||||
remnawave_short_id=short_id,
|
||||
subscription_url=subscription_url,
|
||||
subscription_crypto_link=subscription_crypto_link,
|
||||
autopay_enabled=(settings.is_autopay_enabled_by_default() if autopay_enabled is None else autopay_enabled),
|
||||
@@ -1406,6 +1501,7 @@ async def create_subscription(
|
||||
if connected_squads is None:
|
||||
connected_squads = []
|
||||
|
||||
short_id = await generate_unique_short_id(db)
|
||||
subscription = Subscription(
|
||||
user_id=user_id,
|
||||
status=status,
|
||||
@@ -1416,6 +1512,7 @@ async def create_subscription(
|
||||
device_limit=device_limit,
|
||||
connected_squads=connected_squads,
|
||||
remnawave_short_uuid=remnawave_short_uuid,
|
||||
remnawave_short_id=short_id,
|
||||
subscription_url=subscription_url,
|
||||
subscription_crypto_link=subscription_crypto_link,
|
||||
autopay_enabled=(settings.is_autopay_enabled_by_default() if autopay_enabled is None else autopay_enabled),
|
||||
@@ -1442,6 +1539,7 @@ async def create_pending_subscription(
|
||||
payment_method: str = 'pending',
|
||||
total_price_kopeks: int = 0,
|
||||
is_trial: bool = False,
|
||||
tariff_id: int | None = None,
|
||||
) -> Subscription:
|
||||
"""Creates a pending subscription that will be activated after payment.
|
||||
|
||||
@@ -1452,7 +1550,23 @@ async def create_pending_subscription(
|
||||
current_time = datetime.now(UTC)
|
||||
end_date = current_time + timedelta(days=duration_days)
|
||||
|
||||
existing_subscription = await get_subscription_by_user_id(db, user_id)
|
||||
if settings.is_multi_tariff_enabled() and tariff_id:
|
||||
active_subs = await get_active_subscriptions_by_user_id(db, user_id)
|
||||
existing_subscription = next((s for s in active_subs if s.tariff_id == tariff_id), None)
|
||||
if not existing_subscription:
|
||||
# Also check non-active subs for this tariff
|
||||
result = await db.execute(
|
||||
select(Subscription)
|
||||
.where(
|
||||
Subscription.user_id == user_id,
|
||||
Subscription.tariff_id == tariff_id,
|
||||
)
|
||||
.order_by(Subscription.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
existing_subscription = result.scalar_one_or_none()
|
||||
else:
|
||||
existing_subscription = await get_subscription_by_user_id(db, user_id)
|
||||
|
||||
if existing_subscription:
|
||||
if (
|
||||
@@ -1475,6 +1589,8 @@ async def create_pending_subscription(
|
||||
existing_subscription.connected_squads = connected_squads or []
|
||||
existing_subscription.traffic_used_gb = 0.0
|
||||
existing_subscription.updated_at = current_time
|
||||
if tariff_id is not None:
|
||||
existing_subscription.tariff_id = tariff_id
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(existing_subscription)
|
||||
@@ -1488,6 +1604,7 @@ async def create_pending_subscription(
|
||||
)
|
||||
return existing_subscription
|
||||
|
||||
short_id = await generate_unique_short_id(db)
|
||||
subscription = Subscription(
|
||||
user_id=user_id,
|
||||
status=SubscriptionStatus.PENDING.value,
|
||||
@@ -1497,8 +1614,10 @@ async def create_pending_subscription(
|
||||
traffic_limit_gb=traffic_limit_gb,
|
||||
device_limit=device_limit,
|
||||
connected_squads=connected_squads or [],
|
||||
tariff_id=tariff_id,
|
||||
autopay_enabled=settings.is_autopay_enabled_by_default(),
|
||||
autopay_days_before=settings.DEFAULT_AUTOPAY_DAYS_BEFORE,
|
||||
remnawave_short_id=short_id,
|
||||
)
|
||||
|
||||
db.add(subscription)
|
||||
@@ -1526,6 +1645,7 @@ async def create_pending_trial_subscription(
|
||||
connected_squads: list[str] = None,
|
||||
payment_method: str = 'pending',
|
||||
total_price_kopeks: int = 0,
|
||||
tariff_id: int | None = None,
|
||||
) -> Subscription:
|
||||
"""Creates a pending trial subscription. Wrapper for create_pending_subscription with is_trial=True."""
|
||||
return await create_pending_subscription(
|
||||
@@ -1538,18 +1658,34 @@ async def create_pending_trial_subscription(
|
||||
payment_method=payment_method,
|
||||
total_price_kopeks=total_price_kopeks,
|
||||
is_trial=True,
|
||||
tariff_id=tariff_id,
|
||||
)
|
||||
|
||||
|
||||
async def activate_pending_subscription(db: AsyncSession, user_id: int, period_days: int = None) -> Subscription | None:
|
||||
async def activate_pending_subscription(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
period_days: int = None,
|
||||
subscription_id: int | None = None,
|
||||
) -> Subscription | None:
|
||||
"""Активирует pending подписку пользователя, меняя её статус на ACTIVE."""
|
||||
logger.info('Активация pending подписки: пользователь период дней', user_id=user_id, period_days=period_days)
|
||||
logger.info(
|
||||
'Активация pending подписки: пользователь период дней',
|
||||
user_id=user_id,
|
||||
period_days=period_days,
|
||||
subscription_id=subscription_id,
|
||||
)
|
||||
|
||||
# Находим pending подписку пользователя (последнюю созданную при наличии нескольких)
|
||||
conditions = [
|
||||
Subscription.user_id == user_id,
|
||||
Subscription.status == SubscriptionStatus.PENDING.value,
|
||||
]
|
||||
if subscription_id is not None:
|
||||
conditions.append(Subscription.id == subscription_id)
|
||||
|
||||
# Находим pending подписку пользователя
|
||||
result = await db.execute(
|
||||
select(Subscription).where(
|
||||
and_(Subscription.user_id == user_id, Subscription.status == SubscriptionStatus.PENDING.value)
|
||||
)
|
||||
select(Subscription).where(and_(*conditions)).order_by(Subscription.created_at.desc()).limit(1)
|
||||
)
|
||||
pending_subscription = result.scalar_one_or_none()
|
||||
|
||||
@@ -1860,6 +1996,8 @@ async def update_daily_charge_time(
|
||||
db: AsyncSession,
|
||||
subscription: Subscription,
|
||||
charge_time: datetime = None,
|
||||
*,
|
||||
commit: bool = True,
|
||||
) -> Subscription:
|
||||
"""Обновляет время последнего суточного списания и продлевает подписку на 1 день."""
|
||||
now = charge_time or datetime.now(UTC)
|
||||
@@ -1871,8 +2009,11 @@ async def update_daily_charge_time(
|
||||
subscription.end_date = new_end_date
|
||||
logger.info('📅 Продлена подписка до', subscription_id=subscription.id, new_end_date=new_end_date)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
if commit:
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
else:
|
||||
await db.flush()
|
||||
|
||||
return subscription
|
||||
|
||||
@@ -1929,3 +2070,164 @@ async def toggle_daily_subscription_pause(
|
||||
if subscription.is_daily_paused:
|
||||
return await resume_daily_subscription(db, subscription)
|
||||
return await pause_daily_subscription(db, subscription)
|
||||
|
||||
|
||||
# ── Multi-tariff CRUD functions ──────────────────────────────────────────────
|
||||
|
||||
|
||||
async def get_active_subscriptions_by_user_id(db: AsyncSession, user_id: int) -> list[Subscription]:
|
||||
"""Get all active/trial/limited subscriptions for a user.
|
||||
|
||||
Includes LIMITED status because those subscriptions still have time remaining
|
||||
(just ran out of traffic) and should be treated as "alive" for renewal,
|
||||
duplicate prevention, and display purposes.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(Subscription)
|
||||
.options(
|
||||
selectinload(Subscription.user),
|
||||
selectinload(Subscription.tariff),
|
||||
)
|
||||
.where(
|
||||
Subscription.user_id == user_id,
|
||||
Subscription.status.in_(
|
||||
[
|
||||
SubscriptionStatus.ACTIVE.value,
|
||||
SubscriptionStatus.TRIAL.value,
|
||||
SubscriptionStatus.LIMITED.value,
|
||||
]
|
||||
),
|
||||
)
|
||||
.order_by(Subscription.created_at.desc())
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_subscription_by_id_for_user(db: AsyncSession, subscription_id: int, user_id: int) -> Subscription | None:
|
||||
"""Get subscription by ID with ownership check (IDOR protection)."""
|
||||
result = await db.execute(
|
||||
select(Subscription)
|
||||
.options(
|
||||
selectinload(Subscription.user),
|
||||
selectinload(Subscription.tariff),
|
||||
)
|
||||
.where(
|
||||
Subscription.id == subscription_id,
|
||||
Subscription.user_id == user_id,
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_subscription_by_id(db: AsyncSession, subscription_id: int) -> Subscription | None:
|
||||
"""Get subscription by ID (admin use only, no ownership check)."""
|
||||
result = await db.execute(
|
||||
select(Subscription)
|
||||
.options(
|
||||
selectinload(Subscription.user),
|
||||
selectinload(Subscription.tariff),
|
||||
)
|
||||
.where(Subscription.id == subscription_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_subscription_by_user_and_tariff(db: AsyncSession, user_id: int, tariff_id: int) -> Subscription | None:
|
||||
"""Get active/trial/limited subscription for a specific user+tariff combination.
|
||||
|
||||
Includes LIMITED status because those subscriptions still have time remaining
|
||||
(just ran out of traffic) and should be extended rather than duplicated.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(Subscription)
|
||||
.options(
|
||||
selectinload(Subscription.user),
|
||||
selectinload(Subscription.tariff),
|
||||
)
|
||||
.where(
|
||||
Subscription.user_id == user_id,
|
||||
Subscription.tariff_id == tariff_id,
|
||||
Subscription.status.in_(
|
||||
[
|
||||
SubscriptionStatus.ACTIVE.value,
|
||||
SubscriptionStatus.TRIAL.value,
|
||||
SubscriptionStatus.LIMITED.value,
|
||||
]
|
||||
),
|
||||
)
|
||||
.order_by(Subscription.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def deactivate_user_trial_subscriptions(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
*,
|
||||
exclude_subscription_id: int | None = None,
|
||||
) -> list[Subscription]:
|
||||
"""Deactivate all trial subscriptions for a user.
|
||||
|
||||
Called when user purchases a paid tariff — trial is a probe that must die on purchase.
|
||||
Returns remaining trial time in seconds (for TRIAL_ADD_REMAINING_DAYS_TO_PAID).
|
||||
Handles both tariff-based and squad-based trials uniformly.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(Subscription).where(
|
||||
Subscription.user_id == user_id,
|
||||
Subscription.is_trial.is_(True),
|
||||
Subscription.status.in_(
|
||||
[
|
||||
SubscriptionStatus.ACTIVE.value,
|
||||
SubscriptionStatus.TRIAL.value,
|
||||
]
|
||||
),
|
||||
)
|
||||
)
|
||||
trial_subs = list(result.scalars().all())
|
||||
|
||||
deactivated = []
|
||||
for sub in trial_subs:
|
||||
if exclude_subscription_id and sub.id == exclude_subscription_id:
|
||||
continue
|
||||
sub.status = SubscriptionStatus.DISABLED.value
|
||||
sub.is_trial = False
|
||||
sub.autopay_enabled = False
|
||||
sub.updated_at = datetime.now(UTC)
|
||||
deactivated.append(sub)
|
||||
logger.info(
|
||||
'Trial subscription deactivated on paid purchase',
|
||||
subscription_id=sub.id,
|
||||
user_id=user_id,
|
||||
tariff_id=sub.tariff_id,
|
||||
)
|
||||
|
||||
if deactivated:
|
||||
await db.flush()
|
||||
|
||||
return deactivated
|
||||
|
||||
|
||||
async def get_all_subscriptions_by_user_id(db: AsyncSession, user_id: int) -> list[Subscription]:
|
||||
"""Get all subscriptions for a user (any status).
|
||||
|
||||
Ordering: active first, then trial, then everything else — newest first within each group.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(Subscription)
|
||||
.options(
|
||||
selectinload(Subscription.user),
|
||||
selectinload(Subscription.tariff),
|
||||
)
|
||||
.where(Subscription.user_id == user_id)
|
||||
.order_by(
|
||||
case(
|
||||
(Subscription.status == SubscriptionStatus.ACTIVE.value, 0),
|
||||
(Subscription.status == SubscriptionStatus.TRIAL.value, 1),
|
||||
else_=2,
|
||||
),
|
||||
Subscription.created_at.desc(),
|
||||
)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
@@ -3,7 +3,7 @@ from sqlalchemy import func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import PromoGroup, Subscription, Tariff
|
||||
from app.database.models import PromoGroup, Subscription, SubscriptionStatus, Tariff
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
@@ -83,13 +83,16 @@ async def count_tariffs(db: AsyncSession, *, include_inactive: bool = False) ->
|
||||
async def get_trial_tariff(db: AsyncSession) -> Tariff | None:
|
||||
"""Получает тариф, доступный для триала (is_trial_available=True).
|
||||
|
||||
Триальный тариф может быть неактивным — это сделано специально,
|
||||
чтобы он не отображался в списке покупки, но использовался для триала
|
||||
со своими лимитами (трафик, устройства, серверы).
|
||||
|
||||
Сортируется по updated_at DESC, чтобы вернуть последний установленный
|
||||
триальный тариф (на случай если их несколько).
|
||||
"""
|
||||
query = (
|
||||
select(Tariff)
|
||||
.where(Tariff.is_trial_available.is_(True))
|
||||
.where(Tariff.is_active.is_(True))
|
||||
.options(selectinload(Tariff.allowed_promo_groups))
|
||||
.order_by(Tariff.updated_at.desc().nullslast(), Tariff.id.desc())
|
||||
.limit(1)
|
||||
@@ -119,6 +122,12 @@ async def clear_trial_tariff(db: AsyncSession) -> None:
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def get_all_active_tariffs(db: AsyncSession) -> list[Tariff]:
|
||||
"""Get all active tariffs."""
|
||||
result = await db.execute(select(Tariff).where(Tariff.is_active.is_(True)).order_by(Tariff.tier_level))
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_tariffs_for_user(
|
||||
db: AsyncSession,
|
||||
promo_group_id: int | None = None,
|
||||
@@ -188,7 +197,7 @@ async def create_tariff(
|
||||
# Видимость в разделе подарков
|
||||
show_in_gift: bool = True,
|
||||
# Режим сброса трафика
|
||||
traffic_reset_mode: str | None = None, # DAY, WEEK, MONTH, NO_RESET, None = глобальная настройка
|
||||
traffic_reset_mode: str | None = None, # DAY, WEEK, MONTH, MONTH_ROLLING, NO_RESET, None = глобальная настройка
|
||||
# Внешний сквад RemnaWave
|
||||
external_squad_uuid: str | None = None,
|
||||
) -> Tariff:
|
||||
@@ -390,7 +399,8 @@ async def update_tariff(
|
||||
async def delete_tariff(db: AsyncSession, tariff: Tariff) -> bool:
|
||||
"""
|
||||
Удаляет тариф.
|
||||
Подписки с этим тарифом получат tariff_id = NULL.
|
||||
FK с ondelete=RESTRICT — удаление невозможно, если есть привязанные подписки.
|
||||
Вызывающий код должен проверить отсутствие активных подписок до вызова.
|
||||
"""
|
||||
tariff_id = tariff.id
|
||||
tariff_name = tariff.name
|
||||
@@ -401,7 +411,7 @@ async def delete_tariff(db: AsyncSession, tariff: Tariff) -> bool:
|
||||
)
|
||||
affected_subscriptions = subscriptions_count.scalar_one()
|
||||
|
||||
# Удаляем тариф (FK с ondelete=SET NULL автоматически обнулит tariff_id в подписках)
|
||||
# Удаляем тариф (FK RESTRICT — подписок с tariff_id быть не должно)
|
||||
await db.delete(tariff)
|
||||
await db.commit()
|
||||
|
||||
@@ -421,6 +431,18 @@ async def get_tariff_subscriptions_count(db: AsyncSession, tariff_id: int) -> in
|
||||
return int(result.scalar_one())
|
||||
|
||||
|
||||
async def get_active_subscriptions_count_by_tariff_id(db: AsyncSession, tariff_id: int) -> int:
|
||||
"""Подсчитывает количество активных (active/trial) подписок на тарифе."""
|
||||
active_statuses = [SubscriptionStatus.ACTIVE.value, SubscriptionStatus.TRIAL.value]
|
||||
result = await db.execute(
|
||||
select(func.count(Subscription.id)).where(
|
||||
Subscription.tariff_id == tariff_id,
|
||||
Subscription.status.in_(active_statuses),
|
||||
)
|
||||
)
|
||||
return int(result.scalar_one())
|
||||
|
||||
|
||||
async def set_tariff_promo_groups(
|
||||
db: AsyncSession,
|
||||
tariff: Tariff,
|
||||
|
||||
@@ -235,7 +235,12 @@ async def get_user_total_spent_kopeks(db: AsyncSession, user_id: int) -> int:
|
||||
and_(
|
||||
Transaction.user_id == user_id,
|
||||
Transaction.is_completed.is_(True),
|
||||
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
|
||||
Transaction.type.in_(
|
||||
[
|
||||
TransactionType.SUBSCRIPTION_PAYMENT.value,
|
||||
TransactionType.GIFT_PAYMENT.value,
|
||||
]
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -344,7 +349,7 @@ async def get_transactions_statistics(
|
||||
select(
|
||||
Transaction.payment_method,
|
||||
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_(
|
||||
|
||||
+75
-19
@@ -8,6 +8,7 @@ from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.discount_offer import get_latest_claimed_offer_for_user
|
||||
from app.database.crud.promo_group import get_default_promo_group
|
||||
from app.database.crud.promo_offer_log import log_promo_offer_action
|
||||
@@ -86,7 +87,7 @@ async def get_user_by_id(db: AsyncSession, user_id: int) -> User | None:
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.options(
|
||||
selectinload(User.subscription).selectinload(Subscription.tariff),
|
||||
selectinload(User.subscriptions).selectinload(Subscription.tariff),
|
||||
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
|
||||
selectinload(User.referrer),
|
||||
selectinload(User.promo_group),
|
||||
@@ -106,7 +107,7 @@ async def get_user_by_telegram_id(db: AsyncSession, telegram_id: int) -> User |
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.options(
|
||||
selectinload(User.subscription).selectinload(Subscription.tariff),
|
||||
selectinload(User.subscriptions).selectinload(Subscription.tariff),
|
||||
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
|
||||
selectinload(User.referrer),
|
||||
selectinload(User.promo_group),
|
||||
@@ -134,11 +135,12 @@ async def find_phantom_user_by_username(db: AsyncSession, username: str) -> User
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.options(
|
||||
selectinload(User.subscription).selectinload(Subscription.tariff),
|
||||
selectinload(User.subscriptions).selectinload(Subscription.tariff),
|
||||
)
|
||||
.where(
|
||||
User.telegram_id.is_(None),
|
||||
User.auth_type == 'telegram',
|
||||
User.status != UserStatus.DELETED.value,
|
||||
func.lower(User.username) == normalized,
|
||||
)
|
||||
.with_for_update()
|
||||
@@ -155,7 +157,7 @@ async def get_user_by_username(db: AsyncSession, username: str) -> User | None:
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.options(
|
||||
selectinload(User.subscription).selectinload(Subscription.tariff),
|
||||
selectinload(User.subscriptions).selectinload(Subscription.tariff),
|
||||
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
|
||||
selectinload(User.referrer),
|
||||
selectinload(User.promo_group),
|
||||
@@ -176,7 +178,7 @@ async def get_user_by_referral_code(db: AsyncSession, referral_code: str) -> Use
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.options(
|
||||
selectinload(User.subscription).selectinload(Subscription.tariff),
|
||||
selectinload(User.subscriptions).selectinload(Subscription.tariff),
|
||||
selectinload(User.promo_group),
|
||||
selectinload(User.referrer),
|
||||
)
|
||||
@@ -195,7 +197,7 @@ async def get_user_by_remnawave_uuid(db: AsyncSession, remnawave_uuid: str) -> U
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.options(
|
||||
selectinload(User.subscription).selectinload(Subscription.tariff),
|
||||
selectinload(User.subscriptions).selectinload(Subscription.tariff),
|
||||
selectinload(User.promo_group),
|
||||
selectinload(User.referrer),
|
||||
)
|
||||
@@ -203,6 +205,21 @@ async def get_user_by_remnawave_uuid(db: AsyncSession, remnawave_uuid: str) -> U
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
# Multi-tariff: UUID lives on Subscription, not User
|
||||
if not user and settings.is_multi_tariff_enabled():
|
||||
from app.database.models import Subscription as _Subscription
|
||||
|
||||
sub_result = await db.execute(
|
||||
select(_Subscription)
|
||||
.options(
|
||||
selectinload(_Subscription.user).selectinload(User.subscriptions).selectinload(_Subscription.tariff)
|
||||
)
|
||||
.where(_Subscription.remnawave_uuid == remnawave_uuid)
|
||||
)
|
||||
sub = sub_result.scalar_one_or_none()
|
||||
if sub and sub.user:
|
||||
user = sub.user
|
||||
|
||||
if user and user.subscription:
|
||||
# Загружаем дополнительные зависимости для subscription
|
||||
_ = user.subscription.is_active
|
||||
@@ -315,6 +332,23 @@ async def create_user(
|
||||
referral_code = await create_unique_referral_code(db)
|
||||
normalized_language = _normalize_language_code(language)
|
||||
|
||||
# If no referrer provided, check Redis for pending referral from /start
|
||||
if not referred_by_id and telegram_id:
|
||||
try:
|
||||
from app.services.referral_service import clear_pending_referral, get_pending_referral
|
||||
|
||||
pending = await get_pending_referral(telegram_id)
|
||||
if pending and pending.get('referrer_id'):
|
||||
referred_by_id = pending['referrer_id']
|
||||
logger.info(
|
||||
'Resolved referral from Redis pending_referral',
|
||||
telegram_id=telegram_id,
|
||||
referrer_id=referred_by_id,
|
||||
)
|
||||
await clear_pending_referral(telegram_id)
|
||||
except Exception as e:
|
||||
logger.warning('Failed to check pending referral from Redis', error=e)
|
||||
|
||||
attempts = 3
|
||||
|
||||
for attempt in range(1, attempts + 1):
|
||||
@@ -421,7 +455,7 @@ async def lock_user_for_update(db: AsyncSession, user: User) -> User:
|
||||
select(User)
|
||||
.where(User.id == user.id)
|
||||
.options(
|
||||
selectinload(User.subscription),
|
||||
selectinload(User.subscriptions).selectinload(Subscription.tariff),
|
||||
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
|
||||
selectinload(User.promo_group),
|
||||
selectinload(User.referrer),
|
||||
@@ -441,6 +475,7 @@ async def add_user_balance(
|
||||
transaction_type: TransactionType = TransactionType.DEPOSIT,
|
||||
bot=None,
|
||||
payment_method: PaymentMethod | None = None,
|
||||
commit: bool = True,
|
||||
) -> bool:
|
||||
try:
|
||||
# Lock the user row to prevent concurrent balance race conditions
|
||||
@@ -449,7 +484,7 @@ async def add_user_balance(
|
||||
select(User)
|
||||
.where(User.id == user.id)
|
||||
.options(
|
||||
selectinload(User.subscription),
|
||||
selectinload(User.subscriptions).selectinload(Subscription.tariff),
|
||||
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
|
||||
selectinload(User.promo_group),
|
||||
selectinload(User.referrer),
|
||||
@@ -459,6 +494,14 @@ async def add_user_balance(
|
||||
)
|
||||
user = locked_result.scalar_one()
|
||||
|
||||
if amount_kopeks < 0:
|
||||
logger.error(
|
||||
'add_user_balance вызван с отрицательной суммой — используйте subtract_user_balance',
|
||||
amount_kopeks=amount_kopeks,
|
||||
user_id=user.id,
|
||||
)
|
||||
return False
|
||||
|
||||
old_balance = user.balance_kopeks
|
||||
user.balance_kopeks += amount_kopeks
|
||||
user.updated_at = datetime.now(UTC)
|
||||
@@ -475,8 +518,9 @@ async def add_user_balance(
|
||||
payment_method=payment_method,
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
if commit:
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
user_id_display = user.telegram_id or user.email or f'#{user.id}'
|
||||
logger.info(
|
||||
@@ -496,7 +540,8 @@ async def add_user_balance(
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Ошибка изменения баланса пользователя', user_id=user.id, error=e)
|
||||
await db.rollback()
|
||||
if commit:
|
||||
await db.rollback()
|
||||
return False
|
||||
|
||||
|
||||
@@ -541,7 +586,7 @@ async def lock_user_for_pricing(db: AsyncSession, user_id: int) -> User:
|
||||
.options(
|
||||
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
|
||||
selectinload(User.promo_group),
|
||||
selectinload(User.subscription).selectinload(Subscription.tariff),
|
||||
selectinload(User.subscriptions).selectinload(Subscription.tariff),
|
||||
)
|
||||
.with_for_update()
|
||||
.execution_options(populate_existing=True)
|
||||
@@ -580,7 +625,7 @@ async def subtract_user_balance(
|
||||
select(User)
|
||||
.where(User.id == user.id)
|
||||
.options(
|
||||
selectinload(User.subscription),
|
||||
selectinload(User.subscriptions).selectinload(Subscription.tariff),
|
||||
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
|
||||
selectinload(User.promo_group),
|
||||
selectinload(User.referrer),
|
||||
@@ -801,7 +846,7 @@ async def get_users_list(
|
||||
order_by_purchase_count: bool = False,
|
||||
) -> list[User]:
|
||||
query = select(User).options(
|
||||
selectinload(User.subscription).selectinload(Subscription.tariff),
|
||||
selectinload(User.subscriptions).selectinload(Subscription.tariff),
|
||||
selectinload(User.promo_group),
|
||||
selectinload(User.referrer),
|
||||
)
|
||||
@@ -876,7 +921,7 @@ async def get_users_list(
|
||||
query = query.offset(offset).limit(limit)
|
||||
|
||||
result = await db.execute(query)
|
||||
users = result.scalars().all()
|
||||
users = result.scalars().unique().all()
|
||||
|
||||
# Загружаем дополнительные зависимости для всех пользователей
|
||||
for user in users:
|
||||
@@ -961,7 +1006,7 @@ async def get_referrals(db: AsyncSession, user_id: int) -> list[User]:
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.options(
|
||||
selectinload(User.subscription).selectinload(Subscription.tariff),
|
||||
selectinload(User.subscriptions).selectinload(Subscription.tariff),
|
||||
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
|
||||
selectinload(User.referrer),
|
||||
selectinload(User.promo_group),
|
||||
@@ -986,7 +1031,7 @@ async def get_users_for_promo_segment(db: AsyncSession, segment: str) -> list[Us
|
||||
base_query = (
|
||||
select(User)
|
||||
.options(
|
||||
selectinload(User.subscription).selectinload(Subscription.tariff),
|
||||
selectinload(User.subscriptions).selectinload(Subscription.tariff),
|
||||
selectinload(User.promo_group),
|
||||
selectinload(User.referrer),
|
||||
)
|
||||
@@ -1048,7 +1093,7 @@ async def get_inactive_users(db: AsyncSession, months: int = 3) -> list[User]:
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.options(
|
||||
selectinload(User.subscription).selectinload(Subscription.tariff),
|
||||
selectinload(User.subscriptions).selectinload(Subscription.tariff),
|
||||
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
|
||||
selectinload(User.referrer),
|
||||
selectinload(User.promo_group),
|
||||
@@ -1132,7 +1177,7 @@ async def get_users_with_active_subscriptions(db: AsyncSession) -> list[User]:
|
||||
Subscription.end_date > current_time,
|
||||
)
|
||||
)
|
||||
.options(selectinload(User.subscription).selectinload(Subscription.tariff))
|
||||
.options(selectinload(User.subscriptions).selectinload(Subscription.tariff))
|
||||
)
|
||||
|
||||
return result.scalars().unique().all()
|
||||
@@ -1460,3 +1505,14 @@ async def create_user_by_oauth(
|
||||
logger.warning('Failed to emit user.created event', error=error)
|
||||
|
||||
return user
|
||||
|
||||
|
||||
async def lock_user_subscriptions_for_update(db: AsyncSession, user_id: int) -> list[Subscription]:
|
||||
"""Lock all subscriptions for a user using SELECT FOR UPDATE."""
|
||||
result = await db.execute(
|
||||
select(Subscription)
|
||||
.where(Subscription.user_id == user_id)
|
||||
.with_for_update()
|
||||
.order_by(Subscription.created_at.desc())
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import html
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
@@ -221,10 +222,10 @@ def replace_placeholders(text: str, user) -> str:
|
||||
first_name = first_name.strip() if first_name else None
|
||||
username = username.strip() if username else None
|
||||
|
||||
user_name = first_name or username or 'друг'
|
||||
display_first_name = first_name or 'друг'
|
||||
display_username = f'@{username}' if username else (first_name or 'друг')
|
||||
clean_username = username or first_name or 'друг'
|
||||
user_name = html.escape(first_name or username or 'друг')
|
||||
display_first_name = html.escape(first_name or 'друг')
|
||||
display_username = f'@{html.escape(username)}' if username else html.escape(first_name or 'друг')
|
||||
clean_username = html.escape(username or first_name or 'друг')
|
||||
|
||||
replacements = {
|
||||
'{user_name}': user_name,
|
||||
|
||||
+127
-6
@@ -28,6 +28,7 @@ from sqlalchemy import (
|
||||
Time,
|
||||
TypeDecorator,
|
||||
UniqueConstraint,
|
||||
text,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
@@ -132,6 +133,7 @@ class TransactionType(Enum):
|
||||
WITHDRAWAL = 'withdrawal'
|
||||
SUBSCRIPTION_PAYMENT = 'subscription_payment'
|
||||
REFUND = 'refund'
|
||||
FAILED_REFUND = 'failed_refund'
|
||||
REFERRAL_REWARD = 'referral_reward'
|
||||
POLL_REWARD = 'poll_reward'
|
||||
GIFT_PAYMENT = 'gift_payment'
|
||||
@@ -931,6 +933,13 @@ class PromoGroup(Base):
|
||||
if period_days in discounts:
|
||||
return discounts[period_days]
|
||||
|
||||
# For daily tariffs (period_days=1): fallback to the smallest configured period discount.
|
||||
# Admins configure discounts for standard periods (30, 90, 180, 360) but not for daily.
|
||||
# If all periods have 100% discount, daily should too.
|
||||
if period_days <= 1 and discounts:
|
||||
smallest_period = min(discounts)
|
||||
return discounts[smallest_period]
|
||||
|
||||
if self.is_default:
|
||||
try:
|
||||
from app.config import settings
|
||||
@@ -1042,7 +1051,7 @@ class Tariff(Base):
|
||||
# Видимость в разделе подарков
|
||||
show_in_gift = Column(Boolean, default=True, server_default='true', nullable=False)
|
||||
|
||||
# Режим сброса трафика: DAY, WEEK, MONTH, NO_RESET (по умолчанию берётся из конфига)
|
||||
# Режим сброса трафика: DAY, WEEK, MONTH, MONTH_ROLLING, NO_RESET (по умолчанию берётся из конфига)
|
||||
traffic_reset_mode = Column(String(20), nullable=True, default=None) # None = использовать глобальную настройку
|
||||
|
||||
# Внешний сквад RemnaWave (UUID) — назначается пользователю при создании подписки
|
||||
@@ -1202,6 +1211,8 @@ class User(Base):
|
||||
password_reset_token = Column(String(255), nullable=True)
|
||||
password_reset_expires = Column(AwareDateTime(), nullable=True)
|
||||
cabinet_last_login = Column(AwareDateTime(), nullable=True)
|
||||
# Campaign slug saved at registration, consumed at email verification
|
||||
pending_campaign_slug = Column(String(64), nullable=True)
|
||||
# Email change fields
|
||||
email_change_new = Column(String(255), nullable=True) # New email pending verification
|
||||
email_change_code = Column(String(6), nullable=True) # 6-digit verification code
|
||||
@@ -1215,7 +1226,23 @@ class User(Base):
|
||||
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)
|
||||
subscriptions = relationship('Subscription', back_populates='user', order_by='Subscription.created_at.desc()')
|
||||
|
||||
@property
|
||||
def subscription(self) -> 'Subscription | None':
|
||||
"""Deprecated: returns the first active subscription or most recent one.
|
||||
|
||||
Use user.subscriptions directly for multi-tariff support.
|
||||
"""
|
||||
if not self.subscriptions:
|
||||
return None
|
||||
# Prefer active/trial subscription
|
||||
for sub in self.subscriptions:
|
||||
if sub.status in (SubscriptionStatus.ACTIVE.value, SubscriptionStatus.TRIAL.value):
|
||||
return sub
|
||||
# Fallback to most recent (already ordered by created_at desc)
|
||||
return self.subscriptions[0]
|
||||
|
||||
transactions = relationship('Transaction', back_populates='user')
|
||||
referral_earnings = relationship('ReferralEarning', foreign_keys='ReferralEarning.user_id', back_populates='user')
|
||||
discount_offers = relationship('DiscountOffer', back_populates='user')
|
||||
@@ -1331,10 +1358,20 @@ class Subscription(Base):
|
||||
__table_args__ = (
|
||||
Index('ix_subscriptions_status_trial', 'status', 'is_trial'),
|
||||
Index('ix_subscriptions_trial_created', 'is_trial', 'created_at'),
|
||||
Index('ix_subscriptions_user_id', 'user_id'),
|
||||
Index('ix_subscriptions_user_status', 'user_id', 'status'),
|
||||
Index('ix_subscriptions_user_tariff_status', 'user_id', 'tariff_id', 'status'),
|
||||
Index(
|
||||
'uq_subscriptions_user_tariff_active',
|
||||
'user_id',
|
||||
'tariff_id',
|
||||
unique=True,
|
||||
postgresql_where=text("tariff_id IS NOT NULL AND status IN ('active', 'trial', 'limited')"),
|
||||
),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False, unique=True)
|
||||
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False)
|
||||
|
||||
status = Column(String(20), default=SubscriptionStatus.TRIAL.value)
|
||||
is_trial = Column(Boolean, default=True)
|
||||
@@ -1366,9 +1403,13 @@ class Subscription(Base):
|
||||
last_webhook_update_at = Column(AwareDateTime(), nullable=True)
|
||||
|
||||
remnawave_short_uuid = Column(String(255), nullable=True)
|
||||
remnawave_uuid = Column(String(255), nullable=True)
|
||||
remnawave_short_id = Column(
|
||||
String(16), nullable=False, unique=True, server_default=''
|
||||
) # Permanent short ID for username suffix
|
||||
|
||||
# Тариф (для режима продаж "Тарифы")
|
||||
tariff_id = Column(Integer, ForeignKey('tariffs.id', ondelete='SET NULL'), nullable=True, index=True)
|
||||
tariff_id = Column(Integer, ForeignKey('tariffs.id', ondelete='RESTRICT'), nullable=True, index=True)
|
||||
|
||||
# Суточная подписка
|
||||
is_daily_paused = Column(
|
||||
@@ -1376,7 +1417,7 @@ class Subscription(Base):
|
||||
) # Приостановлена ли суточная подписка пользователем
|
||||
last_daily_charge_at = Column(AwareDateTime(), nullable=True) # Время последнего суточного списания
|
||||
|
||||
user = relationship('User', back_populates='subscription')
|
||||
user = relationship('User', back_populates='subscriptions')
|
||||
tariff = relationship('Tariff', back_populates='subscriptions')
|
||||
discount_offers = relationship('DiscountOffer', back_populates='subscription')
|
||||
temporary_accesses = relationship(
|
||||
@@ -1655,6 +1696,8 @@ class PromoCode(Base):
|
||||
is_active = Column(Boolean, default=True)
|
||||
first_purchase_only = Column(Boolean, default=False) # Только для первой покупки
|
||||
|
||||
tariff_id = Column(Integer, ForeignKey('tariffs.id', ondelete='SET NULL'), nullable=True, index=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)
|
||||
|
||||
@@ -1663,6 +1706,7 @@ class PromoCode(Base):
|
||||
|
||||
uses = relationship('PromoCodeUse', back_populates='promocode')
|
||||
promo_group = relationship('PromoGroup')
|
||||
tariff = relationship('Tariff', foreign_keys=[tariff_id])
|
||||
|
||||
@property
|
||||
def is_valid(self) -> bool:
|
||||
@@ -2203,6 +2247,9 @@ class BroadcastHistory(Base):
|
||||
created_at = Column(AwareDateTime(), server_default=func.now())
|
||||
completed_at = Column(AwareDateTime(), nullable=True)
|
||||
|
||||
# Broadcast category for user notification preferences filtering
|
||||
category = Column(String(20), default='system', nullable=False) # system|news|promo
|
||||
|
||||
# Email broadcast fields
|
||||
channel = Column(String(20), default='telegram', nullable=False) # telegram|email|both
|
||||
email_subject = Column(String(255), nullable=True)
|
||||
@@ -2364,7 +2411,7 @@ class SubscriptionServer(Base):
|
||||
__tablename__ = 'subscription_servers'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
subscription_id = Column(Integer, ForeignKey('subscriptions.id'), nullable=False)
|
||||
subscription_id = Column(Integer, ForeignKey('subscriptions.id', ondelete='CASCADE'), nullable=False, index=True)
|
||||
server_squad_id = Column(Integer, ForeignKey('server_squads.id'), nullable=False)
|
||||
|
||||
connected_at = Column(AwareDateTime(), default=func.now())
|
||||
@@ -3288,6 +3335,8 @@ class GuestPurchase(Base):
|
||||
auto_login_token = Column(Text, nullable=True)
|
||||
recipient_warning = Column(String(50), nullable=True)
|
||||
retry_count = Column(Integer, nullable=False, default=0, server_default='0')
|
||||
receipt_uuid = Column(String(255), nullable=True, index=True)
|
||||
receipt_created_at = Column(AwareDateTime(), nullable=True)
|
||||
|
||||
landing = relationship('LandingPage', back_populates='guest_purchases', lazy='selectin')
|
||||
tariff = relationship('Tariff', lazy='selectin')
|
||||
@@ -3297,3 +3346,75 @@ class GuestPurchase(Base):
|
||||
def __repr__(self) -> str:
|
||||
token_prefix = self.token[:5] if self.token else '?'
|
||||
return f"<GuestPurchase token='{token_prefix}...' status='{self.status}'>"
|
||||
|
||||
|
||||
class NewsArticle(Base):
|
||||
"""News article for the cabinet news section."""
|
||||
|
||||
__tablename__ = 'news_articles'
|
||||
__table_args__ = (
|
||||
# Covers the main public list query: WHERE is_published = true ORDER BY published_at DESC
|
||||
Index('ix_news_articles_published_at_published', 'is_published', 'published_at'),
|
||||
# Covers the category-filtered public list: WHERE is_published = true AND category = ?
|
||||
Index('ix_news_articles_published_category', 'is_published', 'category'),
|
||||
# Covers the admin list query: ORDER BY created_at DESC
|
||||
Index('ix_news_articles_created_at', 'created_at'),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
title = Column(String(500), nullable=False)
|
||||
slug = Column(String(500), unique=True, nullable=False, index=True)
|
||||
content = Column(Text, nullable=False, default='', server_default='')
|
||||
excerpt = Column(Text, nullable=True)
|
||||
category = Column(String(100), nullable=False, default='', server_default='')
|
||||
category_color = Column(String(20), nullable=False, default='#00e5a0', server_default='#00e5a0')
|
||||
tag = Column(String(50), nullable=True)
|
||||
featured_image_url = Column(Text, nullable=True)
|
||||
is_published = Column(Boolean, nullable=False, default=False, server_default='false')
|
||||
is_featured = Column(Boolean, nullable=False, default=False, server_default='false')
|
||||
published_at = Column(AwareDateTime(), nullable=True)
|
||||
read_time_minutes = Column(Integer, nullable=False, default=1, server_default='1')
|
||||
views_count = Column(Integer, nullable=False, default=0, server_default='0')
|
||||
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())
|
||||
|
||||
category_id = Column(Integer, ForeignKey('news_categories.id', ondelete='SET NULL'), nullable=True)
|
||||
tag_id = Column(Integer, ForeignKey('news_tags.id', ondelete='SET NULL'), nullable=True)
|
||||
|
||||
author = relationship('User', backref='created_news_articles', foreign_keys=[created_by])
|
||||
category_obj = relationship('NewsCategory', foreign_keys=[category_id], lazy='noload')
|
||||
tag_obj = relationship('NewsTag', foreign_keys=[tag_id], lazy='noload')
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<NewsArticle id={self.id} slug='{self.slug}' published={self.is_published}>"
|
||||
|
||||
|
||||
class NewsCategory(Base):
|
||||
"""Managed news category with a display color."""
|
||||
|
||||
__tablename__ = 'news_categories'
|
||||
__table_args__ = (Index('ix_news_categories_name_lower', text('lower(name)'), unique=True),)
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
name = Column(String(100), nullable=False)
|
||||
color = Column(String(20), nullable=False, server_default='#00e5a0')
|
||||
created_at = Column(AwareDateTime(), server_default=func.now(), nullable=False)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<NewsCategory id={self.id} name='{self.name}'>"
|
||||
|
||||
|
||||
class NewsTag(Base):
|
||||
"""Managed news tag with a display color."""
|
||||
|
||||
__tablename__ = 'news_tags'
|
||||
__table_args__ = (Index('ix_news_tags_name_lower', text('lower(name)'), unique=True),)
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
name = Column(String(50), nullable=False)
|
||||
color = Column(String(20), nullable=False, server_default='#94a3b8')
|
||||
created_at = Column(AwareDateTime(), server_default=func.now(), nullable=False)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<NewsTag id={self.id} name='{self.name}'>"
|
||||
|
||||
Vendored
+2
-2
@@ -125,8 +125,8 @@ class CryptoBotService:
|
||||
# По документации CryptoBot, ключ ВСЕГДА SHA256 от API токена
|
||||
token = self.api_token
|
||||
if not token:
|
||||
logger.warning('CryptoBot API token не настроен, пропуск проверки подписи')
|
||||
return True
|
||||
logger.error('CryptoBot API token не настроен, отклоняем webhook')
|
||||
return False
|
||||
|
||||
try:
|
||||
secret_hash = hashlib.sha256(token.encode()).digest()
|
||||
|
||||
Vendored
+2
-2
@@ -155,8 +155,8 @@ class HeleketService:
|
||||
|
||||
def verify_webhook_signature(self, payload: dict[str, Any]) -> bool:
|
||||
if not self.is_configured:
|
||||
logger.warning('Heleket сервис не настроен, подпись пропускается')
|
||||
return True
|
||||
logger.error('Heleket сервис не настроен, отклоняем webhook')
|
||||
return False
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
logger.error('Heleket webhook payload не dict', payload=payload)
|
||||
|
||||
Vendored
+2
-1
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
from dataclasses import dataclass
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from typing import Any
|
||||
@@ -194,7 +195,7 @@ class Pal24Client:
|
||||
except Pal24APIError:
|
||||
logger.error('Pal24 signature verification failed: missing token')
|
||||
return False
|
||||
return expected == signature.upper()
|
||||
return hmac.compare_digest(expected, signature.upper())
|
||||
|
||||
@staticmethod
|
||||
def normalize_amount(amount_kopeks: int) -> Decimal:
|
||||
|
||||
Vendored
+172
-47
@@ -27,6 +27,7 @@ class TrafficLimitStrategy(Enum):
|
||||
DAY = 'DAY'
|
||||
WEEK = 'WEEK'
|
||||
MONTH = 'MONTH'
|
||||
MONTH_ROLLING = 'MONTH_ROLLING'
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -59,8 +60,6 @@ class RemnaWaveUser:
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
user_traffic: UserTraffic | None = None
|
||||
sub_last_user_agent: str | None = None
|
||||
sub_last_opened_at: datetime | None = None
|
||||
sub_revoked_at: datetime | None = None
|
||||
last_traffic_reset_at: datetime | None = None
|
||||
trojan_password: str | None = None
|
||||
@@ -147,29 +146,27 @@ class RemnaWaveNode:
|
||||
country_code: str
|
||||
is_connected: bool
|
||||
is_disabled: bool
|
||||
users_online: int | None
|
||||
users_online: int
|
||||
traffic_used_bytes: int | None
|
||||
traffic_limit_bytes: int | None
|
||||
port: int | None = None
|
||||
is_connecting: bool = False
|
||||
xray_version: str | None = None
|
||||
node_version: str | None = None
|
||||
view_position: int = 0
|
||||
tags: list[str] | None = None
|
||||
# Новые поля API
|
||||
last_status_change: datetime | None = None
|
||||
last_status_message: str | None = None
|
||||
xray_uptime: str | None = None
|
||||
xray_uptime: int = 0
|
||||
is_traffic_tracking_active: bool = False
|
||||
traffic_reset_day: int | None = None
|
||||
notify_percent: int | None = None
|
||||
consumption_multiplier: float = 1.0
|
||||
cpu_count: int | None = None
|
||||
cpu_model: str | None = None
|
||||
total_ram: str | None = None
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
provider_uuid: str | None = None
|
||||
# v2.7.0: replaced cpuCount/cpuModel/totalRam/xrayVersion/nodeVersion
|
||||
versions: dict[str, str] | None = None # {xray, node}
|
||||
system: dict[str, Any] | None = None # {info: {arch, cpus, cpuModel, memoryTotal, ...}, stats: {...}}
|
||||
active_plugin_uuid: str | None = None
|
||||
|
||||
@property
|
||||
def is_node_online(self) -> bool:
|
||||
@@ -379,15 +376,16 @@ class RemnaWaveAPI:
|
||||
except json.JSONDecodeError:
|
||||
response_data = {'raw_response': response_text}
|
||||
|
||||
if response.status == 429 and attempt < max_retries:
|
||||
if response.status in (429, 502, 503, 504) and attempt < max_retries:
|
||||
retry_after = float(response.headers.get('Retry-After', base_delay * (2**attempt)))
|
||||
logger.warning(
|
||||
'Rate limited (429) on , retry / after s',
|
||||
method=method,
|
||||
endpoint=endpoint,
|
||||
attempt=attempt + 1,
|
||||
max_retries=max_retries,
|
||||
retry_after=retry_after,
|
||||
'Retryable %s on %s %s, retry %s/%s after %ss',
|
||||
response.status,
|
||||
method,
|
||||
endpoint,
|
||||
attempt + 1,
|
||||
max_retries,
|
||||
retry_after,
|
||||
)
|
||||
await asyncio.sleep(retry_after)
|
||||
continue
|
||||
@@ -448,7 +446,7 @@ class RemnaWaveAPI:
|
||||
'trafficLimitStrategy': traffic_limit_strategy.value,
|
||||
}
|
||||
|
||||
if telegram_id:
|
||||
if telegram_id is not None:
|
||||
data['telegramId'] = telegram_id
|
||||
if email:
|
||||
data['email'] = email
|
||||
@@ -469,7 +467,22 @@ class RemnaWaveAPI:
|
||||
hwidDeviceLimit=data.get('hwidDeviceLimit'),
|
||||
status=data.get('status'),
|
||||
)
|
||||
response = await self._make_request('POST', '/api/users', data)
|
||||
try:
|
||||
response = await self._make_request('POST', '/api/users', data)
|
||||
except RemnaWaveAPIError as e:
|
||||
# A039 = FK violation on externalSquadUuid — retry without it
|
||||
error_code = (e.response_data or {}).get('errorCode', '')
|
||||
if error_code == 'A039' and 'externalSquadUuid' in data:
|
||||
stale_uuid = data.pop('externalSquadUuid')
|
||||
logger.warning(
|
||||
'A039 FK violation on externalSquadUuid, retrying without it',
|
||||
stale_uuid=stale_uuid,
|
||||
username=data.get('username'),
|
||||
)
|
||||
response = await self._make_request('POST', '/api/users', data)
|
||||
else:
|
||||
logger.error('POST /api/users FAILED — full payload', payload=data)
|
||||
raise
|
||||
user = self._parse_user(response['response'])
|
||||
logger.info(
|
||||
'POST /api/users response',
|
||||
@@ -570,10 +583,20 @@ class RemnaWaveAPI:
|
||||
|
||||
try:
|
||||
response = await self._make_request('PATCH', '/api/users', data)
|
||||
except Exception:
|
||||
# Логируем полный payload при ошибке для диагностики A039
|
||||
logger.error('PATCH /api/users FAILED — full payload', payload=data)
|
||||
raise
|
||||
except RemnaWaveAPIError as e:
|
||||
# A039 = FK violation on externalSquadUuid — retry without it
|
||||
error_code = (e.response_data or {}).get('errorCode', '')
|
||||
if error_code == 'A039' and 'externalSquadUuid' in data:
|
||||
stale_uuid = data.pop('externalSquadUuid')
|
||||
logger.warning(
|
||||
'A039 FK violation on externalSquadUuid, retrying without it',
|
||||
stale_uuid=stale_uuid,
|
||||
uuid=uuid,
|
||||
)
|
||||
response = await self._make_request('PATCH', '/api/users', data)
|
||||
else:
|
||||
logger.error('PATCH /api/users FAILED — full payload', payload=data)
|
||||
raise
|
||||
user = self._parse_user(response['response'])
|
||||
logger.info(
|
||||
'PATCH /api/users response',
|
||||
@@ -711,7 +734,7 @@ class RemnaWaveAPI:
|
||||
|
||||
async def remove_users_from_internal_squad(self, uuid: str) -> bool:
|
||||
"""Удаляет всех пользователей из Internal Squad (bulk action)"""
|
||||
response = await self._make_request('POST', f'/api/internal-squads/{uuid}/bulk-actions/remove-users')
|
||||
response = await self._make_request('DELETE', f'/api/internal-squads/{uuid}/bulk-actions/remove-users')
|
||||
return response['response']['eventSent']
|
||||
|
||||
async def reorder_internal_squads(self, items: list[dict[str, Any]]) -> list[RemnaWaveInternalSquad]:
|
||||
@@ -791,7 +814,7 @@ class RemnaWaveAPI:
|
||||
|
||||
async def remove_users_from_external_squad(self, uuid: str) -> bool:
|
||||
"""Удаляет всех пользователей из External Squad (bulk action)"""
|
||||
response = await self._make_request('POST', f'/api/external-squads/{uuid}/bulk-actions/remove-users')
|
||||
response = await self._make_request('DELETE', f'/api/external-squads/{uuid}/bulk-actions/remove-users')
|
||||
return response['response']['eventSent']
|
||||
|
||||
async def reorder_external_squads(self, items: list[dict[str, Any]]) -> list[RemnaWaveExternalSquad]:
|
||||
@@ -843,8 +866,9 @@ class RemnaWaveAPI:
|
||||
response = await self._make_request('POST', f'/api/nodes/{uuid}/actions/restart')
|
||||
return response['response']['eventSent']
|
||||
|
||||
async def restart_all_nodes(self) -> bool:
|
||||
response = await self._make_request('POST', '/api/nodes/actions/restart-all')
|
||||
async def restart_all_nodes(self, force_restart: bool = False) -> bool:
|
||||
data = {'forceRestart': force_restart}
|
||||
response = await self._make_request('POST', '/api/nodes/actions/restart-all', data)
|
||||
return response['response']['eventSent']
|
||||
|
||||
async def get_subscription_info(self, short_uuid: str) -> SubscriptionInfo:
|
||||
@@ -920,8 +944,71 @@ class RemnaWaveAPI:
|
||||
response = await self._make_request('GET', '/api/system/stats/nodes')
|
||||
return response['response']
|
||||
|
||||
async def get_nodes_metrics(self) -> dict[str, Any]:
|
||||
response = await self._make_request('GET', '/api/system/nodes/metrics')
|
||||
return response.get('response', {})
|
||||
|
||||
async def get_nodes_realtime_usage(self) -> list[dict[str, Any]]:
|
||||
return await self.get_bandwidth_stats_nodes_realtime()
|
||||
"""Get per-node metrics with per-inbound traffic breakdown.
|
||||
|
||||
Uses /api/system/nodes/metrics (replacement for removed /api/bandwidth-stats/nodes/realtime).
|
||||
Returns list of dicts with node totals + inbounds/outbounds arrays.
|
||||
"""
|
||||
try:
|
||||
metrics = await self.get_nodes_metrics()
|
||||
nodes = metrics.get('nodes', [])
|
||||
if isinstance(metrics, list):
|
||||
nodes = metrics
|
||||
result = []
|
||||
for node in nodes:
|
||||
download_bytes = 0
|
||||
upload_bytes = 0
|
||||
inbounds = []
|
||||
for ib in node.get('inboundsStats', []):
|
||||
ib_dl = parse_bytes(ib.get('download', '0'))
|
||||
ib_ul = parse_bytes(ib.get('upload', '0'))
|
||||
download_bytes += ib_dl
|
||||
upload_bytes += ib_ul
|
||||
inbounds.append(
|
||||
{
|
||||
'tag': ib.get('tag', 'unknown'),
|
||||
'downloadBytes': ib_dl,
|
||||
'uploadBytes': ib_ul,
|
||||
'totalBytes': ib_dl + ib_ul,
|
||||
}
|
||||
)
|
||||
|
||||
outbounds = []
|
||||
for ob in node.get('outboundsStats', []):
|
||||
ob_dl = parse_bytes(ob.get('download', '0'))
|
||||
ob_ul = parse_bytes(ob.get('upload', '0'))
|
||||
outbounds.append(
|
||||
{
|
||||
'tag': ob.get('tag', 'unknown'),
|
||||
'downloadBytes': ob_dl,
|
||||
'uploadBytes': ob_ul,
|
||||
'totalBytes': ob_dl + ob_ul,
|
||||
}
|
||||
)
|
||||
|
||||
result.append(
|
||||
{
|
||||
'nodeUuid': node.get('nodeUuid', ''),
|
||||
'nodeName': node.get('nodeName', ''),
|
||||
'countryEmoji': node.get('countryEmoji', ''),
|
||||
'providerName': node.get('providerName', ''),
|
||||
'downloadBytes': download_bytes,
|
||||
'uploadBytes': upload_bytes,
|
||||
'totalBytes': download_bytes + upload_bytes,
|
||||
'usersOnline': node.get('usersOnline', 0),
|
||||
'inbounds': inbounds,
|
||||
'outbounds': outbounds,
|
||||
}
|
||||
)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.warning('Failed to get nodes metrics for realtime usage', error=e)
|
||||
return []
|
||||
|
||||
async def get_user_stats_usage(self, user_uuid: str, start_date: str, end_date: str) -> dict[str, Any]:
|
||||
return await self.get_bandwidth_stats_user_legacy(user_uuid, start_date, end_date)
|
||||
@@ -933,10 +1020,6 @@ class RemnaWaveAPI:
|
||||
response = await self._make_request('GET', '/api/bandwidth-stats/nodes', params=params)
|
||||
return response['response']
|
||||
|
||||
async def get_bandwidth_stats_nodes_realtime(self) -> list[dict[str, Any]]:
|
||||
response = await self._make_request('GET', '/api/bandwidth-stats/nodes/realtime')
|
||||
return response['response']
|
||||
|
||||
async def get_bandwidth_stats_node_users(
|
||||
self, node_uuid: str, start_date: str, end_date: str, top_users_limit: int = 10
|
||||
) -> dict[str, Any]:
|
||||
@@ -1058,9 +1141,35 @@ class RemnaWaveAPI:
|
||||
return {'total': 0, 'devices': []}
|
||||
raise
|
||||
|
||||
async def get_user_devices_all(self, user_uuid: str) -> dict[str, Any]:
|
||||
"""GET /api/hwid/devices/{user_uuid} — all devices for a user (paginated)."""
|
||||
all_devices: list[dict[str, Any]] = []
|
||||
start = 0
|
||||
page_size = 1000
|
||||
|
||||
try:
|
||||
while True:
|
||||
response = await self._make_request(
|
||||
'GET', f'/api/hwid/devices/{user_uuid}', params={'start': start, 'size': page_size}
|
||||
)
|
||||
data = response.get('response', {'devices': [], 'total': 0})
|
||||
devices = data.get('devices', [])
|
||||
total = data.get('total', 0)
|
||||
all_devices.extend(devices)
|
||||
|
||||
if len(all_devices) >= total or not devices:
|
||||
break
|
||||
start += len(devices)
|
||||
except RemnaWaveAPIError as e:
|
||||
if e.status_code == 404:
|
||||
return {'total': 0, 'devices': []}
|
||||
raise
|
||||
|
||||
return {'devices': all_devices, 'total': len(all_devices)}
|
||||
|
||||
async def reset_user_devices(self, user_uuid: str) -> bool:
|
||||
try:
|
||||
devices_info = await self.get_user_devices(user_uuid)
|
||||
devices_info = await self.get_user_devices_all(user_uuid)
|
||||
devices = devices_info.get('devices', [])
|
||||
|
||||
if not devices:
|
||||
@@ -1166,8 +1275,6 @@ class RemnaWaveAPI:
|
||||
created_at=datetime.fromisoformat(user_data['createdAt'].replace('Z', '+00:00')),
|
||||
updated_at=datetime.fromisoformat(user_data['updatedAt'].replace('Z', '+00:00')),
|
||||
user_traffic=user_traffic,
|
||||
sub_last_user_agent=user_data.get('subLastUserAgent'),
|
||||
sub_last_opened_at=self._parse_optional_datetime(user_data.get('subLastOpenedAt')),
|
||||
sub_revoked_at=self._parse_optional_datetime(user_data.get('subRevokedAt')),
|
||||
last_traffic_reset_at=self._parse_optional_datetime(user_data.get('lastTrafficResetAt')),
|
||||
trojan_password=user_data.get('trojanPassword'),
|
||||
@@ -1185,6 +1292,16 @@ class RemnaWaveAPI:
|
||||
return datetime.fromisoformat(date_str.replace('Z', '+00:00'))
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _safe_int(value: Any, default: int = 0) -> int:
|
||||
"""Safely convert a value to int, returning default on failure."""
|
||||
if value is None:
|
||||
return default
|
||||
try:
|
||||
return int(value)
|
||||
except (ValueError, TypeError):
|
||||
return default
|
||||
|
||||
def _parse_inbound(self, inbound_data: dict) -> RemnaWaveInbound:
|
||||
"""Парсит данные inbound"""
|
||||
return RemnaWaveInbound(
|
||||
@@ -1232,29 +1349,26 @@ class RemnaWaveAPI:
|
||||
country_code=node_data.get('countryCode', ''),
|
||||
is_connected=node_data.get('isConnected', False),
|
||||
is_disabled=node_data.get('isDisabled', False),
|
||||
users_online=node_data.get('usersOnline'),
|
||||
users_online=node_data.get('usersOnline', 0),
|
||||
traffic_used_bytes=node_data.get('trafficUsedBytes'),
|
||||
traffic_limit_bytes=node_data.get('trafficLimitBytes'),
|
||||
port=node_data.get('port'),
|
||||
is_connecting=node_data.get('isConnecting', False),
|
||||
xray_version=node_data.get('xrayVersion'),
|
||||
node_version=node_data.get('nodeVersion'),
|
||||
view_position=node_data.get('viewPosition', 0),
|
||||
tags=node_data.get('tags', []),
|
||||
# Новые поля API
|
||||
last_status_change=self._parse_optional_datetime(node_data.get('lastStatusChange')),
|
||||
last_status_message=node_data.get('lastStatusMessage'),
|
||||
xray_uptime=node_data.get('xrayUptime'),
|
||||
xray_uptime=self._safe_int(node_data.get('xrayUptime')),
|
||||
is_traffic_tracking_active=node_data.get('isTrafficTrackingActive', False),
|
||||
traffic_reset_day=node_data.get('trafficResetDay'),
|
||||
notify_percent=node_data.get('notifyPercent'),
|
||||
consumption_multiplier=node_data.get('consumptionMultiplier', 1.0),
|
||||
cpu_count=node_data.get('cpuCount'),
|
||||
cpu_model=node_data.get('cpuModel'),
|
||||
total_ram=node_data.get('totalRam'),
|
||||
created_at=self._parse_optional_datetime(node_data.get('createdAt')),
|
||||
updated_at=self._parse_optional_datetime(node_data.get('updatedAt')),
|
||||
provider_uuid=node_data.get('providerUuid'),
|
||||
versions=node_data.get('versions'),
|
||||
system=node_data.get('system'),
|
||||
active_plugin_uuid=node_data.get('activePluginUuid'),
|
||||
)
|
||||
|
||||
def _parse_subscription_info(self, data: dict) -> SubscriptionInfo:
|
||||
@@ -1290,12 +1404,23 @@ def format_bytes(bytes_value: int) -> str:
|
||||
|
||||
|
||||
def parse_bytes(size_str: str) -> int:
|
||||
size_str = size_str.upper().strip()
|
||||
size_str = size_str.strip()
|
||||
|
||||
units = {'B': 1, 'KB': 1024, 'MB': 1024**2, 'GB': 1024**3, 'TB': 1024**4}
|
||||
# Check longest suffixes first; support both IEC (GiB) and SI (GB) units
|
||||
units = [
|
||||
('TiB', 1024**4),
|
||||
('GiB', 1024**3),
|
||||
('MiB', 1024**2),
|
||||
('KiB', 1024),
|
||||
('TB', 1024**4),
|
||||
('GB', 1024**3),
|
||||
('MB', 1024**2),
|
||||
('KB', 1024),
|
||||
('B', 1),
|
||||
]
|
||||
|
||||
for unit, multiplier in units.items():
|
||||
if size_str.endswith(unit):
|
||||
for unit, multiplier in units:
|
||||
if size_str.endswith(unit) or size_str.upper().endswith(unit.upper()):
|
||||
try:
|
||||
value = float(size_str[: -len(unit)].strip())
|
||||
return int(value * multiplier)
|
||||
|
||||
Vendored
+2
-2
@@ -35,8 +35,8 @@ class TributeService:
|
||||
|
||||
def verify_webhook_signature(self, payload: str, signature: str) -> bool:
|
||||
if not self.api_key:
|
||||
logger.warning('API key не настроен, пропускаем проверку')
|
||||
return True
|
||||
logger.error('Tribute API key не настроен — отклоняем webhook')
|
||||
return False
|
||||
|
||||
try:
|
||||
expected_signature = hmac.new(self.api_key.encode(), payload.encode(), hashlib.sha256).hexdigest()
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
Обработчики админ-панели для управления черным списком
|
||||
"""
|
||||
|
||||
import html
|
||||
|
||||
import structlog
|
||||
from aiogram import types
|
||||
from aiogram.filters import StateFilter
|
||||
@@ -147,7 +149,7 @@ async def show_blacklist_users(callback: types.CallbackQuery, db_user: User, sta
|
||||
|
||||
# Показываем первые 20 записей
|
||||
for i, (tg_id, username, reason) in enumerate(blacklist_users[:20], 1):
|
||||
text += f'{i}. <code>{tg_id}</code> {username or ""} — {reason}\n'
|
||||
text += f'{i}. <code>{tg_id}</code> {html.escape(username or "")} — {html.escape(reason or "")}\n'
|
||||
|
||||
if len(blacklist_users) > 20:
|
||||
text += f'\n... и еще {len(blacklist_users) - 20} записей'
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
и выполнять очистку БД и панели Remnawave.
|
||||
"""
|
||||
|
||||
import html
|
||||
from datetime import UTC, datetime
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
@@ -437,7 +438,7 @@ async def show_blocked_list(
|
||||
name = user_data.get('full_name') or user_data.get('username') or 'Без имени'
|
||||
telegram_id = user_data.get('telegram_id', '?')
|
||||
text += BlockedUsersText.BLOCKED_USER_ROW.value.format(
|
||||
name=name,
|
||||
name=html.escape(name),
|
||||
telegram_id=telegram_id,
|
||||
)
|
||||
|
||||
|
||||
@@ -1906,7 +1906,7 @@ async def test_payment_provider(
|
||||
return
|
||||
|
||||
amount_kopeks = 10 * 100
|
||||
description = (settings.get_balance_payment_description(amount_kopeks, telegram_user_id=db_user.telegram_id),)
|
||||
description = settings.get_balance_payment_description(amount_kopeks, telegram_user_id=db_user.telegram_id)
|
||||
payment_result = await payment_service.create_yookassa_payment(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import html
|
||||
import re
|
||||
|
||||
import structlog
|
||||
@@ -67,8 +68,8 @@ def _format_campaign_summary(campaign, texts) -> str:
|
||||
bonus_info = '❓ Неизвестный тип бонуса'
|
||||
|
||||
return (
|
||||
f'<b>{campaign.name}</b>\n'
|
||||
f'Стартовый параметр: <code>{campaign.start_parameter}</code>\n'
|
||||
f'<b>{html.escape(campaign.name)}</b>\n'
|
||||
f'Стартовый параметр: <code>{html.escape(campaign.start_parameter)}</code>\n'
|
||||
f'Статус: {status}\n'
|
||||
f'{bonus_info}\n'
|
||||
)
|
||||
@@ -244,7 +245,7 @@ async def show_campaigns_list(
|
||||
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'
|
||||
f'{status} <b>{html.escape(campaign.name)}</b> — <code>{html.escape(campaign.start_parameter)}</code>\n'
|
||||
f' Регистраций: {registrations}, баланс: {texts.format_price(total_balance)}'
|
||||
)
|
||||
if campaign.is_subscription_bonus:
|
||||
@@ -383,7 +384,7 @@ async def start_edit_campaign_name(
|
||||
await callback.message.edit_text(
|
||||
(
|
||||
'✏️ <b>Изменение названия кампании</b>\n\n'
|
||||
f'Текущее название: <b>{campaign.name}</b>\n'
|
||||
f'Текущее название: <b>{html.escape(campaign.name)}</b>\n'
|
||||
'Введите новое название (3-100 символов):'
|
||||
),
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
@@ -1183,8 +1184,8 @@ async def confirm_delete_campaign(
|
||||
|
||||
text = (
|
||||
'🗑️ <b>Удаление кампании</b>\n\n'
|
||||
f'Название: <b>{campaign.name}</b>\n'
|
||||
f'Параметр: <code>{campaign.start_parameter}</code>\n\n'
|
||||
f'Название: <b>{html.escape(campaign.name)}</b>\n'
|
||||
f'Параметр: <code>{html.escape(campaign.start_parameter)}</code>\n\n'
|
||||
'Вы уверены, что хотите удалить кампанию?'
|
||||
)
|
||||
|
||||
@@ -1591,7 +1592,7 @@ async def select_campaign_tariff(
|
||||
await state.update_data(campaign_tariff_id=tariff_id, campaign_tariff_name=tariff.name)
|
||||
await state.set_state(AdminStates.creating_campaign_tariff_days)
|
||||
await callback.message.edit_text(
|
||||
f'🎁 Выбран тариф: <b>{tariff.name}</b>\n\n📅 Введите длительность тарифа в днях (1-730):',
|
||||
f'🎁 Выбран тариф: <b>{html.escape(tariff.name)}</b>\n\n📅 Введите длительность тарифа в днях (1-730):',
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[[types.InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_campaigns')]]
|
||||
),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import html
|
||||
import math
|
||||
from datetime import UTC, datetime, time
|
||||
from zoneinfo import ZoneInfo
|
||||
@@ -70,7 +71,7 @@ def _format_contest_summary(contest, texts, tz: ZoneInfo) -> str:
|
||||
f'Дневная сводка: <b>{summary_times}</b>',
|
||||
]
|
||||
if contest.prize_text:
|
||||
parts.append(texts.t('ADMIN_CONTEST_PRIZE', 'Приз: {prize}').format(prize=contest.prize_text))
|
||||
parts.append(texts.t('ADMIN_CONTEST_PRIZE', 'Приз: {prize}').format(prize=html.escape(contest.prize_text)))
|
||||
if contest.last_daily_summary_date:
|
||||
parts.append(
|
||||
texts.t('ADMIN_CONTEST_LAST_DAILY', 'Последняя сводка: {date}').format(
|
||||
@@ -188,7 +189,7 @@ async def list_contests(
|
||||
lines.append(texts.t('ADMIN_CONTESTS_EMPTY', 'Пока нет созданных конкурсов.'))
|
||||
else:
|
||||
for contest in contests:
|
||||
lines.append(f'• <b>{contest.title}</b> (#{contest.id})')
|
||||
lines.append(f'• <b>{html.escape(contest.title)}</b> (#{contest.id})')
|
||||
contest_tz = _ensure_timezone(contest.timezone or settings.TIMEZONE)
|
||||
lines.append(_format_contest_summary(contest, texts, contest_tz))
|
||||
lines.append('')
|
||||
@@ -250,21 +251,21 @@ async def show_contest_details(
|
||||
total_events = await get_contest_events_count(db, contest.id) + virtual_count
|
||||
|
||||
lines = [
|
||||
f'🏆 <b>{contest.title}</b>',
|
||||
f'🏆 <b>{html.escape(contest.title)}</b>',
|
||||
_format_contest_summary(contest, texts, tz),
|
||||
texts.t('ADMIN_CONTEST_TOTAL_EVENTS', 'Зачётов: <b>{count}</b>').format(count=total_events),
|
||||
]
|
||||
|
||||
if contest.description:
|
||||
lines.append('')
|
||||
lines.append(contest.description)
|
||||
lines.append(html.escape(contest.description))
|
||||
|
||||
if leaderboard:
|
||||
lines.append('')
|
||||
lines.append(texts.t('ADMIN_CONTEST_LEADERBOARD_TITLE', '📊 Топ участников:'))
|
||||
for idx, (name, score, _, is_virtual) in enumerate(leaderboard, start=1):
|
||||
virt_mark = ' 👻' if is_virtual else ''
|
||||
lines.append(f'{idx}. {name}{virt_mark} — {score}')
|
||||
lines.append(f'{idx}. {html.escape(name)}{virt_mark} — {score}')
|
||||
|
||||
await callback.message.edit_text(
|
||||
'\n'.join(lines),
|
||||
@@ -444,7 +445,7 @@ async def show_leaderboard(
|
||||
]
|
||||
for idx, (name, score, _, is_virtual) in enumerate(leaderboard, start=1):
|
||||
virt_mark = ' 👻' if is_virtual else ''
|
||||
lines.append(f'{idx}. {name}{virt_mark} — {score}')
|
||||
lines.append(f'{idx}. {html.escape(name)}{virt_mark} — {score}')
|
||||
|
||||
await callback.message.edit_text(
|
||||
'\n'.join(lines),
|
||||
@@ -690,7 +691,7 @@ async def show_detailed_stats(
|
||||
# Общее сообщение с основной статистикой
|
||||
general_lines = [
|
||||
'📈 <b>Статистика конкурса</b>',
|
||||
f'🏆 {contest.title}',
|
||||
f'🏆 {html.escape(contest.title)}',
|
||||
'',
|
||||
f'👥 Участников (рефереров): <b>{stats["total_participants"]}</b>',
|
||||
f'📨 Приглашено рефералов: <b>{stats["total_invited"]}</b>',
|
||||
@@ -751,7 +752,7 @@ async def show_detailed_stats_page(
|
||||
for p in page_participants:
|
||||
lines.extend(
|
||||
[
|
||||
f'• <b>{p["full_name"]}</b>',
|
||||
f'• <b>{html.escape(p["full_name"] or "")}</b>',
|
||||
f' 📨 Приглашено: {p["total_referrals"]}',
|
||||
f' 💰 Оплатили: {p["paid_referrals"]}',
|
||||
f' ❌ Не оплатили: {p["unpaid_referrals"]}',
|
||||
@@ -828,7 +829,7 @@ async def sync_contest(
|
||||
lines = [
|
||||
'✅ <b>Синхронизация завершена!</b>',
|
||||
'',
|
||||
f'📊 <b>Конкурс:</b> {contest.title}',
|
||||
f'📊 <b>Конкурс:</b> {html.escape(contest.title)}',
|
||||
f'📅 <b>Период:</b> {contest.start_at.strftime("%d.%m.%Y")} - {contest.end_at.strftime("%d.%m.%Y")}',
|
||||
'🔍 <b>Фильтр транзакций:</b>',
|
||||
f' <code>{start_str}</code>',
|
||||
@@ -870,7 +871,7 @@ async def sync_contest(
|
||||
# Обновляем основное сообщение с новой статистикой
|
||||
detailed_stats = await referral_contest_service.get_detailed_contest_stats(db, contest_id)
|
||||
general_lines = [
|
||||
f'🏆 <b>{contest.title}</b>',
|
||||
f'🏆 <b>{html.escape(contest.title)}</b>',
|
||||
f'📅 Период: {contest.start_at.strftime("%d.%m.%Y")} - {contest.end_at.strftime("%d.%m.%Y")}',
|
||||
'',
|
||||
f'👥 Участников (рефереров): <b>{detailed_stats["total_participants"]}</b>',
|
||||
@@ -927,7 +928,7 @@ async def debug_contest_transactions(
|
||||
lines = [
|
||||
'🔍 <b>Отладка транзакций конкурса</b>',
|
||||
'',
|
||||
f'📊 <b>Конкурс:</b> {contest.title}',
|
||||
f'📊 <b>Конкурс:</b> {html.escape(contest.title)}',
|
||||
'📅 <b>Период фильтрации:</b>',
|
||||
f' Начало: <code>{debug_data.get("contest_start")}</code>',
|
||||
f' Конец: <code>{debug_data.get("contest_end")}</code>',
|
||||
@@ -1002,10 +1003,10 @@ async def show_virtual_participants(
|
||||
|
||||
vps = await list_virtual_participants(db, contest_id)
|
||||
|
||||
lines = [f'👻 <b>Виртуальные участники</b> — {contest.title}', '']
|
||||
lines = [f'👻 <b>Виртуальные участники</b> — {html.escape(contest.title)}', '']
|
||||
if vps:
|
||||
for vp in vps:
|
||||
lines.append(f'• {vp.display_name} — {vp.referral_count} реф.')
|
||||
lines.append(f'• {html.escape(vp.display_name)} — {vp.referral_count} реф.')
|
||||
else:
|
||||
lines.append('Пока нет виртуальных участников.')
|
||||
|
||||
@@ -1156,10 +1157,10 @@ async def delete_virtual_participant_handler(
|
||||
vps = await list_virtual_participants(db, contest_id)
|
||||
contest = await get_referral_contest(db, contest_id)
|
||||
|
||||
lines = [f'👻 <b>Виртуальные участники</b> — {contest.title}', '']
|
||||
lines = [f'👻 <b>Виртуальные участники</b> — {html.escape(contest.title)}', '']
|
||||
if vps:
|
||||
for v in vps:
|
||||
lines.append(f'• {v.display_name} — {v.referral_count} реф.')
|
||||
lines.append(f'• {html.escape(v.display_name)} — {v.referral_count} реф.')
|
||||
else:
|
||||
lines.append('Пока нет виртуальных участников.')
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import html
|
||||
|
||||
import structlog
|
||||
from aiogram import Dispatcher, F, types
|
||||
from aiogram.filters import Command
|
||||
@@ -135,6 +137,8 @@ async def show_support_submenu(callback: types.CallbackQuery, db_user: User, db:
|
||||
|
||||
|
||||
# Moderator panel entry (from main menu quick button)
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_moderator_panel(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||||
texts = get_texts(db_user.language)
|
||||
kb = InlineKeyboardMarkup(
|
||||
@@ -283,7 +287,7 @@ async def clear_rules_command(message: types.Message, db_user: User, db: AsyncSe
|
||||
f'📊 <b>Статистика:</b>\n'
|
||||
f'• Очищено правил: {stats["total_active"]}\n'
|
||||
f'• Язык: {db_user.language}\n'
|
||||
f'• Выполнил: {db_user.full_name}\n\n'
|
||||
f'• Выполнил: {html.escape(db_user.full_name or "")}\n\n'
|
||||
f'Теперь используются стандартные правила по умолчанию.'
|
||||
)
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import html
|
||||
|
||||
import structlog
|
||||
from aiogram import Dispatcher, F, types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
@@ -133,7 +135,7 @@ async def process_maintenance_reason(message: types.Message, db_user: User, db:
|
||||
if success:
|
||||
response_text = 'Режим техработ включен'
|
||||
if reason:
|
||||
response_text += f'\nПричина: {reason}'
|
||||
response_text += f'\nПричина: {html.escape(reason)}'
|
||||
else:
|
||||
response_text = 'Ошибка включения режима техработ'
|
||||
|
||||
|
||||
@@ -83,7 +83,6 @@ CABINET_MINIAPP_BUTTON_KEYS = {
|
||||
'connect',
|
||||
'subscription',
|
||||
'support',
|
||||
'home',
|
||||
}
|
||||
|
||||
|
||||
@@ -97,7 +96,11 @@ def get_updated_message_buttons_selector_keyboard(
|
||||
return get_updated_message_buttons_selector_keyboard_with_media(selected_buttons, False, language)
|
||||
|
||||
|
||||
def create_broadcast_keyboard(selected_buttons: list, language: str = 'ru') -> types.InlineKeyboardMarkup | None:
|
||||
def create_broadcast_keyboard(
|
||||
selected_buttons: list,
|
||||
language: str = 'ru',
|
||||
custom_buttons: list[dict] | None = None,
|
||||
) -> types.InlineKeyboardMarkup | None:
|
||||
selected_buttons = selected_buttons or []
|
||||
keyboard: list[list[types.InlineKeyboardButton]] = []
|
||||
button_config_map = get_broadcast_button_config(language)
|
||||
@@ -123,6 +126,20 @@ def create_broadcast_keyboard(selected_buttons: list, language: str = 'ru') -> t
|
||||
if row_buttons:
|
||||
keyboard.append(row_buttons)
|
||||
|
||||
# Append custom buttons (each on its own row)
|
||||
if custom_buttons:
|
||||
for btn in custom_buttons:
|
||||
label = btn.get('label', '')
|
||||
action_type = btn.get('action_type', 'callback')
|
||||
action_value = btn.get('action_value', '')
|
||||
if not label or not action_value:
|
||||
continue
|
||||
if action_type == 'url':
|
||||
keyboard.append([types.InlineKeyboardButton(text=label, url=action_value)])
|
||||
else:
|
||||
# callback type
|
||||
keyboard.append([types.InlineKeyboardButton(text=label, callback_data=action_value)])
|
||||
|
||||
if not keyboard:
|
||||
return None
|
||||
|
||||
@@ -626,7 +643,7 @@ async def show_messages_history(callback: types.CallbackQuery, db_user: User, db
|
||||
{status_emoji} <b>{broadcast.created_at.strftime('%d.%m.%Y %H:%M')}</b>
|
||||
📊 Отправлено: {broadcast.sent_count}/{broadcast.total_count} ({success_rate}%)
|
||||
🎯 Аудитория: {get_target_name(broadcast.target_type)}
|
||||
👤 Админ: {broadcast.admin_name}
|
||||
👤 Админ: {html.escape(broadcast.admin_name or '')}
|
||||
📝 Сообщение: {message_preview}
|
||||
━━━━━━━━━━━━━━━━━━━━━━━
|
||||
"""
|
||||
@@ -1460,7 +1477,7 @@ async def confirm_broadcast(callback: types.CallbackQuery, db_user: User, state:
|
||||
f'• Не доставлено: {failed_count}\n'
|
||||
f'• Всего пользователей: {total_users_count}\n'
|
||||
f'• Успешность: {success_rate}%{media_info}\n\n'
|
||||
f'<b>Администратор:</b> {admin_name}'
|
||||
f'<b>Администратор:</b> {html.escape(admin_name)}'
|
||||
)
|
||||
|
||||
back_keyboard = types.InlineKeyboardMarkup(
|
||||
@@ -1582,42 +1599,28 @@ async def get_target_users_count(db: AsyncSession, target: str) -> int:
|
||||
result = await db.execute(query)
|
||||
return result.scalar() or 0
|
||||
|
||||
if target == 'expired':
|
||||
# Истекшие подписки
|
||||
if target in ('expired', 'expired_subscribers'):
|
||||
# Истекшие подписки — исключаем юзеров с хотя бы одной активной
|
||||
now = datetime.now(UTC)
|
||||
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)
|
||||
has_active_sub = (
|
||||
select(Subscription.id)
|
||||
.where(
|
||||
base_filter,
|
||||
or_(
|
||||
Subscription.status.in_(expired_statuses),
|
||||
and_(Subscription.end_date <= now, Subscription.status != SubscriptionStatus.ACTIVE.value),
|
||||
and_(Subscription.id == None, User.has_had_paid_subscription == True),
|
||||
),
|
||||
Subscription.user_id == User.id,
|
||||
Subscription.status == SubscriptionStatus.ACTIVE.value,
|
||||
)
|
||||
.exists()
|
||||
)
|
||||
result = await db.execute(query)
|
||||
return result.scalar() or 0
|
||||
|
||||
if target == 'expired_subscribers':
|
||||
# То же что и expired
|
||||
now = datetime.now(UTC)
|
||||
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)
|
||||
.where(
|
||||
base_filter,
|
||||
~has_active_sub,
|
||||
or_(
|
||||
Subscription.status.in_(expired_statuses),
|
||||
and_(Subscription.end_date <= now, Subscription.status != SubscriptionStatus.ACTIVE.value),
|
||||
@@ -1745,14 +1748,14 @@ async def get_target_users(db: AsyncSession, target: str) -> list:
|
||||
return [
|
||||
user
|
||||
for user in users
|
||||
if user.subscription and user.subscription.is_active and not user.subscription.is_trial
|
||||
if any(s.is_active and not s.is_trial for s in (getattr(user, 'subscriptions', None) or []))
|
||||
]
|
||||
|
||||
if target == 'trial':
|
||||
return [user for user in users if user.subscription and user.subscription.is_trial]
|
||||
return [user for user in users if any(s.is_trial for s in (getattr(user, 'subscriptions', None) or []))]
|
||||
|
||||
if target == 'no':
|
||||
return [user for user in users if not user.subscription or not user.subscription.is_active]
|
||||
return [user for user in users if not any(s.is_active for s in (getattr(user, 'subscriptions', None) or []))]
|
||||
|
||||
if target == 'expiring':
|
||||
expiring_subs = await get_expiring_subscriptions(db, 3)
|
||||
@@ -1766,14 +1769,14 @@ async def get_target_users(db: AsyncSession, target: str) -> list:
|
||||
}
|
||||
expired_users = []
|
||||
for user in users:
|
||||
subscription = user.subscription
|
||||
if subscription:
|
||||
if subscription.status in expired_statuses:
|
||||
subs = getattr(user, 'subscriptions', None) or []
|
||||
if subs:
|
||||
has_active = any(s.is_active for s in subs)
|
||||
if has_active:
|
||||
continue # Skip users who have at least one active subscription
|
||||
has_expired = any(s.status in expired_statuses or (s.end_date <= now and not s.is_active) for s in subs)
|
||||
if has_expired:
|
||||
expired_users.append(user)
|
||||
continue
|
||||
if subscription.end_date <= now and not subscription.is_active:
|
||||
expired_users.append(user)
|
||||
continue
|
||||
elif user.has_had_paid_subscription:
|
||||
expired_users.append(user)
|
||||
return expired_users
|
||||
@@ -1782,27 +1785,27 @@ async def get_target_users(db: AsyncSession, target: str) -> list:
|
||||
return [
|
||||
user
|
||||
for user in users
|
||||
if user.subscription
|
||||
and not user.subscription.is_trial
|
||||
and user.subscription.is_active
|
||||
and (user.subscription.traffic_used_gb or 0) <= 0
|
||||
if any(
|
||||
not s.is_trial and s.is_active and (s.traffic_used_gb or 0) <= 0
|
||||
for s in (getattr(user, 'subscriptions', None) or [])
|
||||
)
|
||||
]
|
||||
|
||||
if target == 'trial_zero':
|
||||
return [
|
||||
user
|
||||
for user in users
|
||||
if user.subscription
|
||||
and user.subscription.is_trial
|
||||
and user.subscription.is_active
|
||||
and (user.subscription.traffic_used_gb or 0) <= 0
|
||||
if any(
|
||||
s.is_trial and s.is_active and (s.traffic_used_gb or 0) <= 0
|
||||
for s in (getattr(user, 'subscriptions', None) or [])
|
||||
)
|
||||
]
|
||||
|
||||
if target == 'zero':
|
||||
return [
|
||||
user
|
||||
for user in users
|
||||
if user.subscription and user.subscription.is_active and (user.subscription.traffic_used_gb or 0) <= 0
|
||||
if any(s.is_active and (s.traffic_used_gb or 0) <= 0 for s in (getattr(user, 'subscriptions', None) or []))
|
||||
]
|
||||
|
||||
if target == 'expiring_subscribers':
|
||||
@@ -1817,14 +1820,14 @@ async def get_target_users(db: AsyncSession, target: str) -> list:
|
||||
}
|
||||
expired_users = []
|
||||
for user in users:
|
||||
subscription = user.subscription
|
||||
if subscription:
|
||||
if subscription.status in expired_statuses:
|
||||
subs = getattr(user, 'subscriptions', None) or []
|
||||
if subs:
|
||||
has_active = any(s.is_active for s in subs)
|
||||
if has_active:
|
||||
continue # Skip users who have at least one active subscription
|
||||
has_expired = any(s.status in expired_statuses or (s.end_date <= now and not s.is_active) for s in subs)
|
||||
if has_expired:
|
||||
expired_users.append(user)
|
||||
continue
|
||||
if subscription.end_date <= now and not subscription.is_active:
|
||||
expired_users.append(user)
|
||||
continue
|
||||
elif user.has_had_paid_subscription:
|
||||
expired_users.append(user)
|
||||
return expired_users
|
||||
@@ -1833,7 +1836,7 @@ async def get_target_users(db: AsyncSession, target: str) -> list:
|
||||
return [
|
||||
user
|
||||
for user in users
|
||||
if user.subscription and user.subscription.status == SubscriptionStatus.DISABLED.value
|
||||
if any(s.status == SubscriptionStatus.DISABLED.value for s in (getattr(user, 'subscriptions', None) or []))
|
||||
]
|
||||
|
||||
if target == 'trial_ending':
|
||||
@@ -1842,10 +1845,10 @@ async def get_target_users(db: AsyncSession, target: str) -> list:
|
||||
return [
|
||||
user
|
||||
for user in users
|
||||
if user.subscription
|
||||
and user.subscription.is_trial
|
||||
and user.subscription.is_active
|
||||
and user.subscription.end_date <= in_3_days
|
||||
if any(
|
||||
s.is_trial and s.is_active and s.end_date <= in_3_days
|
||||
for s in (getattr(user, 'subscriptions', None) or [])
|
||||
)
|
||||
]
|
||||
|
||||
if target == 'trial_expired':
|
||||
@@ -1853,7 +1856,7 @@ async def get_target_users(db: AsyncSession, target: str) -> list:
|
||||
return [
|
||||
user
|
||||
for user in users
|
||||
if user.subscription and user.subscription.is_trial and user.subscription.end_date <= now
|
||||
if any(s.is_trial and s.end_date <= now for s in (getattr(user, 'subscriptions', None) or []))
|
||||
]
|
||||
|
||||
if target == 'autopay_failed':
|
||||
@@ -1898,7 +1901,7 @@ async def get_target_users(db: AsyncSession, target: str) -> list:
|
||||
return [
|
||||
user
|
||||
for user in users
|
||||
if user.subscription and user.subscription.is_active and user.subscription.tariff_id == tariff_id
|
||||
if any(s.is_active and s.tariff_id == tariff_id for s in (getattr(user, 'subscriptions', None) or []))
|
||||
]
|
||||
|
||||
return []
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import asyncio
|
||||
import html
|
||||
from datetime import UTC, date, datetime, timedelta
|
||||
|
||||
import structlog
|
||||
@@ -741,7 +742,7 @@ async def traffic_check_callback(callback: CallbackQuery):
|
||||
if violations:
|
||||
text += '\n⚠️ <b>Превышения дельты:</b>\n'
|
||||
for v in violations[:10]:
|
||||
name = v.full_name or v.user_uuid[:8]
|
||||
name = html.escape(v.full_name or '') or v.user_uuid[:8]
|
||||
text += f'• {name}: +{v.used_traffic_gb:.1f} ГБ\n'
|
||||
if len(violations) > 10:
|
||||
text += f'... и ещё {len(violations) - 10}\n'
|
||||
|
||||
@@ -886,7 +886,7 @@ async def _render_poll_details(poll: Poll, language: str) -> str:
|
||||
texts = get_texts(language)
|
||||
lines = [f'🗳️ <b>{html.escape(poll.title)}</b>']
|
||||
if poll.description:
|
||||
lines.append(poll.description)
|
||||
lines.append(html.escape(poll.description))
|
||||
|
||||
lines.append(_format_reward_text(poll, language))
|
||||
lines.append(texts.t('ADMIN_POLLS_QUESTIONS_COUNT', 'Вопросов: {count}').format(count=len(poll.questions)))
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import html
|
||||
from decimal import ROUND_HALF_UP, Decimal, InvalidOperation
|
||||
|
||||
import structlog
|
||||
@@ -293,7 +294,7 @@ def _build_edit_menu_content(
|
||||
header = texts.t(
|
||||
'ADMIN_PROMO_GROUP_EDIT_MENU_TITLE',
|
||||
'✏️ Настройки промогруппы «{name}»',
|
||||
).format(name=group.name)
|
||||
).format(name=html.escape(group.name))
|
||||
|
||||
lines = [header]
|
||||
lines.extend(_format_discount_lines(texts, group))
|
||||
@@ -462,28 +463,17 @@ async def show_promo_groups_menu(
|
||||
|
||||
keyboard_rows = []
|
||||
for group, member_count in groups:
|
||||
icon = '⭐' if group.is_default else '🎯'
|
||||
default_suffix = texts.t('ADMIN_PROMO_GROUPS_DEFAULT_LABEL', ' (базовая)') if group.is_default else ''
|
||||
group_lines = [
|
||||
f'{"⭐" if group.is_default else "🎯"} <b>{group.name}</b>{default_suffix}',
|
||||
]
|
||||
group_lines.extend(_format_discount_lines(texts, group))
|
||||
group_lines.append(_format_auto_assign_line(texts, group))
|
||||
group_lines.append(
|
||||
texts.t(
|
||||
'ADMIN_PROMO_GROUPS_MEMBERS_COUNT',
|
||||
'Участников: {count}',
|
||||
).format(count=member_count)
|
||||
)
|
||||
|
||||
period_lines = _format_period_discounts_lines(texts, group, db_user.language)
|
||||
group_lines.extend(period_lines)
|
||||
group_lines.append('')
|
||||
|
||||
lines.extend(group_lines)
|
||||
members_label = texts.t(
|
||||
'ADMIN_PROMO_GROUPS_MEMBERS_COUNT',
|
||||
'Участников: {count}',
|
||||
).format(count=member_count)
|
||||
lines.append(f'{icon} <b>{html.escape(group.name)}</b>{default_suffix} — {members_label}')
|
||||
keyboard_rows.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=f'{"⭐" if group.is_default else "🎯"} {group.name}',
|
||||
text=f'{icon} {group.name}',
|
||||
callback_data=f'promo_group_manage_{group.id}',
|
||||
)
|
||||
]
|
||||
@@ -535,7 +525,7 @@ async def show_promo_group_details(
|
||||
texts.t(
|
||||
'ADMIN_PROMO_GROUP_DETAILS_TITLE',
|
||||
'💳 <b>Промогруппа:</b> {name}',
|
||||
).format(name=group.name)
|
||||
).format(name=html.escape(group.name))
|
||||
]
|
||||
lines.extend(_format_discount_lines(texts, group))
|
||||
lines.append(_format_auto_assign_line(texts, group))
|
||||
@@ -813,7 +803,7 @@ async def process_create_group_auto_assign(
|
||||
|
||||
await state.clear()
|
||||
await message.answer(
|
||||
texts.t('ADMIN_PROMO_GROUP_CREATED', 'Промогруппа «{name}» создана.').format(name=group.name),
|
||||
texts.t('ADMIN_PROMO_GROUP_CREATED', 'Промогруппа «{name}» создана.').format(name=html.escape(group.name)),
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
@@ -886,7 +876,7 @@ async def prompt_edit_promo_group_field(
|
||||
prompt = texts.t(
|
||||
'ADMIN_PROMO_GROUP_EDIT_NAME_PROMPT',
|
||||
'Введите новое название промогруппы (текущее: {name}):',
|
||||
).format(name=group.name)
|
||||
).format(name=html.escape(group.name))
|
||||
elif field == 'priority':
|
||||
await state.set_state(AdminStates.editing_promo_group_priority)
|
||||
prompt = texts.t(
|
||||
@@ -962,7 +952,7 @@ async def process_edit_group_name(
|
||||
texts,
|
||||
group,
|
||||
data.get('language', db_user.language),
|
||||
texts.t('ADMIN_PROMO_GROUP_UPDATED', 'Промогруппа «{name}» обновлена.').format(name=group.name),
|
||||
texts.t('ADMIN_PROMO_GROUP_UPDATED', 'Промогруппа «{name}» обновлена.').format(name=html.escape(group.name)),
|
||||
)
|
||||
|
||||
|
||||
@@ -1004,7 +994,7 @@ async def process_edit_group_priority(
|
||||
texts,
|
||||
group,
|
||||
data.get('language', db_user.language),
|
||||
texts.t('ADMIN_PROMO_GROUP_UPDATED', 'Промогруппа «{name}» обновлена.').format(name=group.name),
|
||||
texts.t('ADMIN_PROMO_GROUP_UPDATED', 'Промогруппа «{name}» обновлена.').format(name=html.escape(group.name)),
|
||||
)
|
||||
|
||||
|
||||
@@ -1039,7 +1029,7 @@ async def process_edit_group_traffic(
|
||||
texts,
|
||||
group,
|
||||
data.get('language', db_user.language),
|
||||
texts.t('ADMIN_PROMO_GROUP_UPDATED', 'Промогруппа «{name}» обновлена.').format(name=group.name),
|
||||
texts.t('ADMIN_PROMO_GROUP_UPDATED', 'Промогруппа «{name}» обновлена.').format(name=html.escape(group.name)),
|
||||
)
|
||||
|
||||
|
||||
@@ -1074,7 +1064,7 @@ async def process_edit_group_servers(
|
||||
texts,
|
||||
group,
|
||||
data.get('language', db_user.language),
|
||||
texts.t('ADMIN_PROMO_GROUP_UPDATED', 'Промогруппа «{name}» обновлена.').format(name=group.name),
|
||||
texts.t('ADMIN_PROMO_GROUP_UPDATED', 'Промогруппа «{name}» обновлена.').format(name=html.escape(group.name)),
|
||||
)
|
||||
|
||||
|
||||
@@ -1109,7 +1099,7 @@ async def process_edit_group_devices(
|
||||
texts,
|
||||
group,
|
||||
data.get('language', db_user.language),
|
||||
texts.t('ADMIN_PROMO_GROUP_UPDATED', 'Промогруппа «{name}» обновлена.').format(name=group.name),
|
||||
texts.t('ADMIN_PROMO_GROUP_UPDATED', 'Промогруппа «{name}» обновлена.').format(name=html.escape(group.name)),
|
||||
)
|
||||
|
||||
|
||||
@@ -1149,7 +1139,7 @@ async def process_edit_group_period_discounts(
|
||||
texts,
|
||||
group,
|
||||
data.get('language', db_user.language),
|
||||
texts.t('ADMIN_PROMO_GROUP_UPDATED', 'Промогруппа «{name}» обновлена.').format(name=group.name),
|
||||
texts.t('ADMIN_PROMO_GROUP_UPDATED', 'Промогруппа «{name}» обновлена.').format(name=html.escape(group.name)),
|
||||
)
|
||||
|
||||
|
||||
@@ -1193,7 +1183,7 @@ async def process_edit_group_auto_assign(
|
||||
texts,
|
||||
group,
|
||||
data.get('language', db_user.language),
|
||||
texts.t('ADMIN_PROMO_GROUP_UPDATED', 'Промогруппа «{name}» обновлена.').format(name=group.name),
|
||||
texts.t('ADMIN_PROMO_GROUP_UPDATED', 'Промогруппа «{name}» обновлена.').format(name=html.escape(group.name)),
|
||||
)
|
||||
|
||||
|
||||
@@ -1223,19 +1213,20 @@ async def show_promo_group_members(
|
||||
title = texts.t(
|
||||
'ADMIN_PROMO_GROUP_MEMBERS_TITLE',
|
||||
'👥 Участники группы {name}',
|
||||
).format(name=group.name)
|
||||
).format(name=html.escape(group.name))
|
||||
|
||||
if not members:
|
||||
body = texts.t('ADMIN_PROMO_GROUP_MEMBERS_EMPTY', 'В этой группе пока нет участников.')
|
||||
else:
|
||||
lines = []
|
||||
for index, user in enumerate(members, start=offset + 1):
|
||||
username = f'@{user.username}' if user.username else '—'
|
||||
username = f'@{html.escape(user.username)}' if user.username else '—'
|
||||
safe_name = html.escape(user.full_name or '')
|
||||
if user.telegram_id:
|
||||
user_link = f'<a href="tg://user?id={user.telegram_id}">{user.full_name}</a>'
|
||||
user_link = f'<a href="tg://user?id={user.telegram_id}">{safe_name}</a>'
|
||||
tg_display = str(user.telegram_id)
|
||||
else:
|
||||
user_link = f'<b>{user.full_name}</b>'
|
||||
user_link = f'<b>{safe_name}</b>'
|
||||
tg_display = user.email or f'#{user.id}'
|
||||
lines.append(f'{index}. {user_link} (ID {user.id}, {username}, TG {tg_display})')
|
||||
body = '\n'.join(lines)
|
||||
@@ -1284,7 +1275,7 @@ async def request_delete_promo_group(
|
||||
confirm_text = texts.t(
|
||||
'ADMIN_PROMO_GROUP_DELETE_CONFIRM',
|
||||
'Удалить промогруппу «{name}»? Все пользователи будут переведены в базовую группу.',
|
||||
).format(name=group.name)
|
||||
).format(name=html.escape(group.name))
|
||||
|
||||
await callback.message.edit_text(
|
||||
confirm_text,
|
||||
@@ -1319,7 +1310,7 @@ async def delete_promo_group_confirmed(
|
||||
return
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.t('ADMIN_PROMO_GROUP_DELETED', 'Промогруппа «{name}» удалена.').format(name=group.name),
|
||||
texts.t('ADMIN_PROMO_GROUP_DELETED', 'Промогруппа «{name}» удалена.').format(name=html.escape(group.name)),
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[[types.InlineKeyboardButton(text=texts.BACK, callback_data='admin_promo_groups')]]
|
||||
),
|
||||
|
||||
@@ -677,7 +677,7 @@ def _describe_offer(
|
||||
label = texts.t(config.get('label_key', ''), config.get('default_label', template.offer_type))
|
||||
icon = config.get('icon', '📨')
|
||||
|
||||
lines = [f'{icon} <b>{template.name}</b>', '']
|
||||
lines = [f'{icon} <b>{html.escape(template.name)}</b>', '']
|
||||
lines.append(texts.t('ADMIN_PROMO_OFFER_TYPE', 'Тип: {label}').format(label=label))
|
||||
lines.append(texts.t('ADMIN_PROMO_OFFER_VALID', 'Срок действия: {hours} ч').format(hours=template.valid_hours))
|
||||
|
||||
@@ -1468,7 +1468,51 @@ async def show_selected_user_details(
|
||||
)
|
||||
|
||||
subscription = getattr(user, 'subscription', None)
|
||||
if subscription:
|
||||
subscriptions_list = getattr(user, 'subscriptions', None) or []
|
||||
if settings.is_multi_tariff_enabled() and subscriptions_list:
|
||||
lines.append('')
|
||||
lines.append(texts.t('ADMIN_PROMO_OFFER_SEND_USER_SUBSCRIPTION', '💳 <b>Подписки</b>'))
|
||||
for sub in subscriptions_list:
|
||||
tariff_name = sub.tariff.name if sub.tariff else f'#{sub.id}'
|
||||
lines.append(f'<b>{tariff_name}</b>')
|
||||
lines.append(
|
||||
texts.t(
|
||||
'ADMIN_PROMO_OFFER_SEND_USER_SUBSCRIPTION_STATUS',
|
||||
'Статус: {status}',
|
||||
).format(status=sub.status_display)
|
||||
)
|
||||
end_date_text = (
|
||||
format_datetime(sub.end_date)
|
||||
if sub.end_date
|
||||
else texts.t(
|
||||
'ADMIN_PROMO_OFFER_SEND_USER_SUBSCRIPTION_END_UNKNOWN',
|
||||
'не указано',
|
||||
)
|
||||
)
|
||||
lines.append(
|
||||
texts.t(
|
||||
'ADMIN_PROMO_OFFER_SEND_USER_SUBSCRIPTION_END',
|
||||
'Истекает: {date}',
|
||||
).format(date=end_date_text)
|
||||
)
|
||||
lines.append(
|
||||
texts.t(
|
||||
'ADMIN_PROMO_OFFER_SEND_USER_SUBSCRIPTION_TRAFFIC',
|
||||
'Трафик: {used}/{limit} ГБ',
|
||||
).format(
|
||||
used=sub.traffic_used_gb or 0,
|
||||
limit=sub.traffic_limit_gb or 0,
|
||||
)
|
||||
)
|
||||
connected = sub.connected_squads or []
|
||||
if connected:
|
||||
lines.append(
|
||||
texts.t(
|
||||
'ADMIN_PROMO_OFFER_SEND_USER_SUBSCRIPTION_SQUADS',
|
||||
'Подключено сквадов: {count}',
|
||||
).format(count=len(connected))
|
||||
)
|
||||
elif subscription:
|
||||
lines.append('')
|
||||
lines.append(texts.t('ADMIN_PROMO_OFFER_SEND_USER_SUBSCRIPTION', '💳 <b>Подписка</b>'))
|
||||
lines.append(
|
||||
@@ -1771,7 +1815,21 @@ async def show_selected_user_details(
|
||||
).format(count=len(active_offers))
|
||||
)
|
||||
|
||||
if subscription:
|
||||
if settings.is_multi_tariff_enabled() and subscriptions_list:
|
||||
now = datetime.now(UTC)
|
||||
sub_ids = [sub.id for sub in subscriptions_list]
|
||||
result = await db.execute(
|
||||
select(SubscriptionTemporaryAccess)
|
||||
.options(selectinload(SubscriptionTemporaryAccess.offer))
|
||||
.where(
|
||||
SubscriptionTemporaryAccess.subscription_id.in_(sub_ids),
|
||||
SubscriptionTemporaryAccess.is_active == True,
|
||||
SubscriptionTemporaryAccess.expires_at > now,
|
||||
)
|
||||
.order_by(SubscriptionTemporaryAccess.expires_at.desc())
|
||||
)
|
||||
accesses = result.scalars().all()
|
||||
elif subscription:
|
||||
now = datetime.now(UTC)
|
||||
result = await db.execute(
|
||||
select(SubscriptionTemporaryAccess)
|
||||
@@ -1844,7 +1902,11 @@ async def show_selected_user_details(
|
||||
|
||||
|
||||
def _build_connect_button_rows(user: User, texts) -> list[list[InlineKeyboardButton]]:
|
||||
subscription = getattr(user, 'subscription', None)
|
||||
if settings.is_multi_tariff_enabled():
|
||||
subs = getattr(user, 'subscriptions', None) or []
|
||||
subscription = next((s for s in subs if s.subscription_url), None)
|
||||
else:
|
||||
subscription = getattr(user, 'subscription', None)
|
||||
if not subscription:
|
||||
return []
|
||||
|
||||
@@ -1930,12 +1992,25 @@ async def _send_offer_to_users(
|
||||
try:
|
||||
# Используем отдельную сессию для изоляции транзакции
|
||||
async with AsyncSessionLocal() as new_db:
|
||||
if settings.is_multi_tariff_enabled():
|
||||
_user_subs = getattr(user, 'subscriptions', None) or []
|
||||
_active_subs = [s for s in _user_subs if s.is_active]
|
||||
if _active_subs:
|
||||
_non_daily = [s for s in _active_subs if not getattr(s, 'is_daily_tariff', False)]
|
||||
_eligible = _non_daily or _active_subs
|
||||
_best = max(_eligible, key=lambda s: s.days_left)
|
||||
_offer_sub_id = _best.id
|
||||
elif _user_subs:
|
||||
_offer_sub_id = _user_subs[0].id
|
||||
else:
|
||||
_offer_sub_id = None
|
||||
else:
|
||||
_offer_sub = getattr(user, 'subscription', None)
|
||||
_offer_sub_id = _offer_sub.id if _offer_sub else None
|
||||
offer_record = await upsert_discount_offer(
|
||||
new_db,
|
||||
user_id=user.id,
|
||||
subscription_id=getattr(user, 'subscription', None).id
|
||||
if getattr(user, 'subscription', None)
|
||||
else None,
|
||||
subscription_id=_offer_sub_id,
|
||||
notification_type=f'promo_template_{template.id}',
|
||||
discount_percent=template.discount_percent,
|
||||
bonus_amount_kopeks=0,
|
||||
@@ -2051,8 +2126,14 @@ async def send_offer_to_segment(callback: CallbackQuery, db_user: User, db: Asyn
|
||||
if template.offer_type == 'test_access' and squad_uuid:
|
||||
filtered_users: list[User] = []
|
||||
for user in users:
|
||||
subscription = getattr(user, 'subscription', None)
|
||||
connected = set(subscription.connected_squads or []) if subscription else set()
|
||||
if settings.is_multi_tariff_enabled():
|
||||
all_squads: set[str] = set()
|
||||
for s in getattr(user, 'subscriptions', None) or []:
|
||||
all_squads.update(s.connected_squads or [])
|
||||
connected = all_squads
|
||||
else:
|
||||
subscription = getattr(user, 'subscription', None)
|
||||
connected = set(subscription.connected_squads or []) if subscription else set()
|
||||
if squad_uuid in connected:
|
||||
continue
|
||||
filtered_users.append(user)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import html
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import structlog
|
||||
@@ -94,7 +95,7 @@ async def show_promocodes_list(callback: types.CallbackQuery, db_user: User, db:
|
||||
text += f'📅 Дней: {promo.subscription_days}\n'
|
||||
elif promo.type == PromoCodeType.PROMO_GROUP.value:
|
||||
if promo.promo_group:
|
||||
text += f'🏷️ Промогруппа: {promo.promo_group.name}\n'
|
||||
text += f'🏷️ Промогруппа: {html.escape(promo.promo_group.name)}\n'
|
||||
elif promo.type == PromoCodeType.DISCOUNT.value:
|
||||
discount_hours = promo.subscription_days
|
||||
if discount_hours > 0:
|
||||
@@ -170,7 +171,7 @@ async def show_promocode_management(callback: types.CallbackQuery, db_user: User
|
||||
text += f'📅 <b>Дней:</b> {promo.subscription_days}\n'
|
||||
elif promo.type == PromoCodeType.PROMO_GROUP.value:
|
||||
if promo.promo_group:
|
||||
text += f'🏷️ <b>Промогруппа:</b> {promo.promo_group.name} (приоритет: {promo.promo_group.priority})\n'
|
||||
text += f'🏷️ <b>Промогруппа:</b> {html.escape(promo.promo_group.name)} (приоритет: {promo.promo_group.priority})\n'
|
||||
elif promo.promo_group_id:
|
||||
text += f'🏷️ <b>Промогруппа ID:</b> {promo.promo_group_id} (не найдена)\n'
|
||||
elif promo.type == PromoCodeType.DISCOUNT.value:
|
||||
@@ -472,7 +473,9 @@ async def process_promocode_code(message: types.Message, db_user: User, state: F
|
||||
text = f'🏷️ <b>Промокод:</b> <code>{code}</code>\n\nВыберите промогруппу для назначения:\n\n'
|
||||
|
||||
for promo_group, user_count in groups_with_counts:
|
||||
text += f'• {promo_group.name} (приоритет: {promo_group.priority}, пользователей: {user_count})\n'
|
||||
text += (
|
||||
f'• {html.escape(promo_group.name)} (приоритет: {promo_group.priority}, пользователей: {user_count})\n'
|
||||
)
|
||||
keyboard.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
@@ -509,7 +512,7 @@ async def process_promo_group_selection(
|
||||
|
||||
await callback.message.edit_text(
|
||||
f'🏷️ <b>Промокод для промогруппы</b>\n\n'
|
||||
f'Промогруппа: {promo_group.name}\n'
|
||||
f'Промогруппа: {html.escape(promo_group.name)}\n'
|
||||
f'Приоритет: {promo_group.priority}\n\n'
|
||||
f'📊 Введите количество использований промокода (или 0 для безлимита):'
|
||||
)
|
||||
@@ -1039,9 +1042,9 @@ async def show_promocode_stats(callback: types.CallbackQuery, db_user: User, db:
|
||||
use_date = format_datetime(use.used_at)
|
||||
|
||||
if hasattr(use, 'user_username') and use.user_username:
|
||||
user_display = f'@{use.user_username}'
|
||||
user_display = f'@{html.escape(use.user_username)}'
|
||||
elif hasattr(use, 'user_full_name') and use.user_full_name:
|
||||
user_display = use.user_full_name
|
||||
user_display = html.escape(use.user_full_name)
|
||||
elif hasattr(use, 'user_telegram_id'):
|
||||
user_display = f'ID{use.user_telegram_id}'
|
||||
else:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import asyncio
|
||||
import html
|
||||
import json
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
@@ -218,9 +219,9 @@ async def _show_top_referrers_filtered(callback: types.CallbackQuery, db: AsyncS
|
||||
id_display = telegram_id or user_email or f'#{user_id}' if user_id else 'N/A'
|
||||
|
||||
if username:
|
||||
display_text = f'@{username} (ID{id_display})'
|
||||
display_text = f'@{html.escape(username)} (ID{id_display})'
|
||||
elif display_name and display_name != f'ID{id_display}':
|
||||
display_text = f'{display_name} (ID{id_display})'
|
||||
display_text = f'{html.escape(display_name)} (ID{id_display})'
|
||||
else:
|
||||
display_text = f'ID{id_display}'
|
||||
|
||||
@@ -312,7 +313,7 @@ async def show_pending_withdrawal_requests(callback: types.CallbackQuery, db_use
|
||||
|
||||
for req in requests[:10]:
|
||||
user = await get_user_by_id(db, req.user_id)
|
||||
user_name = user.full_name if user else 'Неизвестно'
|
||||
user_name = html.escape(user.full_name) if user and user.full_name else 'Неизвестно'
|
||||
user_tg_id = user.telegram_id if user else 'N/A'
|
||||
|
||||
risk_emoji = (
|
||||
@@ -359,7 +360,7 @@ async def view_withdrawal_request(callback: types.CallbackQuery, db_user: User,
|
||||
return
|
||||
|
||||
user = await get_user_by_id(db, request.user_id)
|
||||
user_name = user.full_name if user else 'Неизвестно'
|
||||
user_name = html.escape(user.full_name) if user and user.full_name else 'Неизвестно'
|
||||
user_tg_id = (user.telegram_id or user.email or f'#{user.id}') if user else 'N/A'
|
||||
|
||||
analysis = json.loads(request.risk_analysis) if request.risk_analysis else {}
|
||||
@@ -381,7 +382,7 @@ async def view_withdrawal_request(callback: types.CallbackQuery, db_user: User,
|
||||
📊 Статус: {status_text}
|
||||
|
||||
💳 <b>Реквизиты:</b>
|
||||
<code>{request.payment_details}</code>
|
||||
<code>{html.escape(request.payment_details or '')}</code>
|
||||
|
||||
📅 Создана: {request.created_at.strftime('%d.%m.%Y %H:%M')}
|
||||
|
||||
@@ -639,7 +640,7 @@ async def process_test_referral_earning(message: types.Message, db_user: User, d
|
||||
|
||||
await message.answer(
|
||||
f'✅ <b>Тестовое начисление создано!</b>\n\n'
|
||||
f'👤 Пользователь: {target_user.full_name or "Без имени"}\n'
|
||||
f'👤 Пользователь: {html.escape(target_user.full_name) if target_user.full_name else "Без имени"}\n'
|
||||
f'🆔 ID: <code>{target_telegram_id}</code>\n'
|
||||
f'💰 Сумма: <b>{amount_rubles:.0f}₽</b>\n'
|
||||
f'💳 Новый баланс: <b>{target_user.balance_kopeks / 100:.0f}₽</b>\n\n'
|
||||
@@ -736,14 +737,17 @@ async def _show_diagnostics_for_period(callback: types.CallbackQuery, db: AsyncS
|
||||
status = f'⚡ Другой реферер (ID{lost.current_referrer_id})'
|
||||
|
||||
# Имя или ID
|
||||
user_name = lost.username or lost.full_name or f'ID{lost.telegram_id}'
|
||||
if lost.username:
|
||||
user_name = f'@{lost.username}'
|
||||
user_name = f'@{html.escape(lost.username)}'
|
||||
elif lost.full_name:
|
||||
user_name = html.escape(lost.full_name)
|
||||
else:
|
||||
user_name = f'ID{lost.telegram_id}'
|
||||
|
||||
# Ожидаемый реферер
|
||||
referrer_info = ''
|
||||
if lost.expected_referrer_name:
|
||||
referrer_info = f' → {lost.expected_referrer_name}'
|
||||
referrer_info = f' → {html.escape(lost.expected_referrer_name)}'
|
||||
elif lost.expected_referrer_id:
|
||||
referrer_info = f' → ID{lost.expected_referrer_id}'
|
||||
|
||||
@@ -751,7 +755,7 @@ async def _show_diagnostics_for_period(callback: types.CallbackQuery, db: AsyncS
|
||||
time_str = lost.click_time.strftime('%H:%M')
|
||||
|
||||
text += f'{i}. {user_name} — {status}\n'
|
||||
text += f' <code>{lost.referral_code}</code>{referrer_info} ({time_str})\n'
|
||||
text += f' <code>{html.escape(lost.referral_code)}</code>{referrer_info} ({time_str})\n'
|
||||
|
||||
if len(report.lost_referrals) > 15:
|
||||
text += f'\n<i>... и ещё {len(report.lost_referrals) - 15}</i>\n'
|
||||
@@ -872,16 +876,22 @@ async def preview_referral_fixes(callback: types.CallbackQuery, db_user: User, d
|
||||
|
||||
# Показываем первые 10 деталей
|
||||
for i, detail in enumerate(fix_report.details[:10], 1):
|
||||
user_name = detail.username or detail.full_name or f'ID{detail.telegram_id}'
|
||||
if detail.username:
|
||||
user_name = f'@{detail.username}'
|
||||
user_name = f'@{html.escape(detail.username)}'
|
||||
elif detail.full_name:
|
||||
user_name = html.escape(detail.full_name)
|
||||
else:
|
||||
user_name = f'ID{detail.telegram_id}'
|
||||
|
||||
if detail.error:
|
||||
text += f'{i}. {user_name} — ❌ {detail.error}\n'
|
||||
text += f'{i}. {user_name} — ❌ {html.escape(str(detail.error))}\n'
|
||||
else:
|
||||
text += f'{i}. {user_name}\n'
|
||||
if detail.referred_by_set:
|
||||
text += f' • Реферер: {detail.referrer_name or f"ID{detail.referrer_id}"}\n'
|
||||
referrer_display = (
|
||||
html.escape(detail.referrer_name) if detail.referrer_name else f'ID{detail.referrer_id}'
|
||||
)
|
||||
text += f' • Реферер: {referrer_display}\n'
|
||||
if detail.had_first_topup:
|
||||
text += f' • Первое пополнение: {settings.format_price(detail.topup_amount_kopeks)}\n'
|
||||
if detail.bonus_to_referral_kopeks > 0:
|
||||
@@ -967,13 +977,19 @@ async def apply_referral_fixes(callback: types.CallbackQuery, db_user: User, db:
|
||||
for detail in fix_report.details:
|
||||
if not detail.error and success_count < 10:
|
||||
success_count += 1
|
||||
user_name = detail.username or detail.full_name or f'ID{detail.telegram_id}'
|
||||
if detail.username:
|
||||
user_name = f'@{user_name}'
|
||||
user_name = f'@{html.escape(detail.username)}'
|
||||
elif detail.full_name:
|
||||
user_name = html.escape(detail.full_name)
|
||||
else:
|
||||
user_name = f'ID{detail.telegram_id}'
|
||||
|
||||
text += f'{success_count}. {user_name}\n'
|
||||
if detail.referred_by_set:
|
||||
text += f' • Реферер: {detail.referrer_name or f"ID{detail.referrer_id}"}\n'
|
||||
referrer_display = (
|
||||
html.escape(detail.referrer_name) if detail.referrer_name else f'ID{detail.referrer_id}'
|
||||
)
|
||||
text += f' • Реферер: {referrer_display}\n'
|
||||
if detail.bonus_to_referral_kopeks > 0:
|
||||
text += f' • Бонус рефералу: {settings.format_price(detail.bonus_to_referral_kopeks)}\n'
|
||||
if detail.bonus_to_referrer_kopeks > 0:
|
||||
@@ -989,8 +1005,13 @@ async def apply_referral_fixes(callback: types.CallbackQuery, db_user: User, db:
|
||||
for detail in fix_report.details:
|
||||
if detail.error and error_count < 5:
|
||||
error_count += 1
|
||||
user_name = detail.username or detail.full_name or f'ID{detail.telegram_id}'
|
||||
text += f'• {user_name}: {detail.error}\n'
|
||||
if detail.username:
|
||||
user_name = f'@{html.escape(detail.username)}'
|
||||
elif detail.full_name:
|
||||
user_name = html.escape(detail.full_name)
|
||||
else:
|
||||
user_name = f'ID{detail.telegram_id}'
|
||||
text += f'• {user_name}: {html.escape(str(detail.error))}\n'
|
||||
if fix_report.errors > 5:
|
||||
text += f'<i>... и ещё {fix_report.errors - 5} ошибок</i>\n'
|
||||
|
||||
@@ -1055,8 +1076,12 @@ async def check_missing_bonuses(callback: types.CallbackQuery, db_user: User, db
|
||||
👤 <b>Список ({len(report.missing_bonuses)} чел.):</b>
|
||||
"""
|
||||
for i, mb in enumerate(report.missing_bonuses[:15], 1):
|
||||
referral_name = mb.referral_full_name or mb.referral_username or str(mb.referral_telegram_id)
|
||||
referrer_name = mb.referrer_full_name or mb.referrer_username or str(mb.referrer_telegram_id)
|
||||
referral_name = html.escape(
|
||||
mb.referral_full_name or mb.referral_username or str(mb.referral_telegram_id)
|
||||
)
|
||||
referrer_name = html.escape(
|
||||
mb.referrer_full_name or mb.referrer_username or str(mb.referrer_telegram_id)
|
||||
)
|
||||
text += f'\n{i}. <b>{referral_name}</b>'
|
||||
text += f'\n └ Пригласил: {referrer_name}'
|
||||
text += f'\n └ Пополнение: {mb.first_topup_amount_kopeks / 100:.0f}₽'
|
||||
@@ -1191,9 +1216,9 @@ async def sync_referrals_with_contest(
|
||||
total_created += stats.get('created', 0)
|
||||
total_updated += stats.get('updated', 0)
|
||||
total_skipped += stats.get('skipped', 0)
|
||||
contest_results.append(f'• {contest.title}: +{stats.get("created", 0)} новых')
|
||||
contest_results.append(f'• {html.escape(contest.title)}: +{stats.get("created", 0)} новых')
|
||||
else:
|
||||
contest_results.append(f'• {contest.title}: ошибка')
|
||||
contest_results.append(f'• {html.escape(contest.title)}: ошибка')
|
||||
|
||||
text = f"""
|
||||
🏆 <b>Синхронизация с конкурсами завершена!</b>
|
||||
@@ -1275,7 +1300,7 @@ async def receive_log_file(message: types.Message, db_user: User, db: AsyncSessi
|
||||
|
||||
if file_ext not in ['.log', '.txt']:
|
||||
await message.answer(
|
||||
f'❌ Неверный формат файла: {file_ext}\n\nПоддерживаются только текстовые файлы (.log, .txt)',
|
||||
f'❌ Неверный формат файла: {html.escape(file_ext)}\n\nПоддерживаются только текстовые файлы (.log, .txt)',
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text='❌ Отмена', callback_data='admin_referral_diagnostics')]
|
||||
@@ -1299,7 +1324,7 @@ async def receive_log_file(message: types.Message, db_user: User, db: AsyncSessi
|
||||
|
||||
# Информируем о начале загрузки
|
||||
status_message = await message.answer(
|
||||
f'📥 Загружаю файл {file_name} ({message.document.file_size / 1024 / 1024:.1f} MB)...'
|
||||
f'📥 Загружаю файл {html.escape(file_name)} ({message.document.file_size / 1024 / 1024:.1f} MB)...'
|
||||
)
|
||||
|
||||
temp_file_path = None
|
||||
@@ -1316,7 +1341,9 @@ async def receive_log_file(message: types.Message, db_user: User, db: AsyncSessi
|
||||
logger.info('📥 Файл загружен: ( байт)', temp_file_path=temp_file_path, file_size=message.document.file_size)
|
||||
|
||||
# Обновляем статус
|
||||
await status_message.edit_text(f'🔍 Анализирую файл {file_name}...\n\nЭто может занять некоторое время.')
|
||||
await status_message.edit_text(
|
||||
f'🔍 Анализирую файл {html.escape(file_name)}...\n\nЭто может занять некоторое время.'
|
||||
)
|
||||
|
||||
# Анализируем файл
|
||||
from app.services.referral_diagnostics_service import referral_diagnostics_service
|
||||
@@ -1325,7 +1352,7 @@ async def receive_log_file(message: types.Message, db_user: User, db: AsyncSessi
|
||||
|
||||
# Формируем отчёт
|
||||
text = f"""
|
||||
🔍 <b>Анализ лог-файла: {file_name}</b>
|
||||
🔍 <b>Анализ лог-файла: {html.escape(file_name)}</b>
|
||||
|
||||
<b>📊 Статистика переходов:</b>
|
||||
• Всего кликов по реф-ссылкам: {report.total_ref_clicks}
|
||||
@@ -1348,14 +1375,17 @@ async def receive_log_file(message: types.Message, db_user: User, db: AsyncSessi
|
||||
status = f'⚡ Другой реферер (ID{lost.current_referrer_id})'
|
||||
|
||||
# Имя или ID
|
||||
user_name = lost.username or lost.full_name or f'ID{lost.telegram_id}'
|
||||
if lost.username:
|
||||
user_name = f'@{lost.username}'
|
||||
user_name = f'@{html.escape(lost.username)}'
|
||||
elif lost.full_name:
|
||||
user_name = html.escape(lost.full_name)
|
||||
else:
|
||||
user_name = f'ID{lost.telegram_id}'
|
||||
|
||||
# Ожидаемый реферер
|
||||
referrer_info = ''
|
||||
if lost.expected_referrer_name:
|
||||
referrer_info = f' → {lost.expected_referrer_name}'
|
||||
referrer_info = f' → {html.escape(lost.expected_referrer_name)}'
|
||||
elif lost.expected_referrer_id:
|
||||
referrer_info = f' → ID{lost.expected_referrer_id}'
|
||||
|
||||
@@ -1363,7 +1393,7 @@ async def receive_log_file(message: types.Message, db_user: User, db: AsyncSessi
|
||||
time_str = lost.click_time.strftime('%d.%m.%Y %H:%M')
|
||||
|
||||
text += f'{i}. {user_name} — {status}\n'
|
||||
text += f' <code>{lost.referral_code}</code>{referrer_info} ({time_str})\n'
|
||||
text += f' <code>{html.escape(lost.referral_code)}</code>{referrer_info} ({time_str})\n'
|
||||
|
||||
if len(report.lost_referrals) > 15:
|
||||
text += f'\n<i>... и ещё {len(report.lost_referrals) - 15}</i>\n'
|
||||
@@ -1408,8 +1438,8 @@ async def receive_log_file(message: types.Message, db_user: User, db: AsyncSessi
|
||||
try:
|
||||
await status_message.edit_text(
|
||||
f'❌ <b>Ошибка при анализе файла</b>\n\n'
|
||||
f'Файл: {file_name}\n'
|
||||
f'Ошибка: {e!s}\n\n'
|
||||
f'Файл: {html.escape(file_name)}\n'
|
||||
f'Ошибка: {html.escape(str(e))}\n\n'
|
||||
f'Проверьте, что файл является текстовым логом бота.',
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
@@ -1428,7 +1458,7 @@ async def receive_log_file(message: types.Message, db_user: User, db: AsyncSessi
|
||||
)
|
||||
except:
|
||||
await message.answer(
|
||||
f'❌ Ошибка при анализе файла: {e!s}',
|
||||
f'❌ Ошибка при анализе файла: {html.escape(str(e))}',
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_referral_diagnostics')]
|
||||
|
||||
@@ -995,9 +995,9 @@ async def show_system_stats(callback: types.CallbackQuery, db_user: User, db: As
|
||||
📊 <b>Детальная статистика Remnawave</b>
|
||||
|
||||
🖥️ <b>Сервер:</b>
|
||||
- CPU: {server_info.get('cpu_cores', 0)} ядер ({server_info.get('cpu_physical_cores', 0)} физ.)
|
||||
- CPU: {server_info.get('cpu_cores', 0)} ядер
|
||||
- RAM: {format_bytes(server_info.get('memory_used', 0))} / {format_bytes(memory_total)} ({memory_used_percent:.1f}%)
|
||||
- Свободно: {format_bytes(server_info.get('memory_available', 0))}
|
||||
- Свободно: {format_bytes(server_info.get('memory_free', 0))}
|
||||
- Uptime: {uptime_str}
|
||||
|
||||
👥 <b>Пользователи ({system.get('total_users', 0)} всего):</b>
|
||||
@@ -1088,8 +1088,7 @@ async def show_traffic_stats(callback: types.CallbackQuery, db_user: User, db: A
|
||||
total_realtime_upload = sum(node.get('uploadBytes', 0) for node in realtime_usage)
|
||||
total_realtime = total_realtime_download + total_realtime_upload
|
||||
|
||||
total_download_speed = sum(node.get('downloadSpeedBps', 0) for node in realtime_usage)
|
||||
total_upload_speed = sum(node.get('uploadSpeedBps', 0) for node in realtime_usage)
|
||||
total_users_online = sum(node.get('usersOnline', 0) for node in realtime_usage)
|
||||
|
||||
periods = {
|
||||
'last_2_days': bandwidth_stats.get('bandwidthLastTwoDays', {}),
|
||||
@@ -1109,15 +1108,11 @@ async def show_traffic_stats(callback: types.CallbackQuery, db_user: User, db: A
|
||||
text = f"""
|
||||
📊 <b>Статистика трафика Remnawave</b>
|
||||
|
||||
⚡ <b>Реалтайм данные:</b>
|
||||
⚡ <b>Трафик по inbounds:</b>
|
||||
- Скачивание: {format_bytes(total_realtime_download)}
|
||||
- Загрузка: {format_bytes(total_realtime_upload)}
|
||||
- Общий трафик: {format_bytes(total_realtime)}
|
||||
|
||||
🚀 <b>Текущие скорости:</b>
|
||||
- Скорость скачивания: {format_bytes(total_download_speed)}/с
|
||||
- Скорость загрузки: {format_bytes(total_upload_speed)}/с
|
||||
- Общая скорость: {format_bytes(total_download_speed + total_upload_speed)}/с
|
||||
- Пользователи онлайн: {total_users_online}
|
||||
|
||||
📈 <b>Статистика по периодам:</b>
|
||||
|
||||
@@ -1244,12 +1239,26 @@ async def show_node_details(callback: types.CallbackQuery, db_user: User, db: As
|
||||
created_at = format_datetime(node['created_at']) if node.get('created_at') else '—'
|
||||
updated_at = format_datetime(node['updated_at']) if node.get('updated_at') else '—'
|
||||
notify_percent = f'{node["notify_percent"]}%' if node.get('notify_percent') is not None else '—'
|
||||
cpu_info = node.get('cpu_model') or '—'
|
||||
if node.get('cpu_count'):
|
||||
cpu_info = f'{node["cpu_count"]}x {cpu_info}'
|
||||
sys_info = (node.get('system') or {}).get('info', {})
|
||||
cpu_model = html.escape(str(sys_info.get('cpuModel') or '—'))
|
||||
cpu_count = sys_info.get('cpus', 0)
|
||||
cpu_info = f'{cpu_count}x {cpu_model}' if cpu_count else cpu_model
|
||||
memory_total = sys_info.get('memoryTotal', 0)
|
||||
total_ram = format_bytes(memory_total) if memory_total else '—'
|
||||
versions = node.get('versions') or {}
|
||||
xray_ver = html.escape(str(versions.get('xray') or '—'))
|
||||
node_ver = html.escape(str(versions.get('node') or '—'))
|
||||
xray_uptime_sec = node.get('xray_uptime', 0)
|
||||
if xray_uptime_sec:
|
||||
days, rem = divmod(int(xray_uptime_sec), 86400)
|
||||
hours, rem = divmod(rem, 3600)
|
||||
mins = rem // 60
|
||||
xray_uptime_str = f'{days}d {hours}h {mins}m' if days else (f'{hours}h {mins}m' if hours else f'{mins}m')
|
||||
else:
|
||||
xray_uptime_str = '—'
|
||||
|
||||
text = f"""
|
||||
🖥️ <b>Нода: {node['name']}</b>
|
||||
🖥️ <b>Нода: {html.escape(node['name'])}</b>
|
||||
|
||||
<b>Статус:</b>
|
||||
- Онлайн: {status_emoji} {'Да' if node['is_node_online'] else 'Нет'}
|
||||
@@ -1257,16 +1266,20 @@ async def show_node_details(callback: types.CallbackQuery, db_user: User, db: As
|
||||
- Подключена: {'📡 Да' if node['is_connected'] else '📵 Нет'}
|
||||
- Отключена: {'❌ Да' if node['is_disabled'] else '✅ Нет'}
|
||||
- Изменение статуса: {status_change}
|
||||
- Сообщение: {node.get('last_status_message') or '—'}
|
||||
- Uptime Xray: {node.get('xray_uptime') or '—'}
|
||||
- Сообщение: {html.escape(str(node.get('last_status_message') or '—'))}
|
||||
- Uptime Xray: {xray_uptime_str}
|
||||
|
||||
<b>Версии:</b>
|
||||
- Xray: {xray_ver}
|
||||
- Node: {node_ver}
|
||||
|
||||
<b>Информация:</b>
|
||||
- Адрес: {node['address']}
|
||||
- Страна: {node['country_code']}
|
||||
- Адрес: {html.escape(node['address'])}
|
||||
- Страна: {html.escape(node['country_code'])}
|
||||
- Пользователей онлайн: {node['users_online']}
|
||||
- CPU: {cpu_info}
|
||||
- RAM: {node.get('total_ram') or '—'}
|
||||
- Провайдер: {node.get('provider_uuid') or '—'}
|
||||
- RAM: {total_ram}
|
||||
- Провайдер: {html.escape(str(node.get('provider_uuid') or '—'))}
|
||||
|
||||
<b>Трафик:</b>
|
||||
- Использовано: {format_bytes(node['traffic_used_bytes'])}
|
||||
@@ -1315,6 +1328,17 @@ async def show_node_statistics(callback: types.CallbackQuery, db_user: User, db:
|
||||
await callback.answer('❌ Нода не найдена', show_alert=True)
|
||||
return
|
||||
|
||||
status_emoji = '🟢' if node['is_node_online'] else '🔴'
|
||||
xray_emoji = '✅' if node['is_xray_running'] else '❌'
|
||||
xray_uptime_sec = node.get('xray_uptime', 0)
|
||||
if xray_uptime_sec:
|
||||
days, rem = divmod(int(xray_uptime_sec), 86400)
|
||||
hours, rem = divmod(rem, 3600)
|
||||
mins = rem // 60
|
||||
xray_uptime_str = f'{days}d {hours}h {mins}m' if days else (f'{hours}h {mins}m' if hours else f'{mins}m')
|
||||
else:
|
||||
xray_uptime_str = '—'
|
||||
|
||||
try:
|
||||
end_date = datetime.now(UTC)
|
||||
start_date = end_date - timedelta(days=7)
|
||||
@@ -1333,28 +1357,36 @@ async def show_node_statistics(callback: types.CallbackQuery, db_user: User, db:
|
||||
created_at = format_datetime(node['created_at']) if node.get('created_at') else '—'
|
||||
updated_at = format_datetime(node['updated_at']) if node.get('updated_at') else '—'
|
||||
notify_percent = f'{node["notify_percent"]}%' if node.get('notify_percent') is not None else '—'
|
||||
cpu_info = node.get('cpu_model') or '—'
|
||||
if node.get('cpu_count'):
|
||||
cpu_info = f'{node["cpu_count"]}x {cpu_info}'
|
||||
|
||||
status_emoji = '🟢' if node['is_node_online'] else '🔴'
|
||||
xray_emoji = '✅' if node['is_xray_running'] else '❌'
|
||||
sys_info = (node.get('system') or {}).get('info', {})
|
||||
cpu_model = html.escape(str(sys_info.get('cpuModel') or '—'))
|
||||
cpu_count = sys_info.get('cpus', 0)
|
||||
cpu_info = f'{cpu_count}x {cpu_model}' if cpu_count else cpu_model
|
||||
memory_total = sys_info.get('memoryTotal', 0)
|
||||
total_ram = format_bytes(memory_total) if memory_total else '—'
|
||||
sys_stats = (node.get('system') or {}).get('stats', {})
|
||||
load_avg = sys_stats.get('loadAvg', [])
|
||||
load_str = ' / '.join(f'{v:.2f}' for v in load_avg[:3]) if load_avg else '—'
|
||||
versions = node.get('versions') or {}
|
||||
xray_ver = html.escape(str(versions.get('xray') or '—'))
|
||||
node_ver = html.escape(str(versions.get('node') or '—'))
|
||||
|
||||
text = f"""
|
||||
📊 <b>Статистика ноды: {node['name']}</b>
|
||||
📊 <b>Статистика ноды: {html.escape(node['name'])}</b>
|
||||
|
||||
<b>Статус:</b>
|
||||
- Онлайн: {status_emoji} {'Да' if node['is_node_online'] else 'Нет'}
|
||||
- Xray: {xray_emoji} {'Запущен' if node['is_xray_running'] else 'Остановлен'}
|
||||
- Пользователей онлайн: {node['users_online'] or 0}
|
||||
- Xray: {xray_emoji} {'Запущен' if node['is_xray_running'] else 'Остановлен'} (v{xray_ver})
|
||||
- Node: v{node_ver}
|
||||
- Пользователей онлайн: {node['users_online']}
|
||||
- Изменение статуса: {status_change}
|
||||
- Сообщение: {node.get('last_status_message') or '—'}
|
||||
- Uptime Xray: {node.get('xray_uptime') or '—'}
|
||||
- Сообщение: {html.escape(str(node.get('last_status_message') or '—'))}
|
||||
- Uptime Xray: {xray_uptime_str}
|
||||
|
||||
<b>Ресурсы:</b>
|
||||
- CPU: {cpu_info}
|
||||
- RAM: {node.get('total_ram') or '—'}
|
||||
- Провайдер: {node.get('provider_uuid') or '—'}
|
||||
- RAM: {total_ram}
|
||||
- Load: {load_str}
|
||||
- Провайдер: {html.escape(str(node.get('provider_uuid') or '—'))}
|
||||
|
||||
<b>Трафик:</b>
|
||||
- Использовано: {format_bytes(node['traffic_used_bytes'] or 0)}
|
||||
@@ -1371,12 +1403,11 @@ async def show_node_statistics(callback: types.CallbackQuery, db_user: User, db:
|
||||
|
||||
if node_realtime:
|
||||
text += f"""
|
||||
<b>Реалтайм статистика:</b>
|
||||
<b>Трафик по inbounds:</b>
|
||||
- Скачано: {format_bytes(node_realtime.get('downloadBytes', 0))}
|
||||
- Загружено: {format_bytes(node_realtime.get('uploadBytes', 0))}
|
||||
- Общий трафик: {format_bytes(node_realtime.get('totalBytes', 0))}
|
||||
- Скорость скачивания: {format_bytes(node_realtime.get('downloadSpeedBps', 0))}/с
|
||||
- Скорость загрузки: {format_bytes(node_realtime.get('uploadSpeedBps', 0))}/с
|
||||
- Онлайн: {node_realtime.get('usersOnline', 0)}
|
||||
"""
|
||||
|
||||
if node_usage:
|
||||
@@ -1405,15 +1436,15 @@ async def show_node_statistics(callback: types.CallbackQuery, db_user: User, db:
|
||||
logger.error('Ошибка получения статистики ноды', node_uuid=node_uuid, error=e)
|
||||
|
||||
text = f"""
|
||||
📊 <b>Статистика ноды: {node['name']}</b>
|
||||
📊 <b>Статистика ноды: {html.escape(node['name'])}</b>
|
||||
|
||||
<b>Статус:</b>
|
||||
- Онлайн: {status_emoji} {'Да' if node['is_node_online'] else 'Нет'}
|
||||
- Xray: {xray_emoji} {'Запущен' if node['is_xray_running'] else 'Остановлен'}
|
||||
- Пользователей онлайн: {node['users_online'] or 0}
|
||||
- Пользователей онлайн: {node['users_online']}
|
||||
- Изменение статуса: {format_datetime(node.get('last_status_change')) if node.get('last_status_change') else '—'}
|
||||
- Сообщение: {node.get('last_status_message') or '—'}
|
||||
- Uptime Xray: {node.get('xray_uptime') or '—'}
|
||||
- Сообщение: {html.escape(str(node.get('last_status_message') or '—'))}
|
||||
- Uptime Xray: {xray_uptime_str}
|
||||
|
||||
<b>Трафик:</b>
|
||||
- Использовано: {format_bytes(node['traffic_used_bytes'] or 0)}
|
||||
|
||||
@@ -5,6 +5,7 @@ from aiogram import Dispatcher, F, types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.promo_group import get_promo_groups_with_counts
|
||||
from app.database.crud.server_squad import (
|
||||
delete_server_squad,
|
||||
@@ -76,7 +77,7 @@ def _build_server_edit_view(server):
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text='🎁 Выдавать сквад' if not server.is_trial_eligible else '🚫 Не выдавать сквад',
|
||||
text='🎁 Выдавать в триал' if not server.is_trial_eligible else '🚫 Не выдавать в триал',
|
||||
callback_data=f'admin_server_trial_{server.id}',
|
||||
),
|
||||
],
|
||||
@@ -362,7 +363,17 @@ async def show_server_users(callback: types.CallbackQuery, db_user: User, db: As
|
||||
if len(display_name) > 30:
|
||||
display_name = display_name[:27] + '...'
|
||||
|
||||
subscription_status = user.subscription.status_display if user.subscription else '❌ Нет подписки'
|
||||
if settings.is_multi_tariff_enabled() and hasattr(user, 'subscriptions') and user.subscriptions:
|
||||
status_parts = []
|
||||
for sub in user.subscriptions:
|
||||
emoji = '🟢' if sub.is_active else '🔴'
|
||||
name = sub.tariff.name if sub.tariff else f'#{sub.id}'
|
||||
status_parts.append(f'{emoji}{name}')
|
||||
subscription_status = ', '.join(status_parts)
|
||||
elif user.subscription:
|
||||
subscription_status = user.subscription.status_display
|
||||
else:
|
||||
subscription_status = '❌ Нет подписки'
|
||||
status_icon = _get_status_icon(subscription_status)
|
||||
|
||||
if status_icon:
|
||||
|
||||
@@ -3,6 +3,7 @@ from aiogram import Dispatcher, F, types
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.subscription import (
|
||||
get_all_subscriptions,
|
||||
get_expired_subscriptions,
|
||||
@@ -364,8 +365,11 @@ async def send_expiry_reminders(callback: types.CallbackQuery, db_user: User, db
|
||||
|
||||
days_left = max(1, subscription.days_left)
|
||||
|
||||
tariff_label = ''
|
||||
if settings.is_multi_tariff_enabled() and hasattr(subscription, 'tariff') and subscription.tariff:
|
||||
tariff_label = f' «{subscription.tariff.name}»'
|
||||
reminder_text = f"""
|
||||
⚠️ <b>Подписка истекает!</b>
|
||||
⚠️ <b>Подписка{tariff_label} истекает!</b>
|
||||
|
||||
Ваша подписка истекает через {days_left} день(а).
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Управление тарифами в админ-панели."""
|
||||
|
||||
import html
|
||||
|
||||
import structlog
|
||||
from aiogram import Dispatcher, F, types
|
||||
from aiogram.exceptions import TelegramBadRequest
|
||||
@@ -13,6 +15,7 @@ from app.database.crud.server_squad import get_all_server_squads
|
||||
from app.database.crud.tariff import (
|
||||
create_tariff,
|
||||
delete_tariff,
|
||||
get_active_subscriptions_count_by_tariff_id,
|
||||
get_tariff_by_id,
|
||||
get_tariff_subscriptions_count,
|
||||
get_tariffs_with_subscriptions_count,
|
||||
@@ -232,6 +235,7 @@ def _format_traffic_reset_mode(mode: str | None) -> str:
|
||||
'DAY': '📅 Ежедневно',
|
||||
'WEEK': '📆 Еженедельно',
|
||||
'MONTH': '🗓️ Ежемесячно',
|
||||
'MONTH_ROLLING': '🔄 Скользящий месяц',
|
||||
'NO_RESET': '🚫 Никогда',
|
||||
}
|
||||
if mode is None:
|
||||
@@ -317,7 +321,7 @@ def format_tariff_info(tariff: Tariff, language: str, subs_count: int = 0) -> st
|
||||
price_block = f'<b>Цены:</b>\n{prices_display}'
|
||||
tariff_type = '📅 Периодный'
|
||||
|
||||
return f"""📦 <b>Тариф: {tariff.name}</b>
|
||||
return f"""📦 <b>Тариф: {html.escape(tariff.name)}</b>
|
||||
|
||||
{status} | {tariff_type}
|
||||
🎚️ Уровень: {tariff.tier_level}
|
||||
@@ -343,7 +347,7 @@ def format_tariff_info(tariff: Tariff, language: str, subs_count: int = 0) -> st
|
||||
|
||||
📊 Подписок на тарифе: {subs_count}
|
||||
|
||||
{f'📝 {tariff.description}' if tariff.description else ''}"""
|
||||
{f'📝 {html.escape(tariff.description)}' if tariff.description else ''}"""
|
||||
|
||||
|
||||
@admin_required
|
||||
@@ -591,7 +595,7 @@ async def start_edit_daily_price(
|
||||
|
||||
await callback.message.edit_text(
|
||||
f'💰 <b>Редактирование суточной цены</b>\n\n'
|
||||
f'Тариф: {tariff.name}\n'
|
||||
f'Тариф: {html.escape(tariff.name)}\n'
|
||||
f'Текущая цена: {format_price_kopeks(current_price)}/день\n\n'
|
||||
'Введите новую цену за день в рублях.\n'
|
||||
'Пример: <code>50</code> или <code>99.90</code>',
|
||||
@@ -1011,7 +1015,7 @@ async def start_edit_tariff_name(
|
||||
await state.update_data(tariff_id=tariff_id, language=db_user.language)
|
||||
|
||||
await callback.message.edit_text(
|
||||
f'✏️ <b>Редактирование названия</b>\n\nТекущее название: <b>{tariff.name}</b>\n\nВведите новое название:',
|
||||
f'✏️ <b>Редактирование названия</b>\n\nТекущее название: <b>{html.escape(tariff.name)}</b>\n\nВведите новое название:',
|
||||
reply_markup=InlineKeyboardMarkup(
|
||||
inline_keyboard=[[InlineKeyboardButton(text=texts.CANCEL, callback_data=f'admin_tariff_view:{tariff_id}')]]
|
||||
),
|
||||
@@ -1801,7 +1805,7 @@ async def start_edit_tariff_traffic_topup(
|
||||
buttons.append([InlineKeyboardButton(text=texts.BACK, callback_data=f'admin_tariff_view:{tariff_id}')])
|
||||
|
||||
await callback.message.edit_text(
|
||||
f'📈 <b>Докупка трафика для «{tariff.name}»</b>\n\n'
|
||||
f'📈 <b>Докупка трафика для «{html.escape(tariff.name)}»</b>\n\n'
|
||||
f'Статус: {status}\n\n'
|
||||
f'<b>Пакеты:</b>\n{packages_display}\n\n'
|
||||
f'<b>Макс. лимит:</b> {max_limit_display}\n\n'
|
||||
@@ -1887,7 +1891,7 @@ async def toggle_tariff_traffic_topup(
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
f'📈 <b>Докупка трафика для «{tariff.name}»</b>\n\n'
|
||||
f'📈 <b>Докупка трафика для «{html.escape(tariff.name)}»</b>\n\n'
|
||||
f'Статус: {status}\n\n'
|
||||
f'<b>Пакеты:</b>\n{packages_display}\n\n'
|
||||
f'<b>Макс. лимит:</b> {max_limit_display}\n\n'
|
||||
@@ -1931,7 +1935,7 @@ async def start_edit_traffic_topup_packages(
|
||||
|
||||
await callback.message.edit_text(
|
||||
f'📦 <b>Настройка пакетов докупки трафика</b>\n\n'
|
||||
f'Тариф: <b>{tariff.name}</b>\n\n'
|
||||
f'Тариф: <b>{html.escape(tariff.name)}</b>\n\n'
|
||||
f'<b>Текущие пакеты:</b>\n{packages_display}\n\n'
|
||||
'Введите пакеты в формате:\n'
|
||||
f'<code>{current_packages}</code>\n\n'
|
||||
@@ -2010,7 +2014,7 @@ async def process_edit_traffic_topup_packages(
|
||||
|
||||
await message.answer(
|
||||
f'✅ <b>Пакеты обновлены!</b>\n\n'
|
||||
f'📈 <b>Докупка трафика для «{tariff.name}»</b>\n\n'
|
||||
f'📈 <b>Докупка трафика для «{html.escape(tariff.name)}»</b>\n\n'
|
||||
f'Статус: ✅ Включено\n\n'
|
||||
f'<b>Пакеты:</b>\n{packages_display}\n\n'
|
||||
f'<b>Макс. лимит:</b> {max_limit_display}\n\n'
|
||||
@@ -2051,7 +2055,7 @@ async def start_edit_max_topup_traffic(
|
||||
|
||||
await callback.message.edit_text(
|
||||
f'📊 <b>Максимальный лимит трафика</b>\n\n'
|
||||
f'Тариф: <b>{tariff.name}</b>\n'
|
||||
f'Тариф: <b>{html.escape(tariff.name)}</b>\n'
|
||||
f'Текущий лимит: <b>{current_display}</b>\n\n'
|
||||
f'Введите максимальный общий объем трафика (в ГБ), который может быть на подписке после всех докупок.\n\n'
|
||||
f'• Например, если тариф дает 100 ГБ и лимит 200 ГБ — пользователь сможет докупить еще 100 ГБ\n'
|
||||
@@ -2127,7 +2131,7 @@ async def process_edit_max_topup_traffic(
|
||||
|
||||
await message.answer(
|
||||
f'✅ <b>Лимит обновлен!</b>\n\n'
|
||||
f'📈 <b>Докупка трафика для «{tariff.name}»</b>\n\n'
|
||||
f'📈 <b>Докупка трафика для «{html.escape(tariff.name)}»</b>\n\n'
|
||||
f'Статус: ✅ Включено\n\n'
|
||||
f'<b>Пакеты:</b>\n{packages_display}\n\n'
|
||||
f'<b>Макс. лимит:</b> {max_limit_display}\n\n'
|
||||
@@ -2156,14 +2160,36 @@ async def confirm_delete_tariff(
|
||||
await callback.answer('Тариф не найден', show_alert=True)
|
||||
return
|
||||
|
||||
active_count = await get_active_subscriptions_count_by_tariff_id(db, tariff_id)
|
||||
|
||||
if active_count > 0:
|
||||
total_count = await get_tariff_subscriptions_count(db, tariff_id)
|
||||
await callback.message.edit_text(
|
||||
f'🗑️ <b>Удаление тарифа</b>\n\n'
|
||||
f'Невозможно удалить тариф <b>{html.escape(tariff.name)}</b>.\n\n'
|
||||
f'⚠️ <b>Активных подписок:</b> {active_count} (всего: {total_count})\n'
|
||||
f'Сначала деактивируйте тариф и дождитесь окончания всех активных подписок, '
|
||||
f'либо переведите подписки на другой тариф.',
|
||||
reply_markup=InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[InlineKeyboardButton(text='◀️ Назад к тарифу', callback_data=f'admin_tariff_view:{tariff_id}')],
|
||||
]
|
||||
),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
subs_count = await get_tariff_subscriptions_count(db, tariff_id)
|
||||
|
||||
warning = ''
|
||||
if subs_count > 0:
|
||||
warning = f'\n\n⚠️ <b>Внимание!</b> На этом тарифе {subs_count} подписок.\nОни будут отвязаны от тарифа.'
|
||||
warning = (
|
||||
f'\n\n⚠️ <b>Внимание!</b> На этом тарифе {subs_count} неактивных подписок.\nОни потеряют привязку к тарифу.'
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
f'🗑️ <b>Удаление тарифа</b>\n\nВы действительно хотите удалить тариф <b>{tariff.name}</b>?{warning}',
|
||||
f'🗑️ <b>Удаление тарифа</b>\n\nВы действительно хотите удалить тариф <b>{html.escape(tariff.name)}</b>?{warning}',
|
||||
reply_markup=InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
@@ -2195,6 +2221,15 @@ async def delete_tariff_confirmed(
|
||||
await callback.answer('Тариф не найден', show_alert=True)
|
||||
return
|
||||
|
||||
# Защита от удаления тарифа с активными подписками (FK RESTRICT)
|
||||
active_count = await get_active_subscriptions_count_by_tariff_id(db, tariff.id)
|
||||
if active_count > 0:
|
||||
await callback.answer(
|
||||
f'Невозможно удалить тариф: {active_count} активных подписок. Сначала деактивируйте тариф.',
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
tariff_name = tariff.name
|
||||
await delete_tariff(db, tariff)
|
||||
|
||||
@@ -2278,7 +2313,7 @@ async def start_edit_tariff_squads(
|
||||
selected_count = len(current_squads)
|
||||
|
||||
await callback.message.edit_text(
|
||||
f'🌐 <b>Серверы для тарифа «{tariff.name}»</b>\n\n'
|
||||
f'🌐 <b>Серверы для тарифа «{html.escape(tariff.name)}»</b>\n\n'
|
||||
f'Выбрано: {selected_count} из {len(squads)}\n\n'
|
||||
'Если не выбран ни один сервер - доступны все.\n'
|
||||
'Нажмите на сервер для выбора/отмены:',
|
||||
@@ -2341,7 +2376,7 @@ async def toggle_tariff_squad(
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
f'🌐 <b>Серверы для тарифа «{tariff.name}»</b>\n\n'
|
||||
f'🌐 <b>Серверы для тарифа «{html.escape(tariff.name)}»</b>\n\n'
|
||||
f'Выбрано: {len(current_squads)} из {len(squads)}\n\n'
|
||||
'Если не выбран ни один сервер - доступны все.\n'
|
||||
'Нажмите на сервер для выбора/отмены:',
|
||||
@@ -2406,7 +2441,7 @@ async def clear_tariff_squads(
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
f'🌐 <b>Серверы для тарифа «{tariff.name}»</b>\n\n'
|
||||
f'🌐 <b>Серверы для тарифа «{html.escape(tariff.name)}»</b>\n\n'
|
||||
f'Выбрано: 0 из {len(squads)}\n\n'
|
||||
'Если не выбран ни один сервер - доступны все.\n'
|
||||
'Нажмите на сервер для выбора/отмены:',
|
||||
@@ -2470,7 +2505,7 @@ async def select_all_tariff_squads(
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
f'🌐 <b>Серверы для тарифа «{tariff.name}»</b>\n\n'
|
||||
f'🌐 <b>Серверы для тарифа «{html.escape(tariff.name)}»</b>\n\n'
|
||||
f'Выбрано: {len(squads)} из {len(squads)}\n\n'
|
||||
'Если не выбран ни один сервер - доступны все.\n'
|
||||
'Нажмите на сервер для выбора/отмены:',
|
||||
@@ -2540,7 +2575,7 @@ async def start_edit_tariff_promo_groups(
|
||||
selected_count = len(current_groups)
|
||||
|
||||
await callback.message.edit_text(
|
||||
f'👥 <b>Промогруппы для тарифа «{tariff.name}»</b>\n\n'
|
||||
f'👥 <b>Промогруппы для тарифа «{html.escape(tariff.name)}»</b>\n\n'
|
||||
f'Выбрано: {selected_count}\n\n'
|
||||
'Если не выбрана ни одна группа - тариф доступен всем.\n'
|
||||
'Выберите группы, которым доступен этот тариф:',
|
||||
@@ -2608,7 +2643,7 @@ async def toggle_tariff_promo_group(
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
f'👥 <b>Промогруппы для тарифа «{tariff.name}»</b>\n\n'
|
||||
f'👥 <b>Промогруппы для тарифа «{html.escape(tariff.name)}»</b>\n\n'
|
||||
f'Выбрано: {len(current_groups)}\n\n'
|
||||
'Если не выбрана ни одна группа - тариф доступен всем.\n'
|
||||
'Выберите группы, которым доступен этот тариф:',
|
||||
@@ -2665,7 +2700,7 @@ async def clear_tariff_promo_groups(
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
f'👥 <b>Промогруппы для тарифа «{tariff.name}»</b>\n\n'
|
||||
f'👥 <b>Промогруппы для тарифа «{html.escape(tariff.name)}»</b>\n\n'
|
||||
f'Выбрано: 0\n\n'
|
||||
'Если не выбрана ни одна группа - тариф доступен всем.\n'
|
||||
'Выберите группы, которым доступен этот тариф:',
|
||||
@@ -2682,6 +2717,7 @@ TRAFFIC_RESET_MODES = [
|
||||
('DAY', '📅 Ежедневно', 'Трафик сбрасывается каждый день'),
|
||||
('WEEK', '📆 Еженедельно', 'Трафик сбрасывается каждую неделю'),
|
||||
('MONTH', '🗓️ Ежемесячно', 'Трафик сбрасывается каждый месяц'),
|
||||
('MONTH_ROLLING', '🔄 Скользящий месяц', 'Трафик сбрасывается через 30 дней от первого подключения'),
|
||||
('NO_RESET', '🚫 Никогда', 'Трафик не сбрасывается автоматически'),
|
||||
]
|
||||
|
||||
@@ -2731,7 +2767,7 @@ async def start_edit_traffic_reset_mode(
|
||||
current_mode = getattr(tariff, 'traffic_reset_mode', None)
|
||||
|
||||
await callback.message.edit_text(
|
||||
f'🔄 <b>Режим сброса трафика для тарифа «{tariff.name}»</b>\n\n'
|
||||
f'🔄 <b>Режим сброса трафика для тарифа «{html.escape(tariff.name)}»</b>\n\n'
|
||||
f'Текущий режим: {_format_traffic_reset_mode(current_mode)}\n\n'
|
||||
'Выберите, когда сбрасывать использованный трафик у подписчиков этого тарифа:\n\n'
|
||||
'• <b>Глобальная настройка</b> — использовать значение из конфига бота\n'
|
||||
@@ -2775,7 +2811,7 @@ async def set_traffic_reset_mode(
|
||||
|
||||
# Обновляем клавиатуру
|
||||
await callback.message.edit_text(
|
||||
f'🔄 <b>Режим сброса трафика для тарифа «{tariff.name}»</b>\n\n'
|
||||
f'🔄 <b>Режим сброса трафика для тарифа «{html.escape(tariff.name)}»</b>\n\n'
|
||||
f'Текущий режим: {mode_display}\n\n'
|
||||
'Выберите, когда сбрасывать использованный трафик у подписчиков этого тарифа:\n\n'
|
||||
'• <b>Глобальная настройка</b> — использовать значение из конфига бота\n'
|
||||
|
||||
@@ -232,8 +232,10 @@ async def view_admin_ticket(
|
||||
TicketStatus.PENDING.value: texts.t('TICKET_STATUS_PENDING', 'В ожидании'),
|
||||
}.get(ticket.status, ticket.status)
|
||||
|
||||
user_name = ticket.user.full_name if ticket.user else 'Unknown'
|
||||
telegram_id_display = (ticket.user.telegram_id or ticket.user.email or f'#{ticket.user.id}') if ticket.user else '—'
|
||||
user_name = html.escape(ticket.user.full_name) if ticket.user else 'Unknown'
|
||||
telegram_id_display = (
|
||||
html.escape(str(ticket.user.telegram_id or ticket.user.email or f'#{ticket.user.id}')) if ticket.user else '—'
|
||||
)
|
||||
username_value = ticket.user.username if ticket.user else None
|
||||
id_label = 'Telegram ID' if (ticket.user and ticket.user.telegram_id) else 'ID'
|
||||
|
||||
@@ -245,7 +247,7 @@ async def view_admin_ticket(
|
||||
header += f'📱 Username: @{safe_username}\n'
|
||||
else:
|
||||
header += '📱 Username: отсутствует\n'
|
||||
header += f'📝 Заголовок: {ticket.title}\n'
|
||||
header += f'📝 Заголовок: {html.escape(ticket.title)}\n'
|
||||
header += f'📊 Статус: {ticket.status_emoji} {status_text}\n'
|
||||
header += f'📅 Создан: {ticket.created_at.strftime("%d.%m.%Y %H:%M")}\n\n'
|
||||
|
||||
@@ -261,7 +263,7 @@ async def view_admin_ticket(
|
||||
message_blocks.append(f'💬 Сообщения ({len(ticket.messages)}):\n\n')
|
||||
for msg in ticket.messages:
|
||||
sender = '👤 Пользователь' if msg.is_user_message else '🛠️ Поддержка'
|
||||
block = f'{sender} ({msg.created_at.strftime("%d.%m %H:%M")}):\n{msg.message_text}\n\n'
|
||||
block = f'{sender} ({msg.created_at.strftime("%d.%m %H:%M")}):\n{html.escape(msg.message_text)}\n\n'
|
||||
if getattr(msg, 'has_media', False) and getattr(msg, 'media_type', None) == 'photo':
|
||||
block += '📎 Вложение: фото\n\n'
|
||||
message_blocks.append(block)
|
||||
@@ -801,10 +803,10 @@ async def handle_admin_block_duration_input(message: types.Message, state: FSMCo
|
||||
TicketStatus.CLOSED.value: texts.t('TICKET_STATUS_CLOSED', 'Закрыт'),
|
||||
TicketStatus.PENDING.value: texts.t('TICKET_STATUS_PENDING', 'В ожидании'),
|
||||
}.get(updated.status, updated.status)
|
||||
user_name = updated.user.full_name if updated.user else 'Unknown'
|
||||
user_name = html.escape(updated.user.full_name) if updated.user else 'Unknown'
|
||||
ticket_text = f'🎫 Тикет #{updated.id}\n\n'
|
||||
ticket_text += f'👤 Пользователь: {user_name}\n'
|
||||
ticket_text += f'📝 Заголовок: {updated.title}\n'
|
||||
ticket_text += f'📝 Заголовок: {html.escape(updated.title)}\n'
|
||||
ticket_text += f'📊 Статус: {updated.status_emoji} {status_text}\n'
|
||||
ticket_text += f'📅 Создан: {updated.created_at.strftime("%d.%m.%Y %H:%M")}\n'
|
||||
ticket_text += f'🔄 Обновлен: {updated.updated_at.strftime("%d.%m.%Y %H:%M")}\n'
|
||||
@@ -823,7 +825,7 @@ async def handle_admin_block_duration_input(message: types.Message, state: FSMCo
|
||||
ticket_text += f'🔗 Чат по ID: <a href="{chat_link}">{chat_link}</a>\n'
|
||||
elif updated.user:
|
||||
# Email-only user
|
||||
user_id_display = updated.user.email or f'#{updated.user.id}'
|
||||
user_id_display = html.escape(str(updated.user.email or f'#{updated.user.id}'))
|
||||
ticket_text += f'🆔 ID: <code>{user_id_display}</code>\n'
|
||||
ticket_text += '📧 Тип: Email-пользователь\n'
|
||||
ticket_text += '\n'
|
||||
@@ -837,7 +839,7 @@ async def handle_admin_block_duration_input(message: types.Message, state: FSMCo
|
||||
for msg in updated.messages:
|
||||
sender = '👤 Пользователь' if msg.is_user_message else '🛠️ Поддержка'
|
||||
ticket_text += f'{sender} ({msg.created_at.strftime("%d.%m %H:%M")}):\n'
|
||||
ticket_text += f'{msg.message_text}\n\n'
|
||||
ticket_text += f'{html.escape(msg.message_text)}\n\n'
|
||||
if getattr(msg, 'has_media', False) and getattr(msg, 'media_type', None) == 'photo':
|
||||
ticket_text += '📎 Вложение: фото\n\n'
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user