Compare commits
439 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ef654a09bb | |||
| 74f3b388d8 | |||
| 5af1f358b2 | |||
| 994325360c | |||
| f0e7f8e3be | |||
| 40d8a6dc8b | |||
| 6488dcfcb2 | |||
| 9ec5f7f59e | |||
| 0621a3febc | |||
| ebd6bee05e | |||
| 119f463c36 | |||
| a3903a252e | |||
| 65ba50c2cf | |||
| cc54a7ad2f | |||
| 7b0403a307 | |||
| 142ff14a50 | |||
| 29a3b395b6 | |||
| 49871f82f3 | |||
| efa3a5d457 | |||
| 0b86f379b4 | |||
| 1cae7130bc | |||
| 45410168af | |||
| 5482e609f8 | |||
| e79f598d17 | |||
| 056070b6a4 | |||
| 8b53c73ce8 | |||
| e6ebf81752 | |||
| 11b8ab1959 | |||
| 17e9259eb1 | |||
| 02c30f8e7e | |||
| 15c7cc2a58 | |||
| f2dbab6171 | |||
| 17af51ce0b | |||
| 8f7fa76e6a | |||
| 4648a82da9 | |||
| 5be82f2d78 | |||
| 9e3aa23f69 | |||
| 46da31d89c | |||
| 5f219c33e6 | |||
| 94fcf20d17 | |||
| 9d39901f78 | |||
| 5cf3f2f76e | |||
| 2f90f9134d | |||
| c57de1081a | |||
| 33d5155a8d | |||
| 9828ff0845 | |||
| da6f746b09 | |||
| 165965d8ea | |||
| e7e01ce9c8 | |||
| c4c49571ec | |||
| 4a63124818 | |||
| d6fa86b870 | |||
| 55d281b0e3 | |||
| a42bc9b281 | |||
| 5bc5567ab1 | |||
| d88ca980ec | |||
| 0ef4f55304 | |||
| 5070bb34e8 | |||
| 02d38d7891 | |||
| c46cc85144 | |||
| 071c23dd52 | |||
| 5f3e426750 | |||
| a9eee19c95 | |||
| 552a8ff8d8 | |||
| bec78beb25 | |||
| a6561a4788 | |||
| c49acc956f | |||
| 4c40b5b370 | |||
| 7c1a142653 | |||
| a161e2f904 | |||
| ad260d9fe0 | |||
| 3fd3bce2cf | |||
| ad6522f547 | |||
| 9ea533a864 | |||
| 61bb8fcafd | |||
| ff5bba3fc5 | |||
| cc1c8bacb4 | |||
| b707b7995b | |||
| a076dfb550 | |||
| 91ac90c2ae | |||
| b12544d3ea | |||
| 38018514dc | |||
| 924d6bc09c | |||
| 1021c2cdcd | |||
| fa01819674 | |||
| eeed2d6369 | |||
| a194be0843 | |||
| aa1cd3829c | |||
| 6c2c25d2cc | |||
| 0b61c7fe48 | |||
| b6745508da | |||
| f5391c3159 | |||
| 9a81932d2b | |||
| 8b50fde9aa | |||
| 8b924df64f | |||
| 7102c50f52 | |||
| e4c65ca220 | |||
| 557dbf3ebe | |||
| 462f7a99b9 | |||
| c68c4e5984 | |||
| f00a051bb3 | |||
| b94e3edf80 | |||
| 51ca3e42b7 | |||
| 943e9a86aa | |||
| c4da591731 | |||
| 287a43ba65 | |||
| 070321230b | |||
| 8886d0dea2 | |||
| d3819c492f | |||
| 3cbb9ef024 | |||
| 41633af763 | |||
| 916ad9d567 | |||
| ccd9ab02c5 | |||
| d0a9cfe6a9 | |||
| 333a3c5901 | |||
| 0de6418bca | |||
| e9b98b837a | |||
| 97be4afbff | |||
| 9ca24efe43 | |||
| 116c8453bb | |||
| 4e7438b9f9 | |||
| c4794db1dd | |||
| 1ffb8a5b85 | |||
| 7ab1a7b88d | |||
| e3f932afe4 | |||
| 5ca2f62854 | |||
| 8afe613451 | |||
| 8de9c6e532 | |||
| b69fcbde11 | |||
| 44d6b6b266 | |||
| 4234769e92 | |||
| c2cabbee09 | |||
| 067b1b6716 | |||
| 5b64046137 | |||
| 085a61721a | |||
| 4c2e11e64b | |||
| 7c5f35b1cf | |||
| 561708b777 | |||
| 806a959662 | |||
| 966a599c2c | |||
| 0ed98c39b6 | |||
| 095bc00b33 | |||
| 43762ce8f4 | |||
| 51752713b3 | |||
| b6fc63e33c | |||
| 488d5c99f7 | |||
| 9151882245 | |||
| 02eca28bc0 | |||
| 3f7ca7be3a | |||
| f7abe03dba | |||
| 992a5cb97f | |||
| 3d94e63c3c | |||
| 79569510d2 | |||
| b9352a5bd5 | |||
| 13ebfdb5c4 | |||
| e8a413c3c3 | |||
| aa1d3289e1 | |||
| 94a00ab269 | |||
| b8682adbbf | |||
| cf10eeda53 | |||
| 3cfac7e2dc | |||
| 39e111c91b | |||
| ba42517808 | |||
| 13846d621a | |||
| e612e2f383 | |||
| 1f524ccd80 | |||
| 37bde2985e | |||
| c6a5e0d4be | |||
| 3985053636 | |||
| 4cfb1dd38f | |||
| 117a417ce0 | |||
| a2e0474572 | |||
| e992891691 | |||
| 57cf8687d4 | |||
| 27870bbdcb | |||
| 21d48078ed | |||
| afb4f162d0 | |||
| 96d479780f | |||
| 2e0cd5d54c | |||
| c4374ce483 | |||
| bd1a0d4a4e | |||
| 97f4cc0f7c | |||
| 3ebbb42096 | |||
| 07ae7c2a7f | |||
| 5c3505aec9 | |||
| fffa231b7e | |||
| f8db099d0f | |||
| 9483517258 | |||
| 0c0ab58236 | |||
| 92ec1219fa | |||
| bf72e81d55 | |||
| 5a008e59a2 | |||
| 61b8d586d3 | |||
| df62e2bd96 | |||
| 07e8990ddb | |||
| 40fc4d7267 | |||
| 5ffd3093ea | |||
| 01ac2c7ed0 | |||
| 937a25aafd | |||
| aeee018a53 | |||
| 5cef11f32b | |||
| 467f67907f | |||
| 6d38531f42 | |||
| 2dd057a911 | |||
| 4178e4b024 | |||
| 21bcde26e5 | |||
| 878606d745 | |||
| 45a90876da | |||
| bb4f496b21 | |||
| 47c1de1cc8 | |||
| f69156d7ee | |||
| c9d559e3f2 | |||
| e28a48853d | |||
| b13da1f2e8 | |||
| ba1bf677d6 | |||
| ccdff05dca | |||
| 03875b593e | |||
| 06224d798d | |||
| 966f436723 | |||
| 4941fe9469 | |||
| fe56078481 | |||
| 39742499b8 | |||
| d8207fa1f0 | |||
| 7eb302aab0 | |||
| 8cb5da4f74 | |||
| cad786f6ee | |||
| 0adc7145a1 | |||
| d35f54a4db | |||
| 000d670869 | |||
| 0c9b69deb0 | |||
| 63e31e84fc | |||
| 1bd301f21a | |||
| 3dbaf99733 | |||
| 4380611bee | |||
| 5aca466d72 | |||
| 86970f0398 | |||
| 733be09658 | |||
| 5e75210c8b | |||
| cb0ccc9c77 | |||
| 078eebfbb1 | |||
| 4049e0d9ff | |||
| 7bf7b942ba | |||
| 4f1c14fda0 | |||
| 7bd6ae3c26 | |||
| caeafc7abd | |||
| 4d249a3bc9 | |||
| 1f26b522b4 | |||
| d1ed6c1b18 | |||
| d4d89ec20d | |||
| 6a2fd5a7de | |||
| f41c5a10b6 | |||
| 3e873a05a4 | |||
| c22f41cbf2 | |||
| f3851b9ecc | |||
| 9a258744f2 | |||
| 8780ae9407 | |||
| 3f886df44f | |||
| e3c6d4a5a1 | |||
| c3215a1e54 | |||
| fdd5a8aa6e | |||
| c1792f487a | |||
| f27f0f8bca | |||
| c70ddfe157 | |||
| 554d776b77 | |||
| 34ac9eb6ed | |||
| 6cc48d2872 | |||
| 9fc31c25b2 | |||
| 73585ebc82 | |||
| e9d3e9a2be | |||
| 3e6e2f577c | |||
| 39c644b505 | |||
| 62500a6369 | |||
| 9e16b56d9a | |||
| 9dfadcda72 | |||
| e2afdc28f3 | |||
| 86bd24edd4 | |||
| 2bda556c4b | |||
| 60724a0354 | |||
| 250edec20e | |||
| 6b1e78f990 | |||
| 56f784c8bf | |||
| ce822ead2b | |||
| e606c1d4d5 | |||
| c204194b8b | |||
| 5a878239f3 | |||
| 08aa8dabfd | |||
| 20ed6071e2 | |||
| 8d34a4b3d2 | |||
| 94a9528397 | |||
| 403052a840 | |||
| bb8e5bb6ca | |||
| 11ee8764a6 | |||
| f9be0e6315 | |||
| 9e56c56528 | |||
| 85cf96b813 | |||
| c16eee4ef2 | |||
| b6bd2625c2 | |||
| 020343cdf8 | |||
| c07fffd809 | |||
| afaeeaf7f1 | |||
| e946cc7354 | |||
| bf6a966668 | |||
| 7f12fa7003 | |||
| 5f6ef5993c | |||
| f1ac67e511 | |||
| 48f9f606aa | |||
| 7d9d1b0a6f | |||
| afadf7160c | |||
| b546fbd1cc | |||
| b8f1785783 | |||
| 7ee8c8ff4d | |||
| 551112d2d9 | |||
| e9dc9630b2 | |||
| b068c1fb12 | |||
| 9f66e176f7 | |||
| c66bad99f0 | |||
| f6a29760a9 | |||
| d7e1b8fd5d | |||
| 54e9175bdf | |||
| f581b10e19 | |||
| 1ae6ea18b7 | |||
| bea6c02d89 | |||
| 153083d791 | |||
| 80538b39ac | |||
| 205f9e8e3c | |||
| 97ccfd3af0 | |||
| 6288d3fb7f | |||
| d611eecece | |||
| 779cccffe6 | |||
| 04144dc7a5 | |||
| b7bfdbb485 | |||
| 21b7c039b7 | |||
| dc7d2eb02a | |||
| 6c26d5c7f8 | |||
| 06e99d5a43 | |||
| a7712c7151 | |||
| 418d329b75 | |||
| a4d5b8067c | |||
| f61ee0f64a | |||
| 841b1e4c52 | |||
| 927ec91e0e | |||
| 9cc2a285dc | |||
| f11173d5aa | |||
| 7bd5d13cc8 | |||
| c30e22e1b1 | |||
| ffff91466e | |||
| a7669d0f35 | |||
| 1371f21d17 | |||
| ec553d3334 | |||
| 638644d1e9 | |||
| 8ae6ef5cb8 | |||
| 1092301767 | |||
| 365f75447f | |||
| 2e3dbaa18c | |||
| 68df186f72 | |||
| 8d3bedefb0 | |||
| 701a4d51de | |||
| fbb45c10c1 | |||
| e4f182ffc6 | |||
| fa94042284 | |||
| b258715cc1 | |||
| 4e9f01d439 | |||
| 5dad41953a | |||
| dd9b33af83 | |||
| fbf2325a42 | |||
| 11eb27437b | |||
| 1bb5ef85aa | |||
| 35c5d78963 | |||
| 38ff15e794 | |||
| 2992dfbada | |||
| a1e5a71ad3 | |||
| 28dbe3dca7 | |||
| 4f77ece187 | |||
| 56a69fa1ba | |||
| 5c94bda60a | |||
| b8d0e6eefb | |||
| 49b48164fd | |||
| f688c74aee | |||
| fe42548dd7 | |||
| 7000cd5bc2 | |||
| e3901c8d39 | |||
| 23f72dc4ec | |||
| 55d817bcad | |||
| 8a9994e539 | |||
| f050a62bc6 | |||
| e26464ddad | |||
| 3263606702 | |||
| 86350424d5 | |||
| d2ade1d9ed | |||
| cc47cea268 | |||
| 6f420264f8 | |||
| 5949460572 | |||
| 4330775d01 | |||
| fa5c217dd0 | |||
| aa270c9ab4 | |||
| b7af25644a | |||
| 8b742082a4 | |||
| 8078a4e64d | |||
| 79f2cc0da5 | |||
| 0c769ac16d | |||
| 25aba75413 | |||
| 9afe370a98 | |||
| 6202801793 | |||
| 4d48418b2c | |||
| a21dfa75f0 | |||
| e09d9b6607 | |||
| e312767247 | |||
| 1dfa243736 | |||
| e0d667df28 | |||
| 68a734051d | |||
| 3f43371e60 | |||
| cb8d233a16 | |||
| d95aa7ca5c | |||
| f7fc7d5cb0 | |||
| 5d6d3b962b | |||
| 6bf8f85c80 | |||
| 0a254b1903 | |||
| 9f33331618 | |||
| 595ecff396 | |||
| ad96e8951d | |||
| 385e1b4287 | |||
| 6551ac1fe9 | |||
| 327ba81d25 | |||
| bf65e16d4d | |||
| 9cf24deb93 | |||
| 3791f1db5a | |||
| 0c3070a0cc | |||
| a37ec7a308 | |||
| b161a8604e | |||
| 1e93f24f78 | |||
| f8cd3076e9 | |||
| e557504309 | |||
| 4602f72030 | |||
| d811321808 | |||
| dffb637e8d | |||
| 32129bebc0 | |||
| dd5ee45ab5 | |||
| 95b7152c05 | |||
| aa093a074a |
+6
-11
@@ -51,6 +51,8 @@ TEST_EMAIL_PASSWORD=
|
||||
CABINET_EMAIL_VERIFICATION_EXPIRE_HOURS=24
|
||||
# Время жизни токена сброса пароля в часах
|
||||
CABINET_PASSWORD_RESET_EXPIRE_HOURS=1
|
||||
# Время жизни кода подтверждения смены email в минутах
|
||||
CABINET_EMAIL_CHANGE_CODE_EXPIRE_MINUTES=15
|
||||
|
||||
# ===== SMTP НАСТРОЙКИ (для email в личном кабинете) =====
|
||||
# SMTP сервер (например: smtp.gmail.com, smtp.yandex.ru)
|
||||
@@ -150,7 +152,7 @@ REMNAWAVE_API_KEY=your_api_key_here
|
||||
|
||||
# Тип авторизации: "api_key", "basic_auth", "caddy"
|
||||
REMNAWAVE_AUTH_TYPE=api_key
|
||||
REMNAWAVE_CADDY_TOKEN=YWRtaW46cGFzc3dvcmQ=
|
||||
REMNAWAVE_CADDY_TOKEN=
|
||||
|
||||
# Для панелей с Basic Auth (опционально)
|
||||
REMNAWAVE_USERNAME=
|
||||
@@ -542,7 +544,6 @@ PAL24_SHOP_ID=
|
||||
PAL24_SIGNATURE_TOKEN=
|
||||
PAL24_BASE_URL=https://pal24.pro/api/v1/
|
||||
PAL24_WEBHOOK_PATH=/pal24-webhook
|
||||
PAL24_WEBHOOK_PORT=8084
|
||||
PAL24_PAYMENT_DESCRIPTION="Пополнение баланса"
|
||||
PAL24_MIN_AMOUNT_KOPEKS=10000
|
||||
PAL24_MAX_AMOUNT_KOPEKS=100000000
|
||||
@@ -739,7 +740,7 @@ MAINTENANCE_MESSAGE=Ведутся технические работы. Серв
|
||||
# ===== ЛОКАЛИЗАЦИЯ =====
|
||||
# Укажите язык из AVAILABLE_LANGUAGES. При некорректном значении используется ru.
|
||||
DEFAULT_LANGUAGE=ru
|
||||
AVAILABLE_LANGUAGES=ru,en,ua,zh
|
||||
AVAILABLE_LANGUAGES=ru,en,ua,zh,fa
|
||||
# Включить выбор языка при старте и отображение кнопки в меню
|
||||
LANGUAGE_SELECTION_ENABLED=true
|
||||
|
||||
@@ -828,7 +829,7 @@ WEBHOOK_MAX_QUEUE_SIZE=1024
|
||||
WEBHOOK_WORKERS=4
|
||||
WEBHOOK_ENQUEUE_TIMEOUT=0.1
|
||||
WEBHOOK_WORKER_SHUTDOWN_TIMEOUT=30.0
|
||||
BOT_RUN_MODE=polling # polling, webhook или both
|
||||
BOT_RUN_MODE=polling # polling или webhook
|
||||
|
||||
# ===== КОНКУРСНАЯ СИСТЕМА =====
|
||||
CONTESTS_ENABLED=false
|
||||
@@ -836,15 +837,9 @@ CONTESTS_BUTTON_VISIBLE=false
|
||||
# Реферальные конкурсы (турниры среди рефералов)
|
||||
REFERRAL_CONTESTS_ENABLED=false
|
||||
|
||||
# ===== АВТОАКТИВАЦИЯ ПОСЛЕ ПОПОЛНЕНИЯ =====
|
||||
# ===== АВТОПОКУПКА ПОСЛЕ ПОПОЛНЕНИЯ =====
|
||||
# Автоматическая покупка из сохранённой корзины после пополнения баланса
|
||||
AUTO_PURCHASE_AFTER_TOPUP_ENABLED=false
|
||||
# Умная автоактивация: система сама решает — продлить или создать подписку
|
||||
# Работает даже без сохранённой корзины. Выбирает максимальный период <= баланса
|
||||
AUTO_ACTIVATE_AFTER_TOPUP_ENABLED=false
|
||||
# Показывать предупреждение об активации подписки после пополнения баланса
|
||||
# Если true - после пополнения показывает сообщение с кнопками: "Активировать", "Продлить", "Добавить устройства"
|
||||
SHOW_ACTIVATION_PROMPT_AFTER_TOPUP=false
|
||||
|
||||
# ===== КНОПКА АКТИВАЦИИ =====
|
||||
ACTIVATE_BUTTON_VISIBLE=false
|
||||
|
||||
@@ -36,15 +36,15 @@ jobs:
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||
echo "🏷️ Собираем релизную версию: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/main ]]; then
|
||||
VERSION="v3.2.0-$(git rev-parse --short HEAD)"
|
||||
VERSION="v3.7.0-$(git rev-parse --short HEAD)" # x-release-please-version
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||
echo "🚀 Собираем версию из main: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
|
||||
VERSION="v3.2.0-dev-$(git rev-parse --short HEAD)"
|
||||
VERSION="v3.7.0-dev-$(git rev-parse --short HEAD)" # x-release-please-version
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:dev,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||
echo "🧪 Собираем dev версию: $VERSION"
|
||||
else
|
||||
VERSION="v3.2.0-pr-$(git rev-parse --short HEAD)"
|
||||
VERSION="v3.7.0-pr-$(git rev-parse --short HEAD)" # x-release-please-version
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:pr-$(git rev-parse --short HEAD)"
|
||||
echo "🔀 Собираем PR версию: $VERSION"
|
||||
fi
|
||||
|
||||
@@ -14,7 +14,7 @@ on:
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: fr1ngg/remnawave-bedolaga-telegram-bot
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
@@ -49,13 +49,13 @@ jobs:
|
||||
VERSION=${GITHUB_REF#refs/tags/}
|
||||
echo "🏷️ Building release version: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/main ]]; then
|
||||
VERSION="v3.2.0-$(git rev-parse --short HEAD)"
|
||||
VERSION="v3.7.0-$(git rev-parse --short HEAD)" # x-release-please-version
|
||||
echo "🚀 Building main version: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
|
||||
VERSION="v3.2.0-dev-$(git rev-parse --short HEAD)"
|
||||
VERSION="v3.7.0-dev-$(git rev-parse --short HEAD)" # x-release-please-version
|
||||
echo "🧪 Building dev version: $VERSION"
|
||||
else
|
||||
VERSION="v3.2.0-pr-$(git rev-parse --short HEAD)"
|
||||
VERSION="v3.7.0-pr-$(git rev-parse --short HEAD)" # x-release-please-version
|
||||
echo "🔀 Building PR version: $VERSION"
|
||||
fi
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
name: Release Please
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
release-please:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
release_created: ${{ steps.release.outputs.release_created }}
|
||||
tag_name: ${{ steps.release.outputs.tag_name }}
|
||||
version: ${{ steps.release.outputs.version }}
|
||||
steps:
|
||||
- uses: googleapis/release-please-action@v4
|
||||
id: release
|
||||
with:
|
||||
config-file: release-please-config.json
|
||||
manifest-file: .release-please-manifest.json
|
||||
@@ -0,0 +1,168 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
uses: ./.github/workflows/lint.yml
|
||||
|
||||
release:
|
||||
needs: lint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Get previous tag
|
||||
id: prev_tag
|
||||
run: |
|
||||
PREV_TAG=$(git describe --tags --abbrev=0 ${{ github.ref_name }}^ 2>/dev/null || echo "")
|
||||
echo "tag=$PREV_TAG" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Generate changelog
|
||||
id: changelog
|
||||
run: |
|
||||
TAG="${{ github.ref_name }}"
|
||||
PREV_TAG="${{ steps.prev_tag.outputs.tag }}"
|
||||
|
||||
if [ -z "$PREV_TAG" ]; then
|
||||
RANGE="$TAG"
|
||||
else
|
||||
RANGE="${PREV_TAG}..${TAG}"
|
||||
fi
|
||||
|
||||
# Collect commits by category
|
||||
FEATURES=$(git log $RANGE --pretty=format:"%s|%an|%h" --no-merges | grep -iE "^feat" || true)
|
||||
FIXES=$(git log $RANGE --pretty=format:"%s|%an|%h" --no-merges | grep -iE "^fix" || true)
|
||||
PERF=$(git log $RANGE --pretty=format:"%s|%an|%h" --no-merges | grep -iE "^perf|^refactor" || true)
|
||||
DOCS=$(git log $RANGE --pretty=format:"%s|%an|%h" --no-merges | grep -iE "^docs|^style" || true)
|
||||
CHORE=$(git log $RANGE --pretty=format:"%s|%an|%h" --no-merges | grep -iE "^chore|^ci|^build|^test" || true)
|
||||
OTHER=$(git log $RANGE --pretty=format:"%s|%an|%h" --no-merges | grep -ivE "^(feat|fix|perf|refactor|docs|style|chore|ci|build|test)" || true)
|
||||
|
||||
# Collect unique contributors
|
||||
CONTRIBUTORS=$(git log $RANGE --pretty=format:"%an" --no-merges | sort -u)
|
||||
|
||||
# Stats
|
||||
TOTAL_COMMITS=$(git log $RANGE --oneline --no-merges | wc -l | tr -d ' ')
|
||||
FILES_CHANGED=$(git diff --stat $RANGE 2>/dev/null | tail -1 || echo "N/A")
|
||||
|
||||
# Format function
|
||||
format_section() {
|
||||
local commits="$1"
|
||||
if [ -n "$commits" ]; then
|
||||
echo "$commits" | while IFS='|' read -r msg author hash; do
|
||||
# Clean conventional commit prefix
|
||||
clean_msg=$(echo "$msg" | sed -E 's/^(feat|fix|perf|refactor|docs|style|chore|ci|build|test)(\([^)]*\))?:\s*//')
|
||||
echo "- ${clean_msg} (\`${hash}\`) — @${author}"
|
||||
done
|
||||
fi
|
||||
}
|
||||
|
||||
# Build changelog
|
||||
{
|
||||
echo "changelog<<CHANGELOG_EOF"
|
||||
|
||||
if [ -n "$FEATURES" ]; then
|
||||
echo "### New Features"
|
||||
echo ""
|
||||
format_section "$FEATURES"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
if [ -n "$FIXES" ]; then
|
||||
echo "### Bug Fixes"
|
||||
echo ""
|
||||
format_section "$FIXES"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
if [ -n "$PERF" ]; then
|
||||
echo "### Performance & Refactoring"
|
||||
echo ""
|
||||
format_section "$PERF"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
if [ -n "$DOCS" ]; then
|
||||
echo "### Documentation & Style"
|
||||
echo ""
|
||||
format_section "$DOCS"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
if [ -n "$CHORE" ]; then
|
||||
echo "### Maintenance"
|
||||
echo ""
|
||||
format_section "$CHORE"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
if [ -n "$OTHER" ]; then
|
||||
echo "### Other Changes"
|
||||
echo ""
|
||||
format_section "$OTHER"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
echo "---"
|
||||
echo ""
|
||||
echo "### Contributors"
|
||||
echo ""
|
||||
if [ -n "$CONTRIBUTORS" ]; then
|
||||
echo "$CONTRIBUTORS" | while read -r name; do
|
||||
echo "- @${name}"
|
||||
done
|
||||
fi
|
||||
echo ""
|
||||
echo "### Stats"
|
||||
echo ""
|
||||
echo "- **Commits:** ${TOTAL_COMMITS}"
|
||||
echo "- **Changes:** ${FILES_CHANGED}"
|
||||
if [ -n "$PREV_TAG" ]; then
|
||||
echo "- **Full diff:** [\`${PREV_TAG}...${TAG}\`](https://github.com/${{ github.repository }}/compare/${PREV_TAG}...${TAG})"
|
||||
fi
|
||||
|
||||
echo "CHANGELOG_EOF"
|
||||
} >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
name: ${{ github.ref_name }}
|
||||
body: |
|
||||
## What's Changed
|
||||
|
||||
${{ steps.changelog.outputs.changelog }}
|
||||
|
||||
---
|
||||
|
||||
### Docker
|
||||
|
||||
```bash
|
||||
# Docker Hub
|
||||
docker pull fr1ngg/remnawave-bedolaga-telegram-bot:${{ github.ref_name }}
|
||||
|
||||
# GitHub Container Registry
|
||||
docker pull ghcr.io/${{ github.repository }}:${{ github.ref_name }}
|
||||
```
|
||||
|
||||
### Update
|
||||
|
||||
```bash
|
||||
# Docker Compose
|
||||
docker compose pull && docker compose up -d
|
||||
|
||||
# Or with Make
|
||||
make reload
|
||||
```
|
||||
draft: false
|
||||
prerelease: ${{ contains(github.ref_name, 'beta') || contains(github.ref_name, 'alpha') || contains(github.ref_name, 'rc') || contains(github.ref_name, 'dev') }}
|
||||
generate_release_notes: false
|
||||
+85
-32
@@ -1,49 +1,102 @@
|
||||
# Игнорируем все файлы и папки по умолчанию
|
||||
*
|
||||
docker-compose.override.yml
|
||||
|
||||
# Исключения: разрешаем только нужные файлы
|
||||
# ========== WHITELIST: разрешённые файлы ==========
|
||||
|
||||
# Конфигурация проекта
|
||||
!.dockerignore
|
||||
!.env.example
|
||||
!install_bot.sh
|
||||
!.gitignore
|
||||
!.python-version
|
||||
!Dockerfile
|
||||
!app-config.json
|
||||
!main.py
|
||||
!requirements.txt
|
||||
!docker-compose.yml
|
||||
!docker-compose.local.yml
|
||||
!Makefile
|
||||
!pyproject.toml
|
||||
!uv.lock
|
||||
!.python-version
|
||||
!docs/
|
||||
!docs/**
|
||||
!migrations/
|
||||
!migrations/**
|
||||
!requirements.txt
|
||||
!alembic.ini
|
||||
!app-config.json
|
||||
!release-please-config.json
|
||||
!.release-please-manifest.json
|
||||
|
||||
# Документация
|
||||
!README.md
|
||||
!LICENSE
|
||||
!CONTRIBUTING.md
|
||||
!SECURITY.md
|
||||
|
||||
# Скрипты
|
||||
!install_bot.sh
|
||||
!main.py
|
||||
|
||||
# Статические файлы
|
||||
!vpn_logo.png
|
||||
|
||||
# ========== WHITELIST: разрешённые папки ==========
|
||||
|
||||
# Разрешаем папку app/ и все её содержимое рекурсивно
|
||||
!app/
|
||||
!app/**
|
||||
!tests/
|
||||
!tests/**
|
||||
|
||||
# Дополнительно разрешаем README и лицензию (опционально)
|
||||
!README.md
|
||||
!LICENSE
|
||||
|
||||
# Разрешаем .gitignore чтобы он попал в репозиторий
|
||||
!.gitignore
|
||||
|
||||
# Разрешаем .github/ (workflows, pre-commit и т.д.)
|
||||
!migrations/
|
||||
!migrations/**
|
||||
!docs/
|
||||
!docs/**
|
||||
!assets/
|
||||
!assets/**
|
||||
!locales/
|
||||
!locales/**
|
||||
!.github/
|
||||
!.github/**
|
||||
|
||||
# Разрешаем Makefile
|
||||
!Makefile
|
||||
# ========== BLACKLIST: игнорируемые внутри папок ==========
|
||||
|
||||
# Внутри разрешенных папок игнорируем служебные файлы
|
||||
app/__pycache__/
|
||||
app/**/__pycache__/
|
||||
app/**/*.pyc
|
||||
app/**/*.pyo
|
||||
app/**/*.pyd
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
# Python
|
||||
__pycache__/
|
||||
**/__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
|
||||
# Virtual environments
|
||||
.venv/
|
||||
venv/
|
||||
ENV/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Build/dist
|
||||
build/
|
||||
dist/
|
||||
*.egg-info/
|
||||
.eggs/
|
||||
|
||||
# Testing/coverage
|
||||
.coverage
|
||||
htmlcov/
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
|
||||
# Local overrides (не коммитить!)
|
||||
docker-compose.override.yml
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Runtime data
|
||||
logs/
|
||||
data/
|
||||
*.log
|
||||
*.db
|
||||
*.sqlite3
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
".": "3.9.1"
|
||||
}
|
||||
+180
@@ -0,0 +1,180 @@
|
||||
# Changelog
|
||||
|
||||
## [3.9.1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.9.0...v3.9.1) (2026-02-10)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* don't delete Heleket invoice message on status check ([9943253](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/994325360ca7665800177bfad8f831154f4d733f))
|
||||
* safe HTML preview truncation and lazy-load subscription fallback ([40d8a6d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/40d8a6dc8baf3f0f7c30b0883898b4655a907eb5))
|
||||
* use actual DB columns for subscription fallback query ([f0e7f8e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f0e7f8e3bec27d97a3f22445948b8dde37a92438))
|
||||
|
||||
## [3.9.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.8.0...v3.9.0) (2026-02-09)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* add lite mode functionality with endpoints for retrieval and update ([7b0403a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7b0403a307702c24efefc5c14af8cb2fb7525671))
|
||||
* add Persian (fa) locale with complete translations ([29a3b39](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/29a3b395b6e67e4ce2437b75120b78c76b69ff4f))
|
||||
* allow tariff deletion with active subscriptions ([ebd6bee](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ebd6bee05ed7d9187de9394c64dfd745bb06b65a))
|
||||
* **localization:** add Persian (fa) locale support and wire it across app flows ([cc54a7a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/cc54a7ad2fb98fe6e662e1923027f4989ae72868))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* nullify payment FK references before deleting transactions in user restoration ([0b86f37](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0b86f379b4e55e499ca3d189137e2aed865774b5))
|
||||
* prevent sync from overwriting end_date for non-ACTIVE panel users ([49871f8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/49871f82f37d84979ea9ec91055e3f046d5854be))
|
||||
* promo code max_uses=0 conversion and trial UX after promo activation ([1cae713](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1cae7130bc87493ab8c7691b3c22ead8189dab55))
|
||||
* skip users with active subscriptions in admin inactive cleanup ([e79f598](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e79f598d17ffa76372e6f88d2a498accf8175c76))
|
||||
* use selection.period.days instead of selection.period_days ([4541016](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/45410168afe683675003a1c41c17074a54ce04f1))
|
||||
|
||||
|
||||
### Performance
|
||||
|
||||
* cache logo file_id to avoid re-uploading on every message ([142ff14](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/142ff14a502e629446be7d67fab880d12bee149d))
|
||||
|
||||
|
||||
### Refactoring
|
||||
|
||||
* remove "both" mode from BOT_RUN_MODE, keep only polling and webhook ([efa3a5d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/efa3a5d4579f24dabeeba01a4f2e981144dd6022))
|
||||
* remove Flask, use FastAPI exclusively for all webhooks ([119f463](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/119f463c36a95685c3bc6cdf704e746b0ba20d56))
|
||||
* remove smart auto-activation & activation prompt, fix production bugs ([a3903a2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a3903a252efdd0db4b42ca3fd6771f1627050a7f))
|
||||
|
||||
## [3.8.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.7.2...v3.8.0) (2026-02-08)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* add admin device management endpoints ([c57de10](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c57de1081a9e905ba191f64c37221c36713c82a6))
|
||||
* add admin traffic packages and device limit management ([2f90f91](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2f90f9134df58b8c0a329c20060efcf07d5d92f9))
|
||||
* add admin updates endpoint for bot and cabinet releases ([11b8ab1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/11b8ab1959e83fafe405be0b76dfa3dd1580a68b))
|
||||
* add endpoint for updating user referral commission percent ([da6f746](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/da6f746b093be8cdbf4e2889c50b35087fbc90de))
|
||||
* add enrichment data to CSV export ([f2dbab6](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f2dbab617155cdc41573d885f0e55222e5b9825b))
|
||||
* add server-side sorting for enrichment columns ([15c7cc2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/15c7cc2a58e1f1935d10712a981466629db251d1))
|
||||
* add system info endpoint for admin dashboard ([02c30f8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/02c30f8e7eb6ba90ed8983cfd82199a22b473bbf))
|
||||
* add traffic usage enrichment endpoint with devices, spending, dates, last node ([5cf3f2f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5cf3f2f76eb2cd93282f845ea0850f6707bfcc09))
|
||||
* admin panel enhancements & bug fixes ([e6ebf81](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e6ebf81752499df8eb0a710072785e3d603dba33))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add debug logging for bulk device response structure ([46da31d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/46da31d89c55c225dec9136d225f2db967cf8961))
|
||||
* add email field to traffic table for OAuth/email users ([94fcf20](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/94fcf20d17c54efd67fa7bd47eff1afdd1507e08))
|
||||
* add email/UUID fallback for OAuth user panel sync ([165965d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/165965d8ea60a002c061fd75f88b759f2da66d7d))
|
||||
* add enrichment device mapping debug logs ([5be82f2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5be82f2d78aed9b54d74e86f261baa5655e5dcd9))
|
||||
* include additional devices in tariff renewal price and display ([17e9259](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/17e9259eb1d41dbf1d313b6a7d500f6458359393))
|
||||
* paginate bulk device endpoint to fetch all HWID devices ([4648a82](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4648a82da959410603c92055bcde7f96131e0c29))
|
||||
* read bot version from pyproject.toml when VERSION env is not set ([9828ff0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9828ff0845ec1d199a6fa63fe490ad3570cf9c8f))
|
||||
* revert device pagination, add raw user data field discovery ([8f7fa76](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8f7fa76e6ab34a3ad2f61f4e1f06026fd3fbf4e3))
|
||||
* use bulk device endpoint instead of per-user calls ([5f219c3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5f219c33e6d49b0e3e4405a57f8344a4237f1002))
|
||||
* use correct pagination params (start/size) for bulk HWID devices ([17af51c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/17af51ce0bdfa45197384988d56960a1918ab709))
|
||||
* use per-user panel endpoints for reliable device counts and last node data ([9d39901](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9d39901f78ece55c740a5df2603601e5d0b1caca))
|
||||
|
||||
## [3.7.2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.7.1...v3.7.2) (2026-02-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* handle FK violation in create_yookassa_payment when user is deleted ([55d281b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/55d281b0e37a6e8977ceff792cccb8669560945b))
|
||||
* remove dots from Remnawave username sanitization ([d6fa86b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d6fa86b870eccbf22327cd205539dd2084f0014e))
|
||||
|
||||
## [3.7.1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.7.0...v3.7.1) (2026-02-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* release-please config — remove blocked workflow files ([d88ca98](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d88ca980ec67e303e37f0094a2912471929b4cef))
|
||||
* remove workflow files and pyproject.toml from release-please extra-files ([5070bb3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5070bb34e8a09b2641783f5e818bb624469ad610))
|
||||
* resolve HWID reset and webhook FK violation ([5f3e426](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5f3e426750c2adcb097b92f1a9e7725b1c5c5eba))
|
||||
* resolve HWID reset context manager bug and webhook FK violation ([a9eee19](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a9eee19c95efdc38ecf5fa28f7402a2bbba7dd07))
|
||||
* resolve merge conflict in release-please config ([0ef4f55](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0ef4f55304751571754f2027105af3e507f75dfd))
|
||||
* resolve multiple production errors and performance issues ([071c23d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/071c23dd5297c20527442cb5d348d498ebf20af4))
|
||||
|
||||
## [3.7.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.6.0...v3.7.0) (2026-02-07)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add admin traffic usage API ([aa1cd38](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/aa1cd3829c5c3671e220d49dd7ec2d83563e2cf9))
|
||||
* add admin traffic usage API with per-node statistics ([6c2c25d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6c2c25d2ccb27446c822e4ed94d9351bfeaf4549))
|
||||
* add node/status filters and custom date range to traffic page ([ad260d9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ad260d9fe0b232c9d65176502476212902909660))
|
||||
* add node/status filters, custom date range, connected devices to traffic page ([9ea533a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9ea533a864e345647754f316bd27971fba1420af))
|
||||
* add node/status filters, date range, devices to traffic page ([ad6522f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ad6522f547e68ef5965e70d395ca381b0a032093))
|
||||
* add risk columns to traffic CSV export ([7c1a142](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7c1a1426537e43d14eff0a1c3faeca484611b58b))
|
||||
* add tariff filter, fix traffic data aggregation ([fa01819](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fa01819674b2d2abb0d05b470559b09eb43abef8))
|
||||
* node/status filters + custom date range for traffic page ([a161e2f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a161e2f904732b459fef98a67abfaae1214ecfd4))
|
||||
* tariff filter + fix traffic data aggregation ([1021c2c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1021c2cdcd07cf2194e59af7b59491108339e61f))
|
||||
* traffic filters, date range & risk columns in CSV export ([4c40b5b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4c40b5b370616a9ab40cbf0cccdbc0ac4a3f8278))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* close unclosed HTML tags in version notification ([0b61c7f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0b61c7fe482e7bbfbb3421307a96d54addfd91ee))
|
||||
* close unclosed HTML tags when truncating version notification ([b674550](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b6745508da861af9b2ff05d89b4ac9a3933da510))
|
||||
* correct response parsing for non-legacy node-users endpoint ([a076dfb](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a076dfb5503a349450b5aa8aac3c6f40070b715d))
|
||||
* correct response parsing for non-legacy node-users endpoint ([91ac90c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/91ac90c2aecfb990679b3d0c835314dde448886a))
|
||||
* handle mixed types in traffic sort ([eeed2d6](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/eeed2d6369b07860505c59bcff391e7b17e0ffb7))
|
||||
* handle mixed types in traffic sort for string fields ([a194be0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a194be0843856b3376167d9ba8a8ef737280998c))
|
||||
* resolve 429 rate limiting on traffic page ([b12544d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b12544d3ea8f4bbd2d8c941f83ee3ac412157adb))
|
||||
* resolve 429 rate limiting on traffic page ([924d6bc](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/924d6bc09c815c1d188ea1d0e7974f7e803c1d3f))
|
||||
* use legacy per-node endpoint for traffic aggregation ([cc1c8ba](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/cc1c8bacb42a9089021b7ae0fecd1f2717953efb))
|
||||
* use legacy per-node endpoint with correct response format ([b707b79](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b707b7995b90c6465910a35e9a4403e1408c6568))
|
||||
* use PaymentService for cabinet YooKassa payments ([61bb8fc](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/61bb8fcafd94509568f134ccdba7769b66cc7d5d))
|
||||
* use PaymentService for cabinet YooKassa payments to save local DB record ([ff5bba3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ff5bba3fc5d1e1b08d008b64215e487a9eb70960))
|
||||
|
||||
## [3.6.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.5.0...v3.6.0) (2026-02-07)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add OAuth 2.0 authorization (Google, Yandex, Discord, VK) ([97be4af](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/97be4afbffd809fe2786a6d248fc4d3f770cb8cf))
|
||||
* add panel info, node usage endpoints and campaign to user detail ([287a43b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/287a43ba6527ff3464a527821d746a68e5371bbe))
|
||||
* add panel info, node usage endpoints and campaign to user detail ([0703212](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/070321230bcb868e4bc7a39c287ed3431a4aef4a))
|
||||
* add TRIAL_DISABLED_FOR setting to disable trial by user type ([c4794db](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c4794db1dd78f7c48b5da896bdb2f000e493e079))
|
||||
* add user_id filter to admin tickets endpoint ([8886d0d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8886d0dea20aa5a31c6b6f0c3391b3c012b4b34d))
|
||||
* add user_id filter to admin tickets endpoint ([d3819c4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d3819c492f88794e4466c2da986fd3a928d7f3df))
|
||||
* block registration with disposable email addresses ([9ca24ef](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9ca24efe434278925c0c1f8d2f2d644a67985c89))
|
||||
* block registration with disposable email addresses ([116c845](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/116c8453bb371b5eacf5c9d07f497eb449a355cc))
|
||||
* disable trial by user type (email/telegram/all) ([4e7438b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4e7438b9f9c01e30c48fcf2bbe191e9b11598185))
|
||||
* migrate OAuth state storage from in-memory to Redis ([e9b98b8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e9b98b837a8552360ef4c41f6cd7a5779aa8b0a7))
|
||||
* OAuth 2.0 authorization (Google, Yandex, Discord, VK) ([3cbb9ef](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3cbb9ef024695352959ef9a82bf8b81f0ba1d940))
|
||||
* return 30-day daily breakdown for node usage ([7102c50](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7102c50f52d583add863331e96f3a9de189f581a))
|
||||
* return 30-day daily breakdown for node usage ([e4c65ca](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e4c65ca220994cf08ed3510f51d9e2808bb2d154))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* increase OAuth HTTP timeout to 30s ([333a3c5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/333a3c590120a64f6b2963efab1edd861274840c))
|
||||
* parse bandwidth stats series format for node usage ([557dbf3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/557dbf3ebe777d2137e0e28303dc2a803b15c1c6))
|
||||
* parse bandwidth stats series format for node usage ([462f7a9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/462f7a99b9d5c0b7436dbc3d6ab5db6c6cfa3118))
|
||||
* pass tariff object instead of tariff_id to set_tariff_promo_groups ([1ffb8a5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1ffb8a5b85455396006e1fcddd48f4c9a2ca2700))
|
||||
* query per-node legacy endpoint for user traffic breakdown ([b94e3ed](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b94e3edf80e747077992c03882119c7559ad1c31))
|
||||
* query per-node legacy endpoint for user traffic breakdown ([51ca3e4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/51ca3e42b75c1870c76a1b25f667629855cfe886))
|
||||
* reduce node usage to 2 API calls to avoid 429 rate limit ([c68c4e5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c68c4e59846abba9c7c78ae91ec18e2e0e329e3c))
|
||||
* reduce node usage to 2 API calls to avoid 429 rate limit ([f00a051](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f00a051bb323e5ba94a3c38939870986726ed58e))
|
||||
* use accessible nodes API and fix date format for node usage ([943e9a8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/943e9a86aaa449cd3154b0919cfdc52d2a35b509))
|
||||
* use accessible nodes API and fix date format for node usage ([c4da591](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c4da59173155e2eeb69eca21416f816fcbd1fa9c))
|
||||
|
||||
## [3.5.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.4.0...v3.5.0) (2026-02-06)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add tariff reorder API endpoint ([4c2e11e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4c2e11e64bed41592f5a12061dcca74ce43e0806))
|
||||
* pass platform-level fields from RemnaWave config to frontend ([095bc00](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/095bc00b33d7082558a8b7252906db2850dce9da))
|
||||
* serve original RemnaWave config from app-config endpoint ([43762ce](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/43762ce8f4fa7142a1ca62a92b97a027dab2564d))
|
||||
* tariff reorder API endpoint ([085a617](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/085a61721a8175b3f4fd744614c446d73346f2b7))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* enforce blacklist via middleware ([561708b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/561708b7772ec5b84d6ee049aeba26dc70675583))
|
||||
* enforce blacklist via middleware instead of per-handler checks ([966a599](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/966a599c2c778dce9eea3c61adf6067fb33119f6))
|
||||
* exclude signature field from Telegram initData HMAC validation ([5b64046](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5b6404613772610c595e55bde1249cdf6ec3269d))
|
||||
* improve button URL resolution and pass uiConfig to frontend ([0ed98c3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0ed98c39b6c95911a38a26a32d0ffbcf9cfd7c80))
|
||||
* restore unquote for user data parsing in telegram auth ([c2cabbe](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c2cabbee097a41a95d16c34d43ab7e70d076c4dc))
|
||||
|
||||
|
||||
### Reverts
|
||||
|
||||
* remove signature pop from HMAC validation ([4234769](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4234769e92104a6c4f8f1d522e1fca25bc7b20d0))
|
||||
+1
-1
@@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
|
||||
|
||||
FROM python:3.13-slim
|
||||
|
||||
ARG VERSION="v3.2.0"
|
||||
ARG VERSION="v3.9.1" # x-release-please-version
|
||||
ARG BUILD_DATE
|
||||
ARG VCS_REF
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ _Полнофункциональное решение с управлением
|
||||
|
||||
---
|
||||
|
||||
## 🧪 [Тестирование бота](https://t.me/FringVPN_bot)
|
||||
## 🧪 [Тестирование бота](https://t.me/zero_ping_vpn_bot?start=Git)
|
||||
|
||||
## 💬 **[Bedolaga Chat](https://t.me/+wTdMtSWq8YdmZmVi)** - Для общения, вопросов, предложений
|
||||
|
||||
@@ -160,7 +160,6 @@ docker compose logs
|
||||
| -------------- | --------------------------------------------------------------------------- | ------------------------------------------------ |
|
||||
| `polling` | Бот опрашивает Telegram через long polling. HTTP-сервер можно не поднимать. | Локальная отладка или отсутствие внешнего HTTPS. |
|
||||
| `webhook` | Aiogram получает апдейты только через вебхук. | Продакшн и серверы за HTTPS-прокси. |
|
||||
| `both` | Одновременно работают polling и webhook. | Тестирование или повышенная отказоустойчивость. |
|
||||
|
||||
### 2. Минимальные настройки для webhook
|
||||
|
||||
@@ -1012,7 +1011,7 @@ curl -I https://miniapp.domain.com
|
||||
| ---------------- | ----------------------------------------------------------------------------------------------------------------- | ------------------------ |
|
||||
| 🤖 **BOT_TOKEN** | [@BotFather](https://t.me/BotFather) | `1234567890:AABBCCdd...` |
|
||||
| 👑 **ADMIN_IDS** | Твой Telegram ID | `123456789,987654321` |
|
||||
| **BOT_RUN_MODE** | определяет способ приёма обновлений: `polling`, `webhook` или `both`, чтобы одновременно использовать оба режима. |
|
||||
| **BOT_RUN_MODE** | определяет способ приёма обновлений: `polling` или `webhook`. |
|
||||
|
||||
[Полный список доступных параметров:](.env.example)
|
||||
|
||||
@@ -1022,7 +1021,7 @@ curl -I https://miniapp.domain.com
|
||||
|
||||
### 🤖 Режимы запуска бота
|
||||
|
||||
- `BOT_RUN_MODE` — определяет способ приёма обновлений: `polling`, `webhook` или `both`, чтобы одновременно использовать оба режима.
|
||||
- `BOT_RUN_MODE` — определяет способ приёма обновлений: `polling` или `webhook`.
|
||||
- `WEBHOOK_SECRET_TOKEN` — секрет для проверки заголовка `X-Telegram-Bot-Api-Secret-Token` при работе через вебхуки.
|
||||
- `WEBHOOK_DROP_PENDING_UPDATES` — управляет очисткой очереди сообщений при установке вебхука.
|
||||
- `WEBHOOK_MAX_QUEUE_SIZE` — ограничивает длину очереди входящих обновлений, чтобы защащаться от перегрузок.
|
||||
@@ -1343,7 +1342,6 @@ CONTEST_BUTTON_VISIBLE=true
|
||||
- 🔄 Автоплатёж с настройкой дня списания
|
||||
- 🎁 Реферальные и промо-бонусы
|
||||
- ⚡ **Быстрое пополнение** с кнопками быстрых сумм
|
||||
- 🔄 **Умная автоактивация** подписки после пополнения баланса
|
||||
|
||||
📱 **Управление подписками**
|
||||
|
||||
@@ -1530,7 +1528,6 @@ CONTEST_BUTTON_VISIBLE=true
|
||||
- 🔄 **Миграция сквадов** - массовый перенос пользователей между сквадами
|
||||
- 🧾 **История операций** - хранение всех транзакций и действий для аудита
|
||||
- 💸 **Сервис автопроверки транзакций** - автоматическая проверка транзакций в статусе "В ожидании оплаты" за последние 24ч
|
||||
- 🔄 **Умная автоактивация** - автоматическая активация подписки после пополнения баланса
|
||||
- 📝 **Ротация логов** - автоматическая очистка и архивация старых логов
|
||||
- 🎮 **Система конкурсов** - ежедневные игры и реферальные конкурсы с призами
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ from app.handlers import (
|
||||
from app.handlers.admin import (
|
||||
backup as admin_backup,
|
||||
blacklist as admin_blacklist,
|
||||
blocked_users as admin_blocked_users,
|
||||
bot_configuration as admin_bot_configuration,
|
||||
bulk_ban as admin_bulk_ban,
|
||||
campaigns as admin_campaigns,
|
||||
@@ -60,6 +61,7 @@ from app.handlers.admin import (
|
||||
)
|
||||
from app.handlers.stars_payments import register_stars_handlers
|
||||
from app.middlewares.auth import AuthMiddleware
|
||||
from app.middlewares.blacklist import BlacklistMiddleware
|
||||
from app.middlewares.button_stats import ButtonStatsMiddleware
|
||||
from app.middlewares.display_name_restriction import DisplayNameRestrictionMiddleware
|
||||
from app.middlewares.global_error import GlobalErrorMiddleware
|
||||
@@ -118,6 +120,10 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
dp.callback_query.middleware(LoggingMiddleware())
|
||||
dp.message.middleware(MaintenanceMiddleware())
|
||||
dp.callback_query.middleware(MaintenanceMiddleware())
|
||||
blacklist_middleware = BlacklistMiddleware()
|
||||
dp.message.middleware(blacklist_middleware)
|
||||
dp.callback_query.middleware(blacklist_middleware)
|
||||
dp.pre_checkout_query.middleware(blacklist_middleware)
|
||||
display_name_middleware = DisplayNameRestrictionMiddleware()
|
||||
dp.message.middleware(display_name_middleware)
|
||||
dp.callback_query.middleware(display_name_middleware)
|
||||
@@ -189,6 +195,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
admin_tariffs.register_handlers(dp)
|
||||
admin_bulk_ban.register_bulk_ban_handlers(dp)
|
||||
admin_blacklist.register_blacklist_handlers(dp)
|
||||
admin_blocked_users.register_handlers(dp)
|
||||
common.register_handlers(dp)
|
||||
register_stars_handlers(dp)
|
||||
user_contests.register_handlers(dp)
|
||||
|
||||
@@ -6,6 +6,27 @@ from datetime import datetime, timedelta
|
||||
from app.config import settings
|
||||
|
||||
|
||||
def generate_email_change_code() -> str:
|
||||
"""
|
||||
Generate a 6-digit verification code for email change.
|
||||
|
||||
Returns:
|
||||
6-digit numeric string
|
||||
"""
|
||||
return str(secrets.randbelow(900000) + 100000)
|
||||
|
||||
|
||||
def get_email_change_expires_at() -> datetime:
|
||||
"""
|
||||
Get the expiration datetime for an email change code.
|
||||
|
||||
Returns:
|
||||
Datetime when the email change code expires
|
||||
"""
|
||||
minutes = settings.get_cabinet_email_change_code_expire_minutes()
|
||||
return datetime.utcnow() + timedelta(minutes=minutes)
|
||||
|
||||
|
||||
def generate_verification_token() -> str:
|
||||
"""
|
||||
Generate a secure random verification token.
|
||||
|
||||
@@ -0,0 +1,429 @@
|
||||
"""OAuth 2.0 provider implementations for cabinet authentication."""
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any, TypedDict
|
||||
|
||||
import httpx
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.config import settings
|
||||
from app.utils.cache import cache, cache_key
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
STATE_TTL_SECONDS = 600 # 10 minutes
|
||||
|
||||
|
||||
# --- Typed dicts for provider API responses ---
|
||||
|
||||
|
||||
class OAuthProviderConfig(TypedDict):
|
||||
client_id: str
|
||||
client_secret: str
|
||||
enabled: bool
|
||||
display_name: str
|
||||
|
||||
|
||||
class OAuthTokenResponse(TypedDict, total=False):
|
||||
access_token: str
|
||||
token_type: str
|
||||
expires_in: int
|
||||
refresh_token: str
|
||||
scope: str
|
||||
# VK-specific: email and user_id come in token response
|
||||
email: str
|
||||
user_id: int
|
||||
|
||||
|
||||
class GoogleUserInfoResponse(TypedDict, total=False):
|
||||
sub: str
|
||||
email: str
|
||||
email_verified: bool
|
||||
given_name: str
|
||||
family_name: str
|
||||
picture: str
|
||||
name: str
|
||||
|
||||
|
||||
class YandexUserInfoResponse(TypedDict, total=False):
|
||||
id: str
|
||||
login: str
|
||||
default_email: str
|
||||
emails: list[str]
|
||||
first_name: str
|
||||
last_name: str
|
||||
default_avatar_id: str
|
||||
|
||||
|
||||
class DiscordUserInfoResponse(TypedDict, total=False):
|
||||
id: str
|
||||
username: str
|
||||
global_name: str
|
||||
email: str
|
||||
verified: bool
|
||||
avatar: str
|
||||
|
||||
|
||||
class VKUserInfoItem(TypedDict, total=False):
|
||||
id: int
|
||||
first_name: str
|
||||
last_name: str
|
||||
photo_200: str
|
||||
|
||||
|
||||
class VKUserInfoResponse(TypedDict, total=False):
|
||||
response: list[VKUserInfoItem]
|
||||
|
||||
|
||||
# --- Models ---
|
||||
|
||||
|
||||
class OAuthUserInfo(BaseModel):
|
||||
"""Normalized user info from OAuth provider."""
|
||||
|
||||
provider: str
|
||||
provider_id: str
|
||||
email: str | None = None
|
||||
email_verified: bool = False
|
||||
first_name: str | None = None
|
||||
last_name: str | None = None
|
||||
username: str | None = None
|
||||
avatar_url: str | None = None
|
||||
|
||||
|
||||
# --- CSRF state management (Redis) ---
|
||||
|
||||
|
||||
async def generate_oauth_state(provider: str) -> str:
|
||||
"""Generate a CSRF state token for OAuth flow. Stored in Redis with TTL."""
|
||||
state = secrets.token_urlsafe(32)
|
||||
await cache.set(cache_key('oauth_state', state), provider, expire=STATE_TTL_SECONDS)
|
||||
return state
|
||||
|
||||
|
||||
async def validate_oauth_state(state: str, provider: str) -> bool:
|
||||
"""Validate and consume a CSRF state token from Redis."""
|
||||
key = cache_key('oauth_state', state)
|
||||
stored_provider: str | None = await cache.get(key)
|
||||
if stored_provider is None:
|
||||
return False
|
||||
await cache.delete(key)
|
||||
if stored_provider != provider:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
# --- Provider implementations ---
|
||||
|
||||
|
||||
class OAuthProvider(ABC):
|
||||
"""Base class for OAuth 2.0 providers."""
|
||||
|
||||
name: str
|
||||
display_name: str
|
||||
|
||||
def __init__(self, client_id: str, client_secret: str, redirect_uri: str) -> None:
|
||||
self.client_id = client_id
|
||||
self.client_secret = client_secret
|
||||
self.redirect_uri = redirect_uri
|
||||
|
||||
@abstractmethod
|
||||
def get_authorization_url(self, state: str) -> str:
|
||||
"""Build the authorization URL for the provider."""
|
||||
|
||||
@abstractmethod
|
||||
async def exchange_code(self, code: str) -> OAuthTokenResponse:
|
||||
"""Exchange authorization code for tokens."""
|
||||
|
||||
@abstractmethod
|
||||
async def get_user_info(self, token_data: OAuthTokenResponse) -> OAuthUserInfo:
|
||||
"""Fetch user info from the provider."""
|
||||
|
||||
|
||||
class GoogleProvider(OAuthProvider):
|
||||
name = 'google'
|
||||
display_name = 'Google'
|
||||
|
||||
AUTHORIZE_URL = 'https://accounts.google.com/o/oauth2/v2/auth'
|
||||
TOKEN_URL = 'https://oauth2.googleapis.com/token'
|
||||
USERINFO_URL = 'https://www.googleapis.com/oauth2/v3/userinfo'
|
||||
|
||||
def get_authorization_url(self, state: str) -> str:
|
||||
params: dict[str, str] = {
|
||||
'client_id': self.client_id,
|
||||
'redirect_uri': self.redirect_uri,
|
||||
'response_type': 'code',
|
||||
'scope': 'openid email profile',
|
||||
'state': state,
|
||||
'access_type': 'offline',
|
||||
'prompt': 'select_account',
|
||||
}
|
||||
request = httpx.Request('GET', self.AUTHORIZE_URL, params=params)
|
||||
return str(request.url)
|
||||
|
||||
async def exchange_code(self, code: str) -> OAuthTokenResponse:
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
response = await client.post(
|
||||
self.TOKEN_URL,
|
||||
json={
|
||||
'client_id': self.client_id,
|
||||
'client_secret': self.client_secret,
|
||||
'code': code,
|
||||
'grant_type': 'authorization_code',
|
||||
'redirect_uri': self.redirect_uri,
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data: OAuthTokenResponse = response.json()
|
||||
return data
|
||||
|
||||
async def get_user_info(self, token_data: OAuthTokenResponse) -> OAuthUserInfo:
|
||||
access_token = token_data['access_token']
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
response = await client.get(
|
||||
self.USERINFO_URL,
|
||||
headers={'Authorization': f'Bearer {access_token}'},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data: GoogleUserInfoResponse = response.json()
|
||||
|
||||
return OAuthUserInfo(
|
||||
provider='google',
|
||||
provider_id=str(data['sub']),
|
||||
email=data.get('email'),
|
||||
email_verified=data.get('email_verified', False),
|
||||
first_name=data.get('given_name'),
|
||||
last_name=data.get('family_name'),
|
||||
avatar_url=data.get('picture'),
|
||||
)
|
||||
|
||||
|
||||
class YandexProvider(OAuthProvider):
|
||||
name = 'yandex'
|
||||
display_name = 'Yandex'
|
||||
|
||||
AUTHORIZE_URL = 'https://oauth.yandex.com/authorize'
|
||||
TOKEN_URL = 'https://oauth.yandex.com/token'
|
||||
USERINFO_URL = 'https://login.yandex.ru/info'
|
||||
|
||||
def get_authorization_url(self, state: str) -> str:
|
||||
params: dict[str, str] = {
|
||||
'client_id': self.client_id,
|
||||
'redirect_uri': self.redirect_uri,
|
||||
'response_type': 'code',
|
||||
'scope': 'login:info login:email',
|
||||
'state': state,
|
||||
'force_confirm': 'yes',
|
||||
}
|
||||
request = httpx.Request('GET', self.AUTHORIZE_URL, params=params)
|
||||
return str(request.url)
|
||||
|
||||
async def exchange_code(self, code: str) -> OAuthTokenResponse:
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
response = await client.post(
|
||||
self.TOKEN_URL,
|
||||
data={
|
||||
'client_id': self.client_id,
|
||||
'client_secret': self.client_secret,
|
||||
'code': code,
|
||||
'grant_type': 'authorization_code',
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data: OAuthTokenResponse = response.json()
|
||||
return data
|
||||
|
||||
async def get_user_info(self, token_data: OAuthTokenResponse) -> OAuthUserInfo:
|
||||
access_token = token_data['access_token']
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
response = await client.get(
|
||||
self.USERINFO_URL,
|
||||
params={'format': 'json'},
|
||||
headers={'Authorization': f'OAuth {access_token}'},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data: YandexUserInfoResponse = response.json()
|
||||
|
||||
default_email = data.get('default_email')
|
||||
emails = data.get('emails', [])
|
||||
email = default_email or (emails[0] if emails else None)
|
||||
|
||||
return OAuthUserInfo(
|
||||
provider='yandex',
|
||||
provider_id=str(data['id']),
|
||||
email=email,
|
||||
email_verified=bool(email),
|
||||
first_name=data.get('first_name'),
|
||||
last_name=data.get('last_name'),
|
||||
username=data.get('login'),
|
||||
avatar_url=(
|
||||
f'https://avatars.yandex.net/get-yapic/{data["default_avatar_id"]}/islands-200'
|
||||
if data.get('default_avatar_id')
|
||||
else None
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class DiscordProvider(OAuthProvider):
|
||||
name = 'discord'
|
||||
display_name = 'Discord'
|
||||
|
||||
AUTHORIZE_URL = 'https://discord.com/api/oauth2/authorize'
|
||||
TOKEN_URL = 'https://discord.com/api/oauth2/token'
|
||||
USERINFO_URL = 'https://discord.com/api/v10/users/@me'
|
||||
|
||||
def get_authorization_url(self, state: str) -> str:
|
||||
params: dict[str, str] = {
|
||||
'client_id': self.client_id,
|
||||
'redirect_uri': self.redirect_uri,
|
||||
'response_type': 'code',
|
||||
'scope': 'identify email',
|
||||
'state': state,
|
||||
'prompt': 'consent',
|
||||
}
|
||||
request = httpx.Request('GET', self.AUTHORIZE_URL, params=params)
|
||||
return str(request.url)
|
||||
|
||||
async def exchange_code(self, code: str) -> OAuthTokenResponse:
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
response = await client.post(
|
||||
self.TOKEN_URL,
|
||||
data={
|
||||
'client_id': self.client_id,
|
||||
'client_secret': self.client_secret,
|
||||
'code': code,
|
||||
'grant_type': 'authorization_code',
|
||||
'redirect_uri': self.redirect_uri,
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data: OAuthTokenResponse = response.json()
|
||||
return data
|
||||
|
||||
async def get_user_info(self, token_data: OAuthTokenResponse) -> OAuthUserInfo:
|
||||
access_token = token_data['access_token']
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
response = await client.get(
|
||||
self.USERINFO_URL,
|
||||
headers={'Authorization': f'Bearer {access_token}'},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data: DiscordUserInfoResponse = response.json()
|
||||
|
||||
avatar_url: str | None = None
|
||||
if data.get('avatar'):
|
||||
avatar_url = f'https://cdn.discordapp.com/avatars/{data["id"]}/{data["avatar"]}.png'
|
||||
|
||||
return OAuthUserInfo(
|
||||
provider='discord',
|
||||
provider_id=str(data['id']),
|
||||
email=data.get('email'),
|
||||
email_verified=data.get('verified', False),
|
||||
first_name=data.get('global_name') or data.get('username'),
|
||||
username=data.get('username'),
|
||||
avatar_url=avatar_url,
|
||||
)
|
||||
|
||||
|
||||
class VKProvider(OAuthProvider):
|
||||
name = 'vk'
|
||||
display_name = 'VK'
|
||||
|
||||
AUTHORIZE_URL = 'https://oauth.vk.com/authorize'
|
||||
TOKEN_URL = 'https://oauth.vk.com/access_token'
|
||||
USERINFO_URL = 'https://api.vk.com/method/users.get'
|
||||
API_VERSION = '5.131'
|
||||
|
||||
def get_authorization_url(self, state: str) -> str:
|
||||
params: dict[str, str] = {
|
||||
'client_id': self.client_id,
|
||||
'redirect_uri': self.redirect_uri,
|
||||
'response_type': 'code',
|
||||
'scope': 'email',
|
||||
'state': state,
|
||||
'v': self.API_VERSION,
|
||||
}
|
||||
request = httpx.Request('GET', self.AUTHORIZE_URL, params=params)
|
||||
return str(request.url)
|
||||
|
||||
async def exchange_code(self, code: str) -> OAuthTokenResponse:
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
response = await client.get(
|
||||
self.TOKEN_URL,
|
||||
params={
|
||||
'client_id': self.client_id,
|
||||
'client_secret': self.client_secret,
|
||||
'code': code,
|
||||
'redirect_uri': self.redirect_uri,
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data: OAuthTokenResponse = response.json()
|
||||
return data
|
||||
|
||||
async def get_user_info(self, token_data: OAuthTokenResponse) -> OAuthUserInfo:
|
||||
access_token = token_data['access_token']
|
||||
user_id: int | None = token_data.get('user_id')
|
||||
# VK returns email in token response, not in userinfo
|
||||
email: str | None = token_data.get('email')
|
||||
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
response = await client.get(
|
||||
self.USERINFO_URL,
|
||||
params={
|
||||
'access_token': access_token,
|
||||
'fields': 'photo_200',
|
||||
'v': self.API_VERSION,
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data: VKUserInfoResponse = response.json()
|
||||
|
||||
users: list[Any] = data.get('response', [])
|
||||
user_data: VKUserInfoItem = users[0] if users else {} # type: ignore[assignment]
|
||||
|
||||
return OAuthUserInfo(
|
||||
provider='vk',
|
||||
provider_id=str(user_id or user_data.get('id', '')),
|
||||
email=email,
|
||||
email_verified=bool(email),
|
||||
first_name=user_data.get('first_name'),
|
||||
last_name=user_data.get('last_name'),
|
||||
avatar_url=user_data.get('photo_200'),
|
||||
)
|
||||
|
||||
|
||||
# --- Provider factory ---
|
||||
|
||||
_PROVIDERS: dict[str, type[OAuthProvider]] = {
|
||||
'google': GoogleProvider,
|
||||
'yandex': YandexProvider,
|
||||
'discord': DiscordProvider,
|
||||
'vk': VKProvider,
|
||||
}
|
||||
|
||||
|
||||
def get_provider(name: str) -> OAuthProvider | None:
|
||||
"""Get an OAuth provider instance if enabled.
|
||||
|
||||
Returns None if the provider is not enabled or not found.
|
||||
"""
|
||||
providers_config: dict[str, OAuthProviderConfig] = settings.get_oauth_providers_config()
|
||||
config = providers_config.get(name)
|
||||
if not config or not config['enabled']:
|
||||
return None
|
||||
|
||||
provider_class = _PROVIDERS.get(name)
|
||||
if not provider_class:
|
||||
return None
|
||||
|
||||
redirect_uri = f'{settings.CABINET_URL}/auth/oauth/callback'
|
||||
|
||||
return provider_class(
|
||||
client_id=config['client_id'],
|
||||
client_secret=config['client_secret'],
|
||||
redirect_uri=redirect_uri,
|
||||
)
|
||||
@@ -12,6 +12,7 @@ from app.config import settings
|
||||
from app.database.crud.user import get_user_by_id
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from app.database.models import User
|
||||
from app.services.blacklist_service import blacklist_service
|
||||
from app.services.maintenance_service import maintenance_service
|
||||
|
||||
from .auth.jwt_handler import get_token_payload
|
||||
@@ -104,6 +105,18 @@ async def get_current_cabinet_user(
|
||||
detail='User account is not active',
|
||||
)
|
||||
|
||||
# Check blacklist
|
||||
if user.telegram_id is not None:
|
||||
is_blacklisted, reason = await blacklist_service.is_user_blacklisted(user.telegram_id, user.username)
|
||||
if is_blacklisted:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail={
|
||||
'code': 'blacklisted',
|
||||
'message': reason or 'Доступ запрещен',
|
||||
},
|
||||
)
|
||||
|
||||
# Check maintenance mode (allow admins to pass)
|
||||
if maintenance_service.is_maintenance_active():
|
||||
# Проверяем админа по telegram_id ИЛИ email
|
||||
|
||||
@@ -17,6 +17,8 @@ from .admin_settings import router as admin_settings_router
|
||||
from .admin_stats import router as admin_stats_router
|
||||
from .admin_tariffs import router as admin_tariffs_router
|
||||
from .admin_tickets import router as admin_tickets_router
|
||||
from .admin_traffic import router as admin_traffic_router
|
||||
from .admin_updates import router as admin_updates_router
|
||||
from .admin_users import router as admin_users_router
|
||||
from .admin_wheel import router as admin_wheel_router
|
||||
from .auth import router as auth_router
|
||||
@@ -26,6 +28,7 @@ from .contests import router as contests_router
|
||||
from .info import router as info_router
|
||||
from .media import router as media_router
|
||||
from .notifications import router as notifications_router
|
||||
from .oauth import router as oauth_router
|
||||
from .polls import router as polls_router
|
||||
from .promo import router as promo_router
|
||||
from .promocode import router as promocode_router
|
||||
@@ -45,6 +48,7 @@ router = APIRouter(prefix='/cabinet', tags=['Cabinet'])
|
||||
|
||||
# Include all sub-routers
|
||||
router.include_router(auth_router)
|
||||
router.include_router(oauth_router)
|
||||
router.include_router(subscription_router)
|
||||
router.include_router(balance_router)
|
||||
router.include_router(referral_router)
|
||||
@@ -83,6 +87,8 @@ router.include_router(admin_payments_router)
|
||||
router.include_router(admin_promo_offers_router)
|
||||
router.include_router(admin_remnawave_router)
|
||||
router.include_router(admin_email_templates_router)
|
||||
router.include_router(admin_updates_router)
|
||||
router.include_router(admin_traffic_router)
|
||||
|
||||
# WebSocket route
|
||||
router.include_router(websocket_router)
|
||||
|
||||
@@ -4,7 +4,7 @@ import logging
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy import distinct, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import BroadcastHistory, Subscription, SubscriptionStatus, Tariff, User
|
||||
@@ -13,7 +13,9 @@ from app.keyboards.admin import BROADCAST_BUTTONS, DEFAULT_BROADCAST_BUTTONS
|
||||
from app.services.broadcast_service import (
|
||||
BroadcastConfig,
|
||||
BroadcastMediaConfig,
|
||||
EmailBroadcastConfig,
|
||||
broadcast_service,
|
||||
email_broadcast_service,
|
||||
)
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
@@ -28,6 +30,11 @@ from ..schemas.broadcasts import (
|
||||
BroadcastPreviewResponse,
|
||||
BroadcastResponse,
|
||||
BroadcastTariffsResponse,
|
||||
CombinedBroadcastCreateRequest,
|
||||
EmailFilterItem,
|
||||
EmailFiltersResponse,
|
||||
EmailPreviewRequest,
|
||||
EmailPreviewResponse,
|
||||
TariffFilter,
|
||||
TariffForBroadcast,
|
||||
)
|
||||
@@ -87,6 +94,25 @@ CUSTOM_FILTER_GROUPS = {
|
||||
}
|
||||
|
||||
|
||||
# ============ Email Filter Labels ============
|
||||
|
||||
EMAIL_FILTER_LABELS = {
|
||||
'all_email': 'Все с email',
|
||||
'email_only': 'Только email-регистрация',
|
||||
'telegram_with_email': 'Telegram с email',
|
||||
'active_email': 'С активной подпиской',
|
||||
'expired_email': 'С истекшей подпиской',
|
||||
}
|
||||
|
||||
EMAIL_FILTER_GROUPS = {
|
||||
'all_email': 'basic',
|
||||
'email_only': 'auth_type',
|
||||
'telegram_with_email': 'auth_type',
|
||||
'active_email': 'subscription',
|
||||
'expired_email': 'subscription',
|
||||
}
|
||||
|
||||
|
||||
# ============ Helper Functions ============
|
||||
|
||||
|
||||
@@ -113,9 +139,73 @@ def _serialize_broadcast(broadcast: BroadcastHistory) -> BroadcastResponse:
|
||||
created_at=broadcast.created_at,
|
||||
completed_at=broadcast.completed_at,
|
||||
progress_percent=progress,
|
||||
channel=getattr(broadcast, 'channel', 'telegram') or 'telegram',
|
||||
email_subject=getattr(broadcast, 'email_subject', None),
|
||||
email_html_content=getattr(broadcast, 'email_html_content', None),
|
||||
)
|
||||
|
||||
|
||||
async def _get_email_filter_count(db: AsyncSession, target: str) -> int:
|
||||
"""Get count of email users matching the filter."""
|
||||
base_conditions = [
|
||||
User.email.isnot(None),
|
||||
User.email_verified == True,
|
||||
User.status == 'active',
|
||||
]
|
||||
|
||||
if target == 'all_email':
|
||||
query = select(func.count(User.id)).where(*base_conditions)
|
||||
|
||||
elif target == 'email_only':
|
||||
query = select(func.count(User.id)).where(
|
||||
*base_conditions,
|
||||
User.auth_type == 'email',
|
||||
)
|
||||
|
||||
elif target == 'telegram_with_email':
|
||||
query = select(func.count(User.id)).where(
|
||||
*base_conditions,
|
||||
User.auth_type == 'telegram',
|
||||
User.telegram_id.isnot(None),
|
||||
)
|
||||
|
||||
elif target == 'active_email':
|
||||
query = (
|
||||
select(func.count(distinct(User.id)))
|
||||
.join(Subscription, User.id == Subscription.user_id)
|
||||
.where(
|
||||
*base_conditions,
|
||||
Subscription.status == SubscriptionStatus.ACTIVE.value,
|
||||
)
|
||||
)
|
||||
|
||||
elif target == 'expired_email':
|
||||
query = (
|
||||
select(func.count(distinct(User.id)))
|
||||
.join(Subscription, User.id == Subscription.user_id)
|
||||
.where(
|
||||
*base_conditions,
|
||||
Subscription.status.in_(
|
||||
[
|
||||
SubscriptionStatus.EXPIRED.value,
|
||||
SubscriptionStatus.DISABLED.value,
|
||||
]
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
else:
|
||||
return 0
|
||||
|
||||
result = await db.execute(query)
|
||||
return result.scalar() or 0
|
||||
|
||||
|
||||
def _validate_email_target(target: str) -> bool:
|
||||
"""Validate email target filter."""
|
||||
return target in EMAIL_FILTER_LABELS
|
||||
|
||||
|
||||
async def _get_tariff_user_counts(db: AsyncSession) -> dict:
|
||||
"""Get count of active users per tariff."""
|
||||
result = await db.execute(
|
||||
@@ -388,6 +478,194 @@ async def list_broadcasts(
|
||||
)
|
||||
|
||||
|
||||
# ============ Email Broadcast Endpoints ============
|
||||
|
||||
|
||||
@router.get('/email-filters', response_model=EmailFiltersResponse)
|
||||
async def get_email_filters(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> EmailFiltersResponse:
|
||||
"""Get all available email filters with user counts."""
|
||||
filters = []
|
||||
total_with_email = 0
|
||||
|
||||
for key, label in EMAIL_FILTER_LABELS.items():
|
||||
try:
|
||||
count = await _get_email_filter_count(db, key)
|
||||
except Exception as e:
|
||||
logger.warning(f'Failed to get count for email filter {key}: {e}')
|
||||
count = 0
|
||||
|
||||
filters.append(
|
||||
EmailFilterItem(
|
||||
key=key,
|
||||
label=label,
|
||||
count=count,
|
||||
group=EMAIL_FILTER_GROUPS.get(key),
|
||||
)
|
||||
)
|
||||
|
||||
# Track total with email (all_email filter)
|
||||
if key == 'all_email':
|
||||
total_with_email = count
|
||||
|
||||
return EmailFiltersResponse(
|
||||
filters=filters,
|
||||
total_with_email=total_with_email,
|
||||
)
|
||||
|
||||
|
||||
@router.post('/email-preview', response_model=EmailPreviewResponse)
|
||||
async def preview_email_broadcast(
|
||||
request: EmailPreviewRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> EmailPreviewResponse:
|
||||
"""Preview email broadcast recipients count."""
|
||||
if not _validate_email_target(request.target):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Invalid email target: {request.target}',
|
||||
)
|
||||
|
||||
try:
|
||||
count = await _get_email_filter_count(db, request.target)
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to get email count for target {request.target}: {e}')
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to count email recipients',
|
||||
)
|
||||
|
||||
return EmailPreviewResponse(target=request.target, count=count)
|
||||
|
||||
|
||||
@router.post('/send', response_model=BroadcastResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_combined_broadcast(
|
||||
request: CombinedBroadcastCreateRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> BroadcastResponse:
|
||||
"""Create and start a combined broadcast (telegram/email/both)."""
|
||||
# Get tariff IDs for target validation
|
||||
result = await db.execute(select(Tariff.id))
|
||||
tariff_ids = {row[0] for row in result.all()}
|
||||
|
||||
admin_name = admin.username or f'Admin #{admin.id}'
|
||||
|
||||
# Validate based on channel
|
||||
if request.channel in ('telegram', 'both'):
|
||||
# Validate telegram target
|
||||
if not _validate_target(request.target, tariff_ids):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Invalid target: {request.target}',
|
||||
)
|
||||
|
||||
# Validate telegram message
|
||||
if not request.message_text or not request.message_text.strip():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Message text is required for Telegram broadcast',
|
||||
)
|
||||
|
||||
# Validate buttons
|
||||
if not _validate_buttons(request.selected_buttons):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Invalid button key',
|
||||
)
|
||||
|
||||
if request.channel in ('email', 'both'):
|
||||
# For email channel, target must be email filter or we use telegram target for 'both'
|
||||
if request.channel == 'email' and not _validate_email_target(request.target):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Invalid email target: {request.target}',
|
||||
)
|
||||
|
||||
# Validate email fields
|
||||
if not request.email_subject or not request.email_subject.strip():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Email subject is required for email broadcast',
|
||||
)
|
||||
|
||||
if not request.email_html_content or not request.email_html_content.strip():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Email HTML content is required for email broadcast',
|
||||
)
|
||||
|
||||
media_payload = request.media
|
||||
|
||||
# Create broadcast record
|
||||
broadcast = BroadcastHistory(
|
||||
target_type=request.target,
|
||||
message_text=request.message_text.strip() if request.message_text else None,
|
||||
has_media=media_payload is not None,
|
||||
media_type=media_payload.type if media_payload else None,
|
||||
media_file_id=media_payload.file_id if media_payload else None,
|
||||
media_caption=media_payload.caption if media_payload else None,
|
||||
total_count=0,
|
||||
sent_count=0,
|
||||
failed_count=0,
|
||||
status='queued',
|
||||
admin_id=admin.id,
|
||||
admin_name=admin_name,
|
||||
channel=request.channel,
|
||||
email_subject=request.email_subject.strip() if request.email_subject else None,
|
||||
email_html_content=request.email_html_content.strip() if request.email_html_content else None,
|
||||
)
|
||||
db.add(broadcast)
|
||||
await db.commit()
|
||||
await db.refresh(broadcast)
|
||||
|
||||
# Start broadcasts based on channel
|
||||
if request.channel in ('telegram', 'both'):
|
||||
# Prepare media config
|
||||
media_config = None
|
||||
if media_payload:
|
||||
media_config = BroadcastMediaConfig(
|
||||
type=media_payload.type,
|
||||
file_id=media_payload.file_id,
|
||||
caption=media_payload.caption or request.message_text,
|
||||
)
|
||||
|
||||
# Create telegram broadcast config
|
||||
telegram_config = BroadcastConfig(
|
||||
target=request.target,
|
||||
message_text=request.message_text.strip(),
|
||||
selected_buttons=request.selected_buttons,
|
||||
media=media_config,
|
||||
initiator_name=admin_name,
|
||||
)
|
||||
|
||||
await broadcast_service.start_broadcast(broadcast.id, telegram_config)
|
||||
|
||||
if request.channel in ('email', 'both'):
|
||||
# For 'both' channel, we use 'all_email' as default email target
|
||||
# since telegram target won't match email filters
|
||||
email_target = request.target if request.channel == 'email' else 'all_email'
|
||||
|
||||
# Create email broadcast config
|
||||
email_config = EmailBroadcastConfig(
|
||||
target=email_target,
|
||||
email_subject=request.email_subject.strip(),
|
||||
email_html_content=request.email_html_content.strip(),
|
||||
initiator_name=admin_name,
|
||||
)
|
||||
|
||||
await email_broadcast_service.start_broadcast(broadcast.id, email_config)
|
||||
|
||||
await db.refresh(broadcast)
|
||||
|
||||
logger.info(f"Admin {admin.id} created {request.channel} broadcast {broadcast.id} for target '{request.target}'")
|
||||
|
||||
return _serialize_broadcast(broadcast)
|
||||
|
||||
|
||||
@router.get('/{broadcast_id}', response_model=BroadcastResponse)
|
||||
async def get_broadcast(
|
||||
broadcast_id: int,
|
||||
@@ -410,7 +688,7 @@ async def stop_broadcast(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> BroadcastResponse:
|
||||
"""Stop a running broadcast."""
|
||||
"""Stop a running broadcast (telegram or email)."""
|
||||
broadcast = await db.get(BroadcastHistory, broadcast_id)
|
||||
if not broadcast:
|
||||
raise HTTPException(
|
||||
@@ -424,7 +702,15 @@ async def stop_broadcast(
|
||||
detail='Broadcast is not running',
|
||||
)
|
||||
|
||||
is_running = await broadcast_service.request_stop(broadcast_id)
|
||||
# Try to stop both telegram and email broadcasts (one or both may be running)
|
||||
channel = getattr(broadcast, 'channel', 'telegram') or 'telegram'
|
||||
|
||||
is_running = False
|
||||
if channel in ('telegram', 'both'):
|
||||
is_running = await broadcast_service.request_stop(broadcast_id) or is_running
|
||||
|
||||
if channel in ('email', 'both'):
|
||||
is_running = await email_broadcast_service.request_stop(broadcast_id) or is_running
|
||||
|
||||
if is_running:
|
||||
broadcast.status = 'cancelling'
|
||||
|
||||
@@ -337,7 +337,7 @@ SAMPLE_CONTEXTS: dict[str, dict[str, Any]] = {
|
||||
'password_reset': {'username': 'John', 'reset_url': 'https://example.com/reset?token=abc123', 'expire_hours': 1},
|
||||
}
|
||||
|
||||
AVAILABLE_LANGUAGES = ['ru', 'en', 'zh', 'ua']
|
||||
AVAILABLE_LANGUAGES = ['ru', 'en', 'zh', 'ua', 'fa']
|
||||
|
||||
|
||||
# ============ Schemas ============
|
||||
|
||||
@@ -575,9 +575,24 @@ async def broadcast_offer(
|
||||
notifications_failed = 0
|
||||
|
||||
if payload.send_notification and offers_to_notify:
|
||||
# Render placeholders in custom message text
|
||||
rendered_message_text = payload.message_text
|
||||
if rendered_message_text:
|
||||
extra = payload.extra_data or {}
|
||||
try:
|
||||
rendered_message_text = rendered_message_text.format(
|
||||
discount_percent=payload.discount_percent,
|
||||
valid_hours=payload.valid_hours,
|
||||
active_discount_hours=extra.get('active_discount_hours') or payload.valid_hours,
|
||||
test_duration_hours=extra.get('test_duration_hours') or 0,
|
||||
server_name=extra.get('server_name', ''),
|
||||
)
|
||||
except (KeyError, ValueError, IndexError):
|
||||
logger.warning('Failed to render promo message placeholders')
|
||||
|
||||
notifications_sent, notifications_failed = await _send_promo_notifications(
|
||||
offers_to_notify=offers_to_notify,
|
||||
message_text=payload.message_text,
|
||||
message_text=rendered_message_text,
|
||||
button_text=payload.button_text,
|
||||
discount_percent=payload.discount_percent,
|
||||
bonus_amount_kopeks=payload.bonus_amount_kopeks,
|
||||
|
||||
@@ -363,13 +363,16 @@ async def create_promocode_endpoint(
|
||||
if existing:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Promo code with this code already exists')
|
||||
|
||||
# 0 means unlimited — convert to large number for is_valid check (current_uses < max_uses)
|
||||
effective_max_uses = 999999 if payload.max_uses == 0 else payload.max_uses
|
||||
|
||||
promocode = await create_promocode(
|
||||
db,
|
||||
code=normalized_code,
|
||||
type=payload.type,
|
||||
balance_bonus_kopeks=payload.balance_bonus_kopeks,
|
||||
subscription_days=payload.subscription_days,
|
||||
max_uses=payload.max_uses,
|
||||
max_uses=effective_max_uses,
|
||||
valid_until=normalized_valid_until,
|
||||
created_by=admin.id,
|
||||
)
|
||||
@@ -426,7 +429,7 @@ async def update_promocode_endpoint(
|
||||
updates['subscription_days'] = payload.subscription_days
|
||||
|
||||
if payload.max_uses is not None:
|
||||
updates['max_uses'] = payload.max_uses
|
||||
updates['max_uses'] = 999999 if payload.max_uses == 0 else payload.max_uses
|
||||
|
||||
if payload.valid_from is not None:
|
||||
updates['valid_from'] = _normalize_datetime(payload.valid_from)
|
||||
@@ -472,6 +475,61 @@ async def delete_promocode_endpoint(
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
class DeactivateDiscountResponse(BaseModel):
|
||||
success: bool
|
||||
message: str
|
||||
deactivated_code: str | None = None
|
||||
discount_percent: int = 0
|
||||
user_id: int
|
||||
|
||||
|
||||
@router.post('/deactivate-discount/{user_id}', response_model=DeactivateDiscountResponse)
|
||||
async def admin_deactivate_discount_promocode(
|
||||
user_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> DeactivateDiscountResponse:
|
||||
"""Admin: deactivate a user's active discount promo code."""
|
||||
from app.database.crud.user import get_user_by_id as get_user
|
||||
|
||||
target_user = await get_user(db, user_id)
|
||||
if not target_user:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, 'User not found')
|
||||
|
||||
from app.services.promocode_service import PromoCodeService
|
||||
|
||||
service = PromoCodeService()
|
||||
result = await service.deactivate_discount_promocode(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
admin_initiated=True,
|
||||
)
|
||||
|
||||
if result['success']:
|
||||
return DeactivateDiscountResponse(
|
||||
success=True,
|
||||
message=f'Discount promo code deactivated for user {user_id}',
|
||||
deactivated_code=result.get('deactivated_code'),
|
||||
discount_percent=result.get('discount_percent', 0),
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
error_messages = {
|
||||
'user_not_found': 'User not found',
|
||||
'no_active_discount_promocode': 'User has no active discount from a promo code',
|
||||
'discount_already_expired': 'Discount has already expired (cleaned up)',
|
||||
'server_error': 'Server error occurred',
|
||||
}
|
||||
|
||||
error_code = result.get('error', 'server_error')
|
||||
error_message = error_messages.get(error_code, 'Failed to deactivate promo code')
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=error_message,
|
||||
)
|
||||
|
||||
|
||||
# ============== PromoGroup Endpoints ==============
|
||||
|
||||
promo_groups_router = APIRouter(prefix='/admin/promo-groups', tags=['Admin Promo Groups'])
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"""Admin routes for statistics dashboard in cabinet."""
|
||||
|
||||
import logging
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
@@ -22,12 +24,15 @@ from app.database.models import (
|
||||
User,
|
||||
)
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
from app.services.version_service import version_service
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_start_time = time.time()
|
||||
|
||||
router = APIRouter(prefix='/admin/stats', tags=['Cabinet Admin Stats'])
|
||||
|
||||
|
||||
@@ -142,6 +147,16 @@ class DashboardStats(BaseModel):
|
||||
tariff_stats: TariffStats | None = None
|
||||
|
||||
|
||||
class SystemInfoResponse(BaseModel):
|
||||
"""System information for admin dashboard."""
|
||||
|
||||
bot_version: str
|
||||
python_version: str
|
||||
uptime_seconds: int
|
||||
users_total: int
|
||||
subscriptions_active: int
|
||||
|
||||
|
||||
# ============ Extended Stats Schemas ============
|
||||
|
||||
|
||||
@@ -309,6 +324,38 @@ async def get_dashboard_stats(
|
||||
)
|
||||
|
||||
|
||||
@router.get('/system-info', response_model=SystemInfoResponse)
|
||||
async def get_system_info(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get system information for admin dashboard."""
|
||||
try:
|
||||
users_total_result = await db.execute(select(func.count()).select_from(User))
|
||||
users_total = users_total_result.scalar() or 0
|
||||
|
||||
subs_active_result = await db.execute(
|
||||
select(func.count(Subscription.id)).where(
|
||||
Subscription.status == SubscriptionStatus.ACTIVE.value,
|
||||
)
|
||||
)
|
||||
subscriptions_active = subs_active_result.scalar() or 0
|
||||
|
||||
return SystemInfoResponse(
|
||||
bot_version=version_service.current_version,
|
||||
python_version=sys.version.split()[0],
|
||||
uptime_seconds=int(time.time() - _start_time),
|
||||
users_total=users_total,
|
||||
subscriptions_active=subscriptions_active,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to get system info: {e}')
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to load system information',
|
||||
)
|
||||
|
||||
|
||||
@router.get('/nodes', response_model=NodesOverview)
|
||||
async def get_nodes_status(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
|
||||
@@ -14,6 +14,7 @@ from app.database.crud.tariff import (
|
||||
get_tariff_by_id,
|
||||
get_tariff_subscriptions_count,
|
||||
load_period_prices_from_db,
|
||||
reorder_tariffs,
|
||||
set_tariff_promo_groups,
|
||||
update_tariff,
|
||||
)
|
||||
@@ -29,6 +30,7 @@ from ..schemas.tariffs import (
|
||||
TariffDetailResponse,
|
||||
TariffListItem,
|
||||
TariffListResponse,
|
||||
TariffSortOrderRequest,
|
||||
TariffStatsResponse,
|
||||
TariffToggleResponse,
|
||||
TariffTrialResponse,
|
||||
@@ -157,6 +159,21 @@ async def get_available_servers(
|
||||
]
|
||||
|
||||
|
||||
@router.put('/order')
|
||||
async def update_tariff_order(
|
||||
request: TariffSortOrderRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update the display order of tariffs."""
|
||||
await reorder_tariffs(db, request.tariff_ids)
|
||||
await db.commit()
|
||||
|
||||
logger.info(f'Admin {admin.id} updated tariff order: {request.tariff_ids}')
|
||||
|
||||
return {'message': 'Tariff order updated successfully'}
|
||||
|
||||
|
||||
@router.get('/{tariff_id}', response_model=TariffDetailResponse)
|
||||
async def get_tariff(
|
||||
tariff_id: int,
|
||||
@@ -371,7 +388,7 @@ async def update_existing_tariff(
|
||||
|
||||
# Update promo groups separately
|
||||
if request.promo_group_ids is not None:
|
||||
await set_tariff_promo_groups(db, tariff_id, request.promo_group_ids)
|
||||
await set_tariff_promo_groups(db, tariff, request.promo_group_ids)
|
||||
|
||||
logger.info(f'Admin {admin.id} updated tariff {tariff_id}')
|
||||
|
||||
@@ -395,21 +412,14 @@ async def delete_existing_tariff(
|
||||
detail='Tariff not found',
|
||||
)
|
||||
|
||||
# Check if tariff has subscriptions
|
||||
subs_count = await get_tariff_subscriptions_count(db, tariff_id)
|
||||
if subs_count > 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Cannot delete tariff with {subs_count} active subscriptions',
|
||||
)
|
||||
|
||||
await delete_tariff(db, tariff)
|
||||
logger.info(f'Admin {admin.id} deleted tariff {tariff_id}: {tariff.name}')
|
||||
logger.info(f'Admin {admin.id} deleted tariff {tariff_id}: {tariff.name} (affected subscriptions: {subs_count})')
|
||||
|
||||
# Перезагружаем периоды из БД для синхронизации с ботом
|
||||
await load_period_prices_from_db(db)
|
||||
|
||||
return {'message': 'Tariff deleted successfully'}
|
||||
return {'message': 'Tariff deleted successfully', 'affected_subscriptions': subs_count}
|
||||
|
||||
|
||||
@router.post('/{tariff_id}/toggle', response_model=TariffToggleResponse)
|
||||
|
||||
+115
-110
@@ -220,12 +220,123 @@ async def get_ticket_stats(
|
||||
)
|
||||
|
||||
|
||||
@router.get('/settings', response_model=TicketSettingsResponse)
|
||||
async def get_ticket_settings(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get ticket system settings."""
|
||||
from app.services.support_settings_service import SupportSettingsService
|
||||
|
||||
return TicketSettingsResponse(
|
||||
sla_enabled=settings.SUPPORT_TICKET_SLA_ENABLED,
|
||||
sla_minutes=settings.SUPPORT_TICKET_SLA_MINUTES,
|
||||
sla_check_interval_seconds=settings.SUPPORT_TICKET_SLA_CHECK_INTERVAL_SECONDS,
|
||||
sla_reminder_cooldown_minutes=settings.SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES,
|
||||
support_system_mode=settings.get_support_system_mode(),
|
||||
cabinet_user_notifications_enabled=SupportSettingsService.get_cabinet_user_notifications_enabled(),
|
||||
cabinet_admin_notifications_enabled=SupportSettingsService.get_cabinet_admin_notifications_enabled(),
|
||||
)
|
||||
|
||||
|
||||
@router.patch('/settings', response_model=TicketSettingsResponse)
|
||||
async def update_ticket_settings(
|
||||
request: TicketSettingsUpdateRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update ticket system settings."""
|
||||
from pathlib import Path
|
||||
|
||||
from app.services.support_settings_service import SupportSettingsService
|
||||
|
||||
# Validate support_system_mode
|
||||
if request.support_system_mode is not None:
|
||||
mode = request.support_system_mode.strip().lower()
|
||||
if mode not in {'tickets', 'contact', 'both'}:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Invalid support_system_mode. Must be: tickets, contact, or both',
|
||||
)
|
||||
|
||||
# Update in-memory settings
|
||||
if request.sla_enabled is not None:
|
||||
settings.SUPPORT_TICKET_SLA_ENABLED = request.sla_enabled
|
||||
if request.sla_minutes is not None:
|
||||
settings.SUPPORT_TICKET_SLA_MINUTES = request.sla_minutes
|
||||
if request.sla_check_interval_seconds is not None:
|
||||
settings.SUPPORT_TICKET_SLA_CHECK_INTERVAL_SECONDS = request.sla_check_interval_seconds
|
||||
if request.sla_reminder_cooldown_minutes is not None:
|
||||
settings.SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES = request.sla_reminder_cooldown_minutes
|
||||
if request.support_system_mode is not None:
|
||||
settings.SUPPORT_SYSTEM_MODE = request.support_system_mode.strip().lower()
|
||||
|
||||
# Update cabinet notification settings
|
||||
if request.cabinet_user_notifications_enabled is not None:
|
||||
SupportSettingsService.set_cabinet_user_notifications_enabled(request.cabinet_user_notifications_enabled)
|
||||
if request.cabinet_admin_notifications_enabled is not None:
|
||||
SupportSettingsService.set_cabinet_admin_notifications_enabled(request.cabinet_admin_notifications_enabled)
|
||||
|
||||
# Try to persist to .env file
|
||||
try:
|
||||
env_file = Path('.env')
|
||||
if env_file.exists():
|
||||
lines = env_file.read_text().splitlines()
|
||||
updates = {}
|
||||
|
||||
if request.sla_enabled is not None:
|
||||
updates['SUPPORT_TICKET_SLA_ENABLED'] = str(request.sla_enabled).lower()
|
||||
if request.sla_minutes is not None:
|
||||
updates['SUPPORT_TICKET_SLA_MINUTES'] = str(request.sla_minutes)
|
||||
if request.sla_check_interval_seconds is not None:
|
||||
updates['SUPPORT_TICKET_SLA_CHECK_INTERVAL_SECONDS'] = str(request.sla_check_interval_seconds)
|
||||
if request.sla_reminder_cooldown_minutes is not None:
|
||||
updates['SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES'] = str(request.sla_reminder_cooldown_minutes)
|
||||
if request.support_system_mode is not None:
|
||||
updates['SUPPORT_SYSTEM_MODE'] = request.support_system_mode.strip().lower()
|
||||
|
||||
new_lines = []
|
||||
updated_keys = set()
|
||||
|
||||
for line in lines:
|
||||
updated = False
|
||||
for key, value in updates.items():
|
||||
if line.startswith(f'{key}='):
|
||||
new_lines.append(f'{key}={value}')
|
||||
updated_keys.add(key)
|
||||
updated = True
|
||||
break
|
||||
if not updated:
|
||||
new_lines.append(line)
|
||||
|
||||
# Add any keys that weren't found
|
||||
for key, value in updates.items():
|
||||
if key not in updated_keys:
|
||||
new_lines.append(f'{key}={value}')
|
||||
|
||||
env_file.write_text('\n'.join(new_lines) + '\n')
|
||||
logger.info('Updated ticket settings in .env file')
|
||||
except Exception as e:
|
||||
logger.warning(f'Failed to update .env file: {e}')
|
||||
|
||||
return TicketSettingsResponse(
|
||||
sla_enabled=settings.SUPPORT_TICKET_SLA_ENABLED,
|
||||
sla_minutes=settings.SUPPORT_TICKET_SLA_MINUTES,
|
||||
sla_check_interval_seconds=settings.SUPPORT_TICKET_SLA_CHECK_INTERVAL_SECONDS,
|
||||
sla_reminder_cooldown_minutes=settings.SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES,
|
||||
support_system_mode=settings.get_support_system_mode(),
|
||||
cabinet_user_notifications_enabled=SupportSettingsService.get_cabinet_user_notifications_enabled(),
|
||||
cabinet_admin_notifications_enabled=SupportSettingsService.get_cabinet_admin_notifications_enabled(),
|
||||
)
|
||||
|
||||
|
||||
@router.get('', response_model=AdminTicketListResponse)
|
||||
async def get_all_tickets(
|
||||
page: int = Query(1, ge=1, description='Page number'),
|
||||
per_page: int = Query(20, ge=1, le=100, description='Items per page'),
|
||||
status_filter: str | None = Query(None, alias='status', description='Filter by status'),
|
||||
priority_filter: str | None = Query(None, alias='priority', description='Filter by priority'),
|
||||
user_id: int | None = Query(None, description='Filter by user ID'),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
@@ -245,6 +356,10 @@ async def get_all_tickets(
|
||||
query = query.where(Ticket.priority == priority_filter)
|
||||
count_query = count_query.where(Ticket.priority == priority_filter)
|
||||
|
||||
if user_id:
|
||||
query = query.where(Ticket.user_id == user_id)
|
||||
count_query = count_query.where(Ticket.user_id == user_id)
|
||||
|
||||
# Get total count
|
||||
total_result = await db.execute(count_query)
|
||||
total = total_result.scalar() or 0
|
||||
@@ -490,113 +605,3 @@ async def update_ticket_priority(
|
||||
user=user_info,
|
||||
messages=messages_response,
|
||||
)
|
||||
|
||||
|
||||
@router.get('/settings', response_model=TicketSettingsResponse)
|
||||
async def get_ticket_settings(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get ticket system settings."""
|
||||
from app.services.support_settings_service import SupportSettingsService
|
||||
|
||||
return TicketSettingsResponse(
|
||||
sla_enabled=settings.SUPPORT_TICKET_SLA_ENABLED,
|
||||
sla_minutes=settings.SUPPORT_TICKET_SLA_MINUTES,
|
||||
sla_check_interval_seconds=settings.SUPPORT_TICKET_SLA_CHECK_INTERVAL_SECONDS,
|
||||
sla_reminder_cooldown_minutes=settings.SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES,
|
||||
support_system_mode=settings.get_support_system_mode(),
|
||||
cabinet_user_notifications_enabled=SupportSettingsService.get_cabinet_user_notifications_enabled(),
|
||||
cabinet_admin_notifications_enabled=SupportSettingsService.get_cabinet_admin_notifications_enabled(),
|
||||
)
|
||||
|
||||
|
||||
@router.patch('/settings', response_model=TicketSettingsResponse)
|
||||
async def update_ticket_settings(
|
||||
request: TicketSettingsUpdateRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update ticket system settings."""
|
||||
from pathlib import Path
|
||||
|
||||
from app.services.support_settings_service import SupportSettingsService
|
||||
|
||||
# Validate support_system_mode
|
||||
if request.support_system_mode is not None:
|
||||
mode = request.support_system_mode.strip().lower()
|
||||
if mode not in {'tickets', 'contact', 'both'}:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Invalid support_system_mode. Must be: tickets, contact, or both',
|
||||
)
|
||||
|
||||
# Update in-memory settings
|
||||
if request.sla_enabled is not None:
|
||||
settings.SUPPORT_TICKET_SLA_ENABLED = request.sla_enabled
|
||||
if request.sla_minutes is not None:
|
||||
settings.SUPPORT_TICKET_SLA_MINUTES = request.sla_minutes
|
||||
if request.sla_check_interval_seconds is not None:
|
||||
settings.SUPPORT_TICKET_SLA_CHECK_INTERVAL_SECONDS = request.sla_check_interval_seconds
|
||||
if request.sla_reminder_cooldown_minutes is not None:
|
||||
settings.SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES = request.sla_reminder_cooldown_minutes
|
||||
if request.support_system_mode is not None:
|
||||
settings.SUPPORT_SYSTEM_MODE = request.support_system_mode.strip().lower()
|
||||
|
||||
# Update cabinet notification settings
|
||||
if request.cabinet_user_notifications_enabled is not None:
|
||||
SupportSettingsService.set_cabinet_user_notifications_enabled(request.cabinet_user_notifications_enabled)
|
||||
if request.cabinet_admin_notifications_enabled is not None:
|
||||
SupportSettingsService.set_cabinet_admin_notifications_enabled(request.cabinet_admin_notifications_enabled)
|
||||
|
||||
# Try to persist to .env file
|
||||
try:
|
||||
env_file = Path('.env')
|
||||
if env_file.exists():
|
||||
lines = env_file.read_text().splitlines()
|
||||
updates = {}
|
||||
|
||||
if request.sla_enabled is not None:
|
||||
updates['SUPPORT_TICKET_SLA_ENABLED'] = str(request.sla_enabled).lower()
|
||||
if request.sla_minutes is not None:
|
||||
updates['SUPPORT_TICKET_SLA_MINUTES'] = str(request.sla_minutes)
|
||||
if request.sla_check_interval_seconds is not None:
|
||||
updates['SUPPORT_TICKET_SLA_CHECK_INTERVAL_SECONDS'] = str(request.sla_check_interval_seconds)
|
||||
if request.sla_reminder_cooldown_minutes is not None:
|
||||
updates['SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES'] = str(request.sla_reminder_cooldown_minutes)
|
||||
if request.support_system_mode is not None:
|
||||
updates['SUPPORT_SYSTEM_MODE'] = request.support_system_mode.strip().lower()
|
||||
|
||||
new_lines = []
|
||||
updated_keys = set()
|
||||
|
||||
for line in lines:
|
||||
updated = False
|
||||
for key, value in updates.items():
|
||||
if line.startswith(f'{key}='):
|
||||
new_lines.append(f'{key}={value}')
|
||||
updated_keys.add(key)
|
||||
updated = True
|
||||
break
|
||||
if not updated:
|
||||
new_lines.append(line)
|
||||
|
||||
# Add any keys that weren't found
|
||||
for key, value in updates.items():
|
||||
if key not in updated_keys:
|
||||
new_lines.append(f'{key}={value}')
|
||||
|
||||
env_file.write_text('\n'.join(new_lines) + '\n')
|
||||
logger.info('Updated ticket settings in .env file')
|
||||
except Exception as e:
|
||||
logger.warning(f'Failed to update .env file: {e}')
|
||||
|
||||
return TicketSettingsResponse(
|
||||
sla_enabled=settings.SUPPORT_TICKET_SLA_ENABLED,
|
||||
sla_minutes=settings.SUPPORT_TICKET_SLA_MINUTES,
|
||||
sla_check_interval_seconds=settings.SUPPORT_TICKET_SLA_CHECK_INTERVAL_SECONDS,
|
||||
sla_reminder_cooldown_minutes=settings.SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES,
|
||||
support_system_mode=settings.get_support_system_mode(),
|
||||
cabinet_user_notifications_enabled=SupportSettingsService.get_cabinet_user_notifications_enabled(),
|
||||
cabinet_admin_notifications_enabled=SupportSettingsService.get_cabinet_admin_notifications_enabled(),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,694 @@
|
||||
"""Admin routes for traffic usage statistics."""
|
||||
|
||||
import asyncio
|
||||
import csv
|
||||
import io
|
||||
import logging
|
||||
import time
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
from aiogram.types import BufferedInputFile
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import and_, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import Subscription, Transaction, TransactionType, User
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
from ..schemas.traffic import (
|
||||
ExportCsvRequest,
|
||||
ExportCsvResponse,
|
||||
TrafficEnrichmentResponse,
|
||||
TrafficNodeInfo,
|
||||
TrafficUsageResponse,
|
||||
UserTrafficEnrichment,
|
||||
UserTrafficItem,
|
||||
)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/traffic', tags=['Admin Traffic'])
|
||||
|
||||
_ALLOWED_PERIODS = frozenset({1, 3, 7, 14, 30})
|
||||
_CONCURRENCY_LIMIT = 5 # Max parallel API calls to avoid rate limiting
|
||||
|
||||
# In-memory cache: {(start_str, end_str): (timestamp, aggregated_data, nodes_info)}
|
||||
_traffic_cache: dict[tuple[str, str], tuple[float, dict[str, dict[str, int]], list[TrafficNodeInfo]]] = {}
|
||||
_CACHE_TTL = 300 # 5 minutes
|
||||
_cache_lock = asyncio.Lock()
|
||||
|
||||
# Valid sort fields for the GET endpoint
|
||||
_SORT_FIELDS = frozenset({'total_bytes', 'full_name', 'tariff_name', 'device_limit', 'traffic_limit_gb'})
|
||||
_ENRICHMENT_SORT_FIELDS = frozenset({'connected', 'total_spent', 'sub_start', 'sub_end', 'last_node'})
|
||||
|
||||
|
||||
def _get_status(sub) -> str | None:
|
||||
"""Get subscription status via actual_status property."""
|
||||
return sub.actual_status
|
||||
|
||||
|
||||
def _validate_period(period: int) -> None:
|
||||
if period not in _ALLOWED_PERIODS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Period must be one of: {sorted(_ALLOWED_PERIODS)}',
|
||||
)
|
||||
|
||||
|
||||
async def _aggregate_traffic(
|
||||
start_str: str, end_str: str, user_uuids: list[str]
|
||||
) -> tuple[dict[str, dict[str, int]], list[TrafficNodeInfo]]:
|
||||
"""Aggregate per-user traffic across all nodes for a given date range.
|
||||
|
||||
Uses legacy per-node endpoint to fetch all users' traffic per node —
|
||||
O(nodes) API calls instead of O(users). The legacy endpoint returns
|
||||
{userUuid, nodeUuid, total} per entry (non-legacy only returns topUsers
|
||||
without userUuid).
|
||||
|
||||
Returns (user_traffic, nodes_info) where:
|
||||
user_traffic = {remnawave_uuid: {node_uuid: total_bytes, ...}}
|
||||
nodes_info = [TrafficNodeInfo, ...]
|
||||
"""
|
||||
cache_key = (start_str, end_str)
|
||||
|
||||
# Quick check without lock
|
||||
now = time.time()
|
||||
cached = _traffic_cache.get(cache_key)
|
||||
if cached and (now - cached[0]) < _CACHE_TTL:
|
||||
return cached[1], cached[2]
|
||||
|
||||
# Acquire lock for the slow path
|
||||
async with _cache_lock:
|
||||
# Re-check after acquiring lock
|
||||
now = time.time()
|
||||
cached = _traffic_cache.get(cache_key)
|
||||
if cached and (now - cached[0]) < _CACHE_TTL:
|
||||
return cached[1], cached[2]
|
||||
|
||||
service = RemnaWaveService()
|
||||
if not service.is_configured:
|
||||
return {}, []
|
||||
|
||||
user_uuids_set = set(user_uuids)
|
||||
|
||||
async with service.get_api_client() as api:
|
||||
nodes = await api.get_all_nodes()
|
||||
|
||||
# Fetch per-node user stats — O(nodes) calls instead of O(users)
|
||||
semaphore = asyncio.Semaphore(_CONCURRENCY_LIMIT)
|
||||
|
||||
async def fetch_node_users(node):
|
||||
async with semaphore:
|
||||
try:
|
||||
stats = await api.get_bandwidth_stats_node_users_legacy(node.uuid, start_str, end_str)
|
||||
return node.uuid, stats
|
||||
except Exception:
|
||||
logger.warning('Failed to get traffic for node %s', node.name, exc_info=True)
|
||||
return node.uuid, None
|
||||
|
||||
results = await asyncio.gather(*(fetch_node_users(n) for n in nodes))
|
||||
|
||||
nodes_info: list[TrafficNodeInfo] = [
|
||||
TrafficNodeInfo(node_uuid=node.uuid, node_name=node.name, country_code=node.country_code) for node in nodes
|
||||
]
|
||||
nodes_info.sort(key=lambda n: n.node_name)
|
||||
|
||||
# Legacy response: [{userUuid, username, nodeUuid, total, date}, ...]
|
||||
user_traffic: dict[str, dict[str, int]] = {}
|
||||
for node_uuid, entries in results:
|
||||
if not isinstance(entries, list):
|
||||
continue
|
||||
for entry in entries:
|
||||
uid = entry.get('userUuid', '')
|
||||
total = int(entry.get('total', 0))
|
||||
if uid and total > 0 and uid in user_uuids_set:
|
||||
user_traffic.setdefault(uid, {})[node_uuid] = user_traffic.get(uid, {}).get(node_uuid, 0) + total
|
||||
|
||||
_traffic_cache[cache_key] = (now, user_traffic, nodes_info)
|
||||
|
||||
# Evict expired entries to prevent unbounded growth
|
||||
expired = [k for k, (ts, _, _) in _traffic_cache.items() if (now - ts) >= _CACHE_TTL]
|
||||
for k in expired:
|
||||
del _traffic_cache[k]
|
||||
|
||||
return user_traffic, nodes_info
|
||||
|
||||
|
||||
def _compute_date_range(period_days: int) -> tuple[str, str]:
|
||||
"""Compute ISO date-time range from period days.
|
||||
|
||||
Truncates to 5-minute intervals for stable cache keys.
|
||||
"""
|
||||
end_dt = datetime.now(UTC).replace(second=0, microsecond=0)
|
||||
end_dt = end_dt.replace(minute=(end_dt.minute // 5) * 5)
|
||||
start_dt = end_dt - timedelta(days=period_days)
|
||||
return start_dt.strftime('%Y-%m-%dT%H:%M:%SZ'), end_dt.strftime('%Y-%m-%dT%H:%M:%SZ')
|
||||
|
||||
|
||||
async def _load_user_map(db: AsyncSession) -> dict[str, User]:
|
||||
"""Load all users with remnawave_uuid, eagerly loading subscription + tariff."""
|
||||
stmt = (
|
||||
select(User)
|
||||
.where(User.remnawave_uuid.isnot(None))
|
||||
.options(selectinload(User.subscription).selectinload(Subscription.tariff))
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
users = result.scalars().all()
|
||||
return {u.remnawave_uuid: u for u in users if u.remnawave_uuid}
|
||||
|
||||
|
||||
def _build_traffic_items(
|
||||
user_traffic: dict[str, dict[str, int]],
|
||||
user_map: dict[str, User],
|
||||
nodes_info: list[TrafficNodeInfo],
|
||||
search: str = '',
|
||||
sort_by: str = 'total_bytes',
|
||||
sort_desc: bool = True,
|
||||
tariff_filter: set[str] | None = None,
|
||||
status_filter: set[str] | None = None,
|
||||
node_filter: set[str] | None = None,
|
||||
) -> list[UserTrafficItem]:
|
||||
"""Merge traffic data with user data, apply search/tariff/status/node filters, return sorted list."""
|
||||
items: list[UserTrafficItem] = []
|
||||
search_lower = search.lower().strip()
|
||||
|
||||
all_uuids = set(user_traffic.keys()) | set(user_map.keys())
|
||||
for uuid in all_uuids:
|
||||
user = user_map.get(uuid)
|
||||
if not user:
|
||||
continue
|
||||
|
||||
traffic = user_traffic.get(uuid, {})
|
||||
|
||||
full_name = user.full_name
|
||||
username = user.username
|
||||
email = user.email
|
||||
|
||||
if search_lower:
|
||||
if (
|
||||
search_lower not in (full_name or '').lower()
|
||||
and search_lower not in (username or '').lower()
|
||||
and search_lower not in (email or '').lower()
|
||||
):
|
||||
continue
|
||||
|
||||
sub = user.subscription
|
||||
tariff_name = None
|
||||
subscription_status = None
|
||||
traffic_limit_gb = 0.0
|
||||
device_limit = 1
|
||||
|
||||
if sub:
|
||||
subscription_status = _get_status(sub)
|
||||
traffic_limit_gb = float(sub.traffic_limit_gb or 0)
|
||||
device_limit = sub.device_limit or 1
|
||||
if sub.tariff:
|
||||
tariff_name = sub.tariff.name
|
||||
|
||||
if tariff_filter is not None:
|
||||
if (tariff_name or '') not in tariff_filter:
|
||||
continue
|
||||
|
||||
if status_filter is not None:
|
||||
if (subscription_status or '') not in status_filter:
|
||||
continue
|
||||
|
||||
# Apply node filter: keep only selected nodes, recalculate total
|
||||
if node_filter is not None:
|
||||
traffic = {k: v for k, v in traffic.items() if k in node_filter}
|
||||
|
||||
total_bytes = sum(traffic.values())
|
||||
|
||||
items.append(
|
||||
UserTrafficItem(
|
||||
user_id=user.id,
|
||||
telegram_id=user.telegram_id,
|
||||
username=username,
|
||||
email=email,
|
||||
full_name=full_name,
|
||||
tariff_name=tariff_name,
|
||||
subscription_status=subscription_status,
|
||||
traffic_limit_gb=traffic_limit_gb,
|
||||
device_limit=device_limit,
|
||||
node_traffic=traffic,
|
||||
total_bytes=total_bytes,
|
||||
)
|
||||
)
|
||||
|
||||
# Sort by the requested field; node columns use 'node_<uuid>' prefix
|
||||
if sort_by.startswith('node_'):
|
||||
node_uuid = sort_by[5:]
|
||||
items.sort(key=lambda x: x.node_traffic.get(node_uuid, 0), reverse=sort_desc)
|
||||
elif sort_by in ('full_name', 'tariff_name'):
|
||||
items.sort(key=lambda x: (getattr(x, sort_by, None) or '').lower(), reverse=sort_desc)
|
||||
else:
|
||||
items.sort(key=lambda x: getattr(x, sort_by, 0) or 0, reverse=sort_desc)
|
||||
|
||||
return items
|
||||
|
||||
|
||||
@router.get('', response_model=TrafficUsageResponse)
|
||||
async def get_traffic_usage(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
period: int = Query(30, ge=1, le=30),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
search: str = Query('', max_length=100),
|
||||
sort_by: str = Query('total_bytes', max_length=100),
|
||||
sort_desc: bool = Query(True),
|
||||
tariffs: str = Query('', max_length=500),
|
||||
statuses: str = Query('', max_length=500),
|
||||
nodes: str = Query('', max_length=2000),
|
||||
start_date: str = Query('', max_length=10),
|
||||
end_date: str = Query('', max_length=10),
|
||||
):
|
||||
"""Get paginated per-user traffic usage by node."""
|
||||
# Determine date range: custom dates or period-based
|
||||
if start_date.strip() and end_date.strip():
|
||||
try:
|
||||
start_dt = datetime.strptime(start_date.strip(), '%Y-%m-%d').replace(tzinfo=UTC)
|
||||
end_dt = datetime.strptime(end_date.strip(), '%Y-%m-%d').replace(tzinfo=UTC, hour=23, minute=59, second=59)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Invalid date format. Use YYYY-MM-DD.')
|
||||
|
||||
now = datetime.now(UTC)
|
||||
end_dt = min(end_dt, now)
|
||||
|
||||
if start_dt > end_dt:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='start_date must be before end_date.')
|
||||
|
||||
if (end_dt - start_dt).days > 31:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Date range cannot exceed 31 days.')
|
||||
|
||||
start_str = start_dt.strftime('%Y-%m-%dT%H:%M:%SZ')
|
||||
end_str = end_dt.strftime('%Y-%m-%dT%H:%M:%SZ')
|
||||
effective_period = (end_dt - start_dt).days or 1
|
||||
else:
|
||||
_validate_period(period)
|
||||
start_str, end_str = _compute_date_range(period)
|
||||
effective_period = period
|
||||
|
||||
user_map = await _load_user_map(db)
|
||||
user_traffic, nodes_info = await _aggregate_traffic(start_str, end_str, list(user_map.keys()))
|
||||
|
||||
# Collect all available tariff names (before filtering)
|
||||
available_tariffs = sorted(
|
||||
{
|
||||
u.subscription.tariff.name
|
||||
for u in user_map.values()
|
||||
if u.subscription and u.subscription.tariff and u.subscription.tariff.name
|
||||
}
|
||||
)
|
||||
|
||||
# Collect all available statuses (before filtering)
|
||||
available_statuses = sorted(
|
||||
{_get_status(sub) for u in user_map.values() if (sub := u.subscription) and _get_status(sub)}
|
||||
)
|
||||
|
||||
# Parse tariff filter
|
||||
tariff_filter: set[str] | None = None
|
||||
if tariffs.strip():
|
||||
tariff_filter = {t.strip() for t in tariffs.split(',') if t.strip()}
|
||||
|
||||
# Parse status filter
|
||||
status_filter: set[str] | None = None
|
||||
if statuses.strip():
|
||||
status_filter = {s.strip() for s in statuses.split(',') if s.strip()}
|
||||
|
||||
# Parse node filter
|
||||
node_filter: set[str] | None = None
|
||||
all_node_uuids = {n.node_uuid for n in nodes_info}
|
||||
if nodes.strip():
|
||||
node_filter = {n.strip() for n in nodes.split(',') if n.strip()} & all_node_uuids
|
||||
if not node_filter:
|
||||
node_filter = None # No valid nodes matched, treat as "all nodes"
|
||||
|
||||
# Validate sort_by: allow known fields + enrichment fields + 'node_<uuid>'
|
||||
is_node_sort = sort_by.startswith('node_') and sort_by[5:] in all_node_uuids
|
||||
is_enrichment_sort = sort_by in _ENRICHMENT_SORT_FIELDS
|
||||
if sort_by not in _SORT_FIELDS and not is_node_sort and not is_enrichment_sort:
|
||||
sort_by = 'total_bytes'
|
||||
|
||||
# For enrichment sort, build items unsorted then sort by enrichment field
|
||||
effective_sort = 'total_bytes' if is_enrichment_sort else sort_by
|
||||
items = _build_traffic_items(
|
||||
user_traffic, user_map, nodes_info, search, effective_sort, sort_desc, tariff_filter, status_filter, node_filter
|
||||
)
|
||||
|
||||
if is_enrichment_sort:
|
||||
enrichment_data = await _build_enrichment(db, user_map)
|
||||
enr_key_map = {
|
||||
'connected': lambda e: e.devices_connected,
|
||||
'total_spent': lambda e: e.total_spent_kopeks,
|
||||
'sub_start': lambda e: e.subscription_start_date or '',
|
||||
'sub_end': lambda e: e.subscription_end_date or '',
|
||||
'last_node': lambda e: e.last_node_name or '',
|
||||
}
|
||||
key_fn = enr_key_map[sort_by]
|
||||
empty = UserTrafficEnrichment()
|
||||
items.sort(key=lambda x: key_fn(enrichment_data.get(x.user_id, empty)), reverse=sort_desc)
|
||||
|
||||
total = len(items)
|
||||
paginated = items[offset : offset + limit]
|
||||
|
||||
return TrafficUsageResponse(
|
||||
items=paginated,
|
||||
nodes=nodes_info,
|
||||
total=total,
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
period_days=effective_period,
|
||||
available_tariffs=available_tariffs,
|
||||
available_statuses=available_statuses,
|
||||
)
|
||||
|
||||
|
||||
# ============== Enrichment endpoint ==============
|
||||
|
||||
_enrichment_cache: dict[str, tuple[float, dict[int, UserTrafficEnrichment]]] = {}
|
||||
_ENRICHMENT_CACHE_TTL = 300 # 5 minutes
|
||||
_enrichment_lock = asyncio.Lock()
|
||||
|
||||
|
||||
async def _get_bulk_spending(db: AsyncSession, user_ids: list[int]) -> dict[int, int]:
|
||||
"""Get total spent kopeks for multiple users in a single query."""
|
||||
if not user_ids:
|
||||
return {}
|
||||
result = await db.execute(
|
||||
select(Transaction.user_id, func.coalesce(func.sum(Transaction.amount_kopeks), 0))
|
||||
.where(
|
||||
and_(
|
||||
Transaction.user_id.in_(user_ids),
|
||||
Transaction.is_completed.is_(True),
|
||||
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
|
||||
)
|
||||
)
|
||||
.group_by(Transaction.user_id)
|
||||
)
|
||||
return {row[0]: int(row[1]) for row in result.all()}
|
||||
|
||||
|
||||
async def _build_enrichment(db: AsyncSession, user_map: dict[str, User]) -> dict[int, UserTrafficEnrichment]:
|
||||
"""Build enrichment data for all users: devices, spending, dates, last node."""
|
||||
uuid_to_user_id: dict[str, int] = {}
|
||||
for uuid, user in user_map.items():
|
||||
uuid_to_user_id[uuid] = user.id
|
||||
|
||||
service = RemnaWaveService()
|
||||
devices_by_user: dict[int, int] = {}
|
||||
last_node_uuid_by_user: dict[int, str] = {}
|
||||
node_uuid_to_name: dict[str, str] = {}
|
||||
|
||||
if service.is_configured:
|
||||
async with service.get_api_client() as api:
|
||||
# 3 bulk calls: nodes + users (paginated) + devices
|
||||
try:
|
||||
nodes_list = await api.get_all_nodes()
|
||||
except Exception:
|
||||
logger.warning('Failed to fetch nodes for enrichment', exc_info=True)
|
||||
nodes_list = []
|
||||
|
||||
for node in nodes_list:
|
||||
node_uuid_to_name[node.uuid] = node.name
|
||||
|
||||
# Fetch all panel users (paginated) for last connected node
|
||||
panel_users = []
|
||||
try:
|
||||
first_page = await api.get_all_users(start=0, size=500)
|
||||
panel_users.extend(first_page['users'])
|
||||
total_panel = first_page['total']
|
||||
|
||||
if total_panel > 500:
|
||||
remaining_tasks = [
|
||||
api.get_all_users(start=offset, size=500) for offset in range(500, total_panel, 500)
|
||||
]
|
||||
pages = await asyncio.gather(*remaining_tasks, return_exceptions=True)
|
||||
for page in pages:
|
||||
if isinstance(page, dict):
|
||||
panel_users.extend(page['users'])
|
||||
except Exception:
|
||||
logger.warning('Failed to fetch panel users for enrichment', exc_info=True)
|
||||
|
||||
for pu in panel_users:
|
||||
uid = uuid_to_user_id.get(pu.uuid)
|
||||
if uid is None:
|
||||
continue
|
||||
if pu.user_traffic and pu.user_traffic.last_connected_node_uuid:
|
||||
last_node_uuid_by_user[uid] = pu.user_traffic.last_connected_node_uuid
|
||||
|
||||
# Bulk device fetch — single API call (paginated with start/size)
|
||||
try:
|
||||
devices_data = await api.get_all_hwid_devices()
|
||||
for device in devices_data.get('devices', []):
|
||||
user_uuid = device.get('userUuid', '')
|
||||
uid = uuid_to_user_id.get(user_uuid)
|
||||
if uid is not None:
|
||||
devices_by_user[uid] = devices_by_user.get(uid, 0) + 1
|
||||
except Exception:
|
||||
logger.warning('Failed to fetch bulk devices for enrichment', exc_info=True)
|
||||
|
||||
# Bulk spending stats
|
||||
all_user_ids = [u.id for u in user_map.values()]
|
||||
spending_map = await _get_bulk_spending(db, all_user_ids)
|
||||
|
||||
# Build enrichment data
|
||||
enrichment: dict[int, UserTrafficEnrichment] = {}
|
||||
for uuid, user in user_map.items():
|
||||
uid = user.id
|
||||
sub = user.subscription
|
||||
|
||||
start_date = None
|
||||
end_date = None
|
||||
if sub:
|
||||
if sub.start_date:
|
||||
start_date = sub.start_date.isoformat()
|
||||
if sub.end_date:
|
||||
end_date = sub.end_date.isoformat()
|
||||
|
||||
last_node_name = None
|
||||
last_uuid = last_node_uuid_by_user.get(uid)
|
||||
if last_uuid:
|
||||
last_node_name = node_uuid_to_name.get(last_uuid)
|
||||
|
||||
enrichment[uid] = UserTrafficEnrichment(
|
||||
devices_connected=devices_by_user.get(uid, 0),
|
||||
total_spent_kopeks=spending_map.get(uid, 0),
|
||||
subscription_start_date=start_date,
|
||||
subscription_end_date=end_date,
|
||||
last_node_name=last_node_name,
|
||||
)
|
||||
|
||||
return enrichment
|
||||
|
||||
|
||||
@router.get('/enrichment', response_model=TrafficEnrichmentResponse)
|
||||
async def get_traffic_enrichment(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Return enrichment data: device counts, spending, dates, last node."""
|
||||
cache_key = 'enrichment'
|
||||
now = time.time()
|
||||
|
||||
cached = _enrichment_cache.get(cache_key)
|
||||
if cached and (now - cached[0]) < _ENRICHMENT_CACHE_TTL:
|
||||
return TrafficEnrichmentResponse(data=cached[1])
|
||||
|
||||
async with _enrichment_lock:
|
||||
now = time.time()
|
||||
cached = _enrichment_cache.get(cache_key)
|
||||
if cached and (now - cached[0]) < _ENRICHMENT_CACHE_TTL:
|
||||
return TrafficEnrichmentResponse(data=cached[1])
|
||||
|
||||
user_map = await _load_user_map(db)
|
||||
enrichment = await _build_enrichment(db, user_map)
|
||||
|
||||
_enrichment_cache[cache_key] = (now, enrichment)
|
||||
|
||||
# Evict expired
|
||||
expired = [k for k, (ts, _) in _enrichment_cache.items() if (now - ts) >= _ENRICHMENT_CACHE_TTL]
|
||||
for k in expired:
|
||||
del _enrichment_cache[k]
|
||||
|
||||
return TrafficEnrichmentResponse(data=enrichment)
|
||||
|
||||
|
||||
@router.post('/export-csv', response_model=ExportCsvResponse)
|
||||
async def export_traffic_csv(
|
||||
request: ExportCsvRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Generate CSV with traffic usage and send to admin's Telegram DM."""
|
||||
if not admin.telegram_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Admin has no Telegram ID configured',
|
||||
)
|
||||
|
||||
# Determine date range: custom dates or period-based
|
||||
if request.start_date and request.end_date:
|
||||
try:
|
||||
start_dt = datetime.strptime(request.start_date.strip(), '%Y-%m-%d').replace(tzinfo=UTC)
|
||||
end_dt = datetime.strptime(request.end_date.strip(), '%Y-%m-%d').replace(
|
||||
tzinfo=UTC, hour=23, minute=59, second=59
|
||||
)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Invalid date format. Use YYYY-MM-DD.')
|
||||
|
||||
now = datetime.now(UTC)
|
||||
end_dt = min(end_dt, now)
|
||||
if start_dt > end_dt:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='start_date must be before end_date.')
|
||||
if (end_dt - start_dt).days > 31:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Date range cannot exceed 31 days.')
|
||||
|
||||
start_str = start_dt.strftime('%Y-%m-%dT%H:%M:%SZ')
|
||||
end_str = end_dt.strftime('%Y-%m-%dT%H:%M:%SZ')
|
||||
period_label = f'{request.start_date}_{request.end_date}'
|
||||
else:
|
||||
_validate_period(request.period)
|
||||
start_str, end_str = _compute_date_range(request.period)
|
||||
period_label = f'{request.period}d'
|
||||
|
||||
user_map = await _load_user_map(db)
|
||||
user_traffic, nodes_info = await _aggregate_traffic(start_str, end_str, list(user_map.keys()))
|
||||
enrichment = await _build_enrichment(db, user_map)
|
||||
|
||||
# Parse filters
|
||||
tariff_filter: set[str] | None = None
|
||||
if request.tariffs and request.tariffs.strip():
|
||||
tariff_filter = {t.strip() for t in request.tariffs.split(',') if t.strip()}
|
||||
|
||||
status_filter: set[str] | None = None
|
||||
if request.statuses and request.statuses.strip():
|
||||
status_filter = {s.strip() for s in request.statuses.split(',') if s.strip()}
|
||||
|
||||
node_filter: set[str] | None = None
|
||||
all_node_uuids = {n.node_uuid for n in nodes_info}
|
||||
if request.nodes and request.nodes.strip():
|
||||
node_filter = {n.strip() for n in request.nodes.split(',') if n.strip()} & all_node_uuids
|
||||
if not node_filter:
|
||||
node_filter = None
|
||||
|
||||
items = _build_traffic_items(
|
||||
user_traffic,
|
||||
user_map,
|
||||
nodes_info,
|
||||
sort_by='total_bytes',
|
||||
sort_desc=True,
|
||||
tariff_filter=tariff_filter,
|
||||
status_filter=status_filter,
|
||||
node_filter=node_filter,
|
||||
)
|
||||
|
||||
# Determine which nodes to include in CSV columns
|
||||
csv_nodes = [n for n in nodes_info if n.node_uuid in node_filter] if node_filter else nodes_info
|
||||
|
||||
# Compute period days for risk calculation
|
||||
if request.start_date and request.end_date:
|
||||
period_days = max((end_dt - start_dt).days, 1)
|
||||
else:
|
||||
period_days = request.period
|
||||
|
||||
total_thr = request.total_threshold_gb or 0
|
||||
node_thr = request.node_threshold_gb or 0
|
||||
has_risk = total_thr > 0 or node_thr > 0
|
||||
|
||||
# Build CSV rows
|
||||
rows: list[dict] = []
|
||||
for item in items:
|
||||
row: dict = {
|
||||
'User ID': item.user_id,
|
||||
'Telegram ID': item.telegram_id or '',
|
||||
'Username': item.username or '',
|
||||
'Email': item.email or '',
|
||||
'Full Name': item.full_name,
|
||||
'Tariff': item.tariff_name or '',
|
||||
'Status': item.subscription_status or '',
|
||||
'Traffic Limit (GB)': item.traffic_limit_gb,
|
||||
'Device Limit': item.device_limit,
|
||||
}
|
||||
# Enrichment columns
|
||||
enr = enrichment.get(item.user_id)
|
||||
row['Connected Devices'] = enr.devices_connected if enr else 0
|
||||
row['Total Spent (RUB)'] = round(enr.total_spent_kopeks / 100, 2) if enr else 0
|
||||
row['Sub Start'] = enr.subscription_start_date or '' if enr else ''
|
||||
row['Sub End'] = enr.subscription_end_date or '' if enr else ''
|
||||
row['Last Node'] = enr.last_node_name or '' if enr else ''
|
||||
|
||||
for node in csv_nodes:
|
||||
row[f'{node.node_name} (bytes)'] = item.node_traffic.get(node.node_uuid, 0)
|
||||
row['Total (bytes)'] = item.total_bytes
|
||||
row['Total (GB)'] = round(item.total_bytes / (1024**3), 2) if item.total_bytes else 0
|
||||
|
||||
if has_risk:
|
||||
daily_total = item.total_bytes / period_days / (1024**3) if period_days > 0 else 0
|
||||
row['Total GB/day'] = round(daily_total, 4)
|
||||
|
||||
total_ratio = daily_total / total_thr if total_thr > 0 else 0
|
||||
|
||||
max_node_ratio = 0.0
|
||||
worst_node_daily = 0.0
|
||||
for node_bytes in item.node_traffic.values():
|
||||
if node_bytes > 0 and node_thr > 0:
|
||||
daily_node = node_bytes / period_days / (1024**3) if period_days > 0 else 0
|
||||
ratio = daily_node / node_thr
|
||||
if ratio > max_node_ratio:
|
||||
max_node_ratio = ratio
|
||||
worst_node_daily = daily_node
|
||||
|
||||
ratio = max(total_ratio, max_node_ratio)
|
||||
if ratio < 0.5:
|
||||
risk_level = 'low'
|
||||
elif ratio < 0.8:
|
||||
risk_level = 'medium'
|
||||
elif ratio < 1.2:
|
||||
risk_level = 'high'
|
||||
else:
|
||||
risk_level = 'critical'
|
||||
|
||||
row['Risk Level'] = risk_level
|
||||
row['Risk Ratio'] = round(ratio, 3)
|
||||
row['Risk GB/day'] = round(daily_total if total_ratio >= max_node_ratio else worst_node_daily, 4)
|
||||
|
||||
rows.append(row)
|
||||
|
||||
# Generate CSV
|
||||
output = io.StringIO()
|
||||
if rows:
|
||||
writer = csv.DictWriter(output, fieldnames=rows[0].keys())
|
||||
writer.writeheader()
|
||||
writer.writerows(rows)
|
||||
csv_bytes = output.getvalue().encode('utf-8-sig')
|
||||
|
||||
timestamp = datetime.now(UTC).strftime('%Y%m%d_%H%M%S')
|
||||
filename = f'traffic_usage_{period_label}_{timestamp}.csv'
|
||||
|
||||
try:
|
||||
bot = Bot(
|
||||
token=settings.BOT_TOKEN,
|
||||
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
|
||||
)
|
||||
async with bot:
|
||||
await bot.send_document(
|
||||
chat_id=admin.telegram_id,
|
||||
document=BufferedInputFile(csv_bytes, filename=filename),
|
||||
caption=f'Traffic usage report ({period_label})\nUsers: {len(rows)}',
|
||||
)
|
||||
except Exception:
|
||||
logger.error('Failed to send CSV to admin %s', admin.telegram_id, exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to send CSV report. Please try again later.',
|
||||
)
|
||||
|
||||
return ExportCsvResponse(success=True, message=f'CSV sent ({len(rows)} users)')
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Admin routes for version and release information."""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import aiohttp
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.database.models import User
|
||||
from app.services.version_service import version_service
|
||||
|
||||
from ..dependencies import get_current_admin_user
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/updates', tags=['Cabinet Admin Updates'])
|
||||
|
||||
|
||||
# ============ Schemas ============
|
||||
|
||||
|
||||
class ReleaseItem(BaseModel):
|
||||
tag_name: str
|
||||
name: str
|
||||
body: str
|
||||
published_at: str
|
||||
prerelease: bool
|
||||
|
||||
|
||||
class ProjectReleasesInfo(BaseModel):
|
||||
current_version: str
|
||||
has_updates: bool
|
||||
releases: list[ReleaseItem]
|
||||
repo_url: str
|
||||
|
||||
|
||||
class ReleasesResponse(BaseModel):
|
||||
bot: ProjectReleasesInfo
|
||||
cabinet: ProjectReleasesInfo
|
||||
|
||||
|
||||
# ============ Cabinet releases cache ============
|
||||
|
||||
CABINET_REPO = 'BEDOLAGA-DEV/bedolaga-cabinet'
|
||||
_cabinet_cache: dict = {}
|
||||
_cabinet_last_check: datetime | None = None
|
||||
_CACHE_TTL = 3600
|
||||
|
||||
|
||||
async def _fetch_cabinet_releases(force: bool = False) -> list[dict]:
|
||||
global _cabinet_last_check
|
||||
|
||||
if not force and _cabinet_cache.get('releases') and _cabinet_last_check:
|
||||
if datetime.now() - _cabinet_last_check < timedelta(seconds=_CACHE_TTL):
|
||||
return _cabinet_cache['releases']
|
||||
|
||||
url = f'https://api.github.com/repos/{CABINET_REPO}/releases'
|
||||
|
||||
try:
|
||||
timeout = aiohttp.ClientTimeout(total=10)
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session, session.get(url) as response:
|
||||
if response.status == 200:
|
||||
data = await response.json()
|
||||
releases = []
|
||||
for item in data[:20]:
|
||||
releases.append(
|
||||
{
|
||||
'tag_name': item['tag_name'],
|
||||
'name': item.get('name') or item['tag_name'],
|
||||
'body': item.get('body') or '',
|
||||
'published_at': item['published_at'],
|
||||
'prerelease': item.get('prerelease', False),
|
||||
}
|
||||
)
|
||||
_cabinet_cache['releases'] = releases
|
||||
_cabinet_last_check = datetime.now()
|
||||
logger.info('Fetched %d cabinet releases from GitHub', len(releases))
|
||||
return releases
|
||||
logger.warning('GitHub API returned status %d for cabinet releases', response.status)
|
||||
return _cabinet_cache.get('releases', [])
|
||||
except TimeoutError:
|
||||
logger.warning('Timeout fetching cabinet releases from GitHub')
|
||||
return _cabinet_cache.get('releases', [])
|
||||
except Exception as e:
|
||||
logger.error('Error fetching cabinet releases: %s', e)
|
||||
return _cabinet_cache.get('releases', [])
|
||||
|
||||
|
||||
# ============ Routes ============
|
||||
|
||||
|
||||
@router.get('/releases', response_model=ReleasesResponse)
|
||||
async def get_releases(
|
||||
current_user: User = Depends(get_current_admin_user),
|
||||
) -> ReleasesResponse:
|
||||
"""Get release information for bot and cabinet."""
|
||||
# Bot releases
|
||||
bot_releases_raw = await version_service._fetch_releases()
|
||||
has_updates, _ = await version_service.check_for_updates()
|
||||
|
||||
bot_releases = [
|
||||
ReleaseItem(
|
||||
tag_name=r.tag_name,
|
||||
name=r.name,
|
||||
body=r.full_description,
|
||||
published_at=r.published_at.isoformat(),
|
||||
prerelease=r.prerelease,
|
||||
)
|
||||
for r in bot_releases_raw[:10]
|
||||
]
|
||||
|
||||
bot_info = ProjectReleasesInfo(
|
||||
current_version=version_service.current_version,
|
||||
has_updates=has_updates,
|
||||
releases=bot_releases,
|
||||
repo_url=f'https://github.com/{version_service.repo}',
|
||||
)
|
||||
|
||||
# Cabinet releases
|
||||
cabinet_releases_raw = await _fetch_cabinet_releases()
|
||||
cabinet_releases = [ReleaseItem(**r) for r in cabinet_releases_raw[:10]]
|
||||
|
||||
# Current version = latest non-prerelease tag
|
||||
cabinet_current = ''
|
||||
for r in cabinet_releases_raw:
|
||||
if not r.get('prerelease', False):
|
||||
cabinet_current = r['tag_name']
|
||||
break
|
||||
|
||||
cabinet_info = ProjectReleasesInfo(
|
||||
current_version=cabinet_current,
|
||||
has_updates=False,
|
||||
releases=cabinet_releases,
|
||||
repo_url=f'https://github.com/{CABINET_REPO}',
|
||||
)
|
||||
|
||||
return ReleasesResponse(bot=bot_info, cabinet=cabinet_info)
|
||||
File diff suppressed because it is too large
Load Diff
+190
-17
@@ -11,14 +11,20 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.user import (
|
||||
clear_email_change_pending,
|
||||
create_user,
|
||||
create_user_by_email,
|
||||
get_user_by_id,
|
||||
get_user_by_referral_code,
|
||||
get_user_by_telegram_id,
|
||||
is_email_taken,
|
||||
set_email_change_pending,
|
||||
verify_and_apply_email_change,
|
||||
)
|
||||
from app.database.models import CabinetRefreshToken, User
|
||||
from app.services.disposable_email_service import disposable_email_service
|
||||
from app.services.referral_service import process_referral_registration
|
||||
from app.utils.timezone import panel_datetime_to_naive_utc
|
||||
|
||||
from ..auth import (
|
||||
create_access_token,
|
||||
@@ -30,8 +36,10 @@ from ..auth import (
|
||||
verify_password,
|
||||
)
|
||||
from ..auth.email_verification import (
|
||||
generate_email_change_code,
|
||||
generate_password_reset_token,
|
||||
generate_verification_token,
|
||||
get_email_change_expires_at,
|
||||
get_password_reset_expires_at,
|
||||
get_verification_expires_at,
|
||||
is_token_expired,
|
||||
@@ -40,6 +48,9 @@ from ..auth.jwt_handler import get_refresh_token_expires_at
|
||||
from ..dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
from ..schemas.auth import (
|
||||
AuthResponse,
|
||||
EmailChangeRequest,
|
||||
EmailChangeResponse,
|
||||
EmailChangeVerifyRequest,
|
||||
EmailLoginRequest,
|
||||
EmailRegisterRequest,
|
||||
EmailRegisterStandaloneRequest,
|
||||
@@ -162,8 +173,8 @@ async def _sync_subscription_from_panel_by_email(db: AsyncSession, user: User) -
|
||||
|
||||
existing_sub = await get_subscription_by_user_id(db, user.id)
|
||||
|
||||
# Parse panel data
|
||||
expire_at = panel_user.expire_at
|
||||
# Parse panel data — panel returns local time with misleading +00:00 offset
|
||||
expire_at = panel_datetime_to_naive_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
|
||||
|
||||
@@ -173,11 +184,8 @@ async def _sync_subscription_from_panel_by_email(db: AsyncSession, user: User) -
|
||||
# Device limit from panel
|
||||
device_limit = panel_user.hwid_device_limit or 1
|
||||
|
||||
# Determine status - use timezone-aware datetime for comparison
|
||||
current_time = datetime.now(UTC)
|
||||
# Make expire_at timezone-aware if it's naive
|
||||
if expire_at.tzinfo is None:
|
||||
expire_at = expire_at.replace(tzinfo=UTC)
|
||||
# Determine status — expire_at is now naive UTC
|
||||
current_time = datetime.now(UTC).replace(tzinfo=None)
|
||||
|
||||
if panel_user.status.value == 'ACTIVE' and expire_at > current_time:
|
||||
sub_status = SubscriptionStatus.ACTIVE
|
||||
@@ -187,10 +195,8 @@ async def _sync_subscription_from_panel_by_email(db: AsyncSession, user: User) -
|
||||
sub_status = SubscriptionStatus.DISABLED
|
||||
|
||||
if existing_sub:
|
||||
# Update existing subscription
|
||||
# Convert to naive datetime for database storage
|
||||
end_date_naive = expire_at.replace(tzinfo=None) if expire_at.tzinfo else expire_at
|
||||
existing_sub.end_date = end_date_naive
|
||||
# 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
|
||||
@@ -204,14 +210,11 @@ async def _sync_subscription_from_panel_by_email(db: AsyncSession, user: User) -
|
||||
f'Updated subscription for email user {user.email}, squads: {connected_squads}, devices: {device_limit}'
|
||||
)
|
||||
else:
|
||||
# Create new subscription
|
||||
# Convert current_time to naive for database storage if needed
|
||||
start_date_naive = current_time.replace(tzinfo=None)
|
||||
end_date_naive = expire_at.replace(tzinfo=None) if expire_at.tzinfo else expire_at
|
||||
# Create new subscription (expire_at and current_time already naive UTC)
|
||||
new_sub = Subscription(
|
||||
user_id=user.id,
|
||||
start_date=start_date_naive,
|
||||
end_date=end_date_naive,
|
||||
start_date=current_time,
|
||||
end_date=expire_at,
|
||||
traffic_limit_gb=traffic_limit_gb,
|
||||
traffic_used_gb=traffic_used_gb,
|
||||
status=sub_status.value,
|
||||
@@ -383,6 +386,13 @@ async def register_email(
|
||||
Requires valid JWT token from Telegram authentication.
|
||||
Sends verification email to the provided address.
|
||||
"""
|
||||
# Check for disposable email
|
||||
if disposable_email_service.is_disposable(request.email):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Disposable email addresses are not allowed',
|
||||
)
|
||||
|
||||
# Check if email already exists
|
||||
existing_user = await db.execute(select(User).where(User.email == request.email))
|
||||
if existing_user.scalar_one_or_none():
|
||||
@@ -476,6 +486,13 @@ async def register_email_standalone(
|
||||
)
|
||||
logger.info(f'Test email registration: {request.email}')
|
||||
|
||||
# Check for disposable email
|
||||
if disposable_email_service.is_disposable(request.email):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Disposable email addresses are not allowed',
|
||||
)
|
||||
|
||||
# Проверить что email не занят
|
||||
existing = await db.execute(select(User).where(User.email == request.email))
|
||||
if existing.scalar_one_or_none():
|
||||
@@ -941,3 +958,159 @@ async def check_is_admin(
|
||||
"""Check if current user is an admin."""
|
||||
is_admin = settings.is_admin(telegram_id=user.telegram_id, email=user.email if user.email_verified else None)
|
||||
return {'is_admin': is_admin}
|
||||
|
||||
|
||||
@router.post('/email/change', response_model=EmailChangeResponse)
|
||||
async def request_email_change(
|
||||
request: EmailChangeRequest,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""
|
||||
Request email change.
|
||||
|
||||
Sends a 6-digit verification code to the new email address.
|
||||
User must have a verified email to change it.
|
||||
"""
|
||||
# Check if user has a verified email
|
||||
if not user.email or not user.email_verified:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='You must have a verified email to change it',
|
||||
)
|
||||
|
||||
# Check if new email is the same as current
|
||||
if request.new_email.lower() == user.email.lower():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='New email is the same as current email',
|
||||
)
|
||||
|
||||
# Check for disposable email
|
||||
if disposable_email_service.is_disposable(request.new_email):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Disposable email addresses are not allowed',
|
||||
)
|
||||
|
||||
# Check if new email is already taken
|
||||
if await is_email_taken(db, request.new_email, exclude_user_id=user.id):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='This email is already registered',
|
||||
)
|
||||
|
||||
# Generate verification code
|
||||
code = generate_email_change_code()
|
||||
expires_at = get_email_change_expires_at()
|
||||
expire_minutes = settings.get_cabinet_email_change_code_expire_minutes()
|
||||
|
||||
# Save pending email change
|
||||
await set_email_change_pending(db, user, request.new_email, code, expires_at)
|
||||
|
||||
# Send verification email to new address
|
||||
if email_service.is_configured():
|
||||
lang = user.language or 'ru'
|
||||
|
||||
# Check for admin template override
|
||||
override = await get_rendered_override(
|
||||
'email_change_code',
|
||||
lang,
|
||||
context={
|
||||
'username': user.first_name or '',
|
||||
'code': code,
|
||||
'expire_minutes': str(expire_minutes),
|
||||
},
|
||||
db=db,
|
||||
)
|
||||
custom_subject, custom_body = override if override else (None, None)
|
||||
|
||||
await asyncio.to_thread(
|
||||
email_service.send_email_change_code,
|
||||
to_email=request.new_email,
|
||||
code=code,
|
||||
username=user.first_name,
|
||||
language=lang,
|
||||
custom_subject=custom_subject,
|
||||
custom_body_html=custom_body,
|
||||
)
|
||||
else:
|
||||
# Clear pending change if email service is not configured
|
||||
await clear_email_change_pending(db, user)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail='Email service is not configured',
|
||||
)
|
||||
|
||||
logger.info(f'Email change requested for user {user.id}: {user.email} -> {request.new_email}')
|
||||
|
||||
return EmailChangeResponse(
|
||||
message='Verification code sent to new email',
|
||||
new_email=request.new_email,
|
||||
expires_in_minutes=expire_minutes,
|
||||
)
|
||||
|
||||
|
||||
@router.post('/email/change/verify')
|
||||
async def verify_email_change(
|
||||
request: EmailChangeVerifyRequest,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""
|
||||
Verify email change with code.
|
||||
|
||||
Completes the email change process if the code is valid.
|
||||
"""
|
||||
success, message = await verify_and_apply_email_change(db, user, request.code)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=message,
|
||||
)
|
||||
|
||||
return {
|
||||
'message': message,
|
||||
'new_email': user.email,
|
||||
}
|
||||
|
||||
|
||||
@router.post('/email/change/cancel')
|
||||
async def cancel_email_change(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""
|
||||
Cancel pending email change.
|
||||
"""
|
||||
if not user.email_change_new:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='No pending email change',
|
||||
)
|
||||
|
||||
await clear_email_change_pending(db, user)
|
||||
|
||||
return {'message': 'Email change cancelled'}
|
||||
|
||||
|
||||
@router.get('/email/change/status')
|
||||
async def get_email_change_status(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
):
|
||||
"""
|
||||
Get pending email change status.
|
||||
"""
|
||||
if not user.email_change_new:
|
||||
return {
|
||||
'pending': False,
|
||||
'new_email': None,
|
||||
'expires_at': None,
|
||||
}
|
||||
|
||||
return {
|
||||
'pending': True,
|
||||
'new_email': user.email_change_new,
|
||||
'expires_at': user.email_change_expires.isoformat() if user.email_change_expires else None,
|
||||
}
|
||||
|
||||
+117
-141
@@ -13,6 +13,7 @@ from app.config import settings
|
||||
from app.database.crud.user import get_user_by_id
|
||||
from app.database.models import PaymentMethod, Transaction, User
|
||||
from app.external.cryptobot import CryptoBotService
|
||||
from app.services.payment_method_config_service import get_enabled_methods_for_user
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.services.payment_verification_service import (
|
||||
SUPPORTED_MANUAL_CHECK_METHODS,
|
||||
@@ -22,7 +23,6 @@ from app.services.payment_verification_service import (
|
||||
method_display_name,
|
||||
run_manual_check,
|
||||
)
|
||||
from app.services.yookassa_service import YooKassaService
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
from ..schemas.balance import (
|
||||
@@ -127,134 +127,82 @@ async def get_transactions(
|
||||
)
|
||||
|
||||
|
||||
async def _get_available_payment_methods(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
) -> list[PaymentMethodResponse]:
|
||||
"""Get available payment methods filtered by DB config and user context.
|
||||
@router.get('/payment-methods', response_model=list[PaymentMethodResponse])
|
||||
async def get_payment_methods(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get available payment methods for the current user.
|
||||
|
||||
Combines env-var availability with DB-based admin config (ordering, display conditions).
|
||||
Uses PaymentMethodConfig from database for:
|
||||
- Sort order (sort_order)
|
||||
- Enabled/disabled status (is_enabled)
|
||||
- Display names (display_name with fallback to env)
|
||||
- Min/max amounts (with fallback to env defaults)
|
||||
- Sub-options filtering (sub_options)
|
||||
- User filters (user_type_filter, first_topup_filter, promo_group_filter)
|
||||
"""
|
||||
from app.services.payment_method_config_service import (
|
||||
_get_method_defaults,
|
||||
get_all_configs,
|
||||
# Check if this is user's first topup
|
||||
from sqlalchemy import exists
|
||||
|
||||
has_completed_topup = await db.execute(
|
||||
select(
|
||||
exists().where(
|
||||
Transaction.user_id == user.id,
|
||||
Transaction.type == 'deposit',
|
||||
Transaction.is_completed == True,
|
||||
)
|
||||
)
|
||||
)
|
||||
is_first_topup = not has_completed_topup.scalar()
|
||||
|
||||
configs = await get_all_configs(db)
|
||||
defaults = _get_method_defaults()
|
||||
# Get enabled methods from database config
|
||||
enabled_methods = await get_enabled_methods_for_user(db, user=user, is_first_topup=is_first_topup)
|
||||
|
||||
# Provider availability checks from env vars
|
||||
provider_enabled = {
|
||||
'telegram_stars': settings.TELEGRAM_STARS_ENABLED,
|
||||
'tribute': settings.TRIBUTE_ENABLED and bool(getattr(settings, 'TRIBUTE_DONATE_LINK', '')),
|
||||
'cryptobot': settings.is_cryptobot_enabled(),
|
||||
'heleket': settings.is_heleket_enabled(),
|
||||
'yookassa': settings.is_yookassa_enabled(),
|
||||
'mulenpay': settings.is_mulenpay_enabled(),
|
||||
'pal24': settings.is_pal24_enabled(),
|
||||
'platega': settings.is_platega_enabled(),
|
||||
'wata': settings.is_wata_enabled(),
|
||||
'freekassa': settings.is_freekassa_enabled(),
|
||||
'cloudpayments': settings.is_cloudpayments_enabled(),
|
||||
}
|
||||
# Build response with additional options formatting
|
||||
methods = []
|
||||
for method_data in enabled_methods:
|
||||
method_id = method_data['id']
|
||||
|
||||
# Default options builder (for methods with sub-options)
|
||||
def _build_options(method_id: str, config_sub_options: dict | None) -> list[dict] | None:
|
||||
if method_id == 'yookassa':
|
||||
all_opts = [
|
||||
{'id': 'card', 'name': '💳 Карта', 'description': 'Банковская карта'},
|
||||
{'id': 'sbp', 'name': '🏦 СБП', 'description': 'Система быстрых платежей (QR)'},
|
||||
]
|
||||
elif method_id == 'pal24':
|
||||
all_opts = [
|
||||
{'id': 'sbp', 'name': '🏦 СБП', 'description': 'Система быстрых платежей'},
|
||||
{'id': 'card', 'name': '💳 Карта', 'description': 'Банковская карта'},
|
||||
]
|
||||
elif method_id == 'platega':
|
||||
platega_methods = settings.get_platega_active_methods()
|
||||
definitions = settings.get_platega_method_definitions()
|
||||
all_opts = []
|
||||
for method_code in platega_methods:
|
||||
info = definitions.get(method_code, {})
|
||||
all_opts.append(
|
||||
# Format options with descriptions for specific methods
|
||||
options = method_data.get('options')
|
||||
if options:
|
||||
formatted_options = []
|
||||
for opt in options:
|
||||
opt_id = opt['id']
|
||||
opt_name = opt.get('name', opt_id)
|
||||
description = ''
|
||||
|
||||
# Add descriptions based on method and option
|
||||
if method_id in ('yookassa', 'pal24', 'cloudpayments', 'freekassa'):
|
||||
if opt_id == 'card':
|
||||
opt_name = f'💳 {opt_name}'
|
||||
description = 'Банковская карта'
|
||||
elif opt_id == 'sbp':
|
||||
opt_name = f'🏦 {opt_name}'
|
||||
description = 'Система быстрых платежей'
|
||||
elif method_id == 'platega':
|
||||
# Platega options already have descriptions from config
|
||||
definitions = settings.get_platega_method_definitions()
|
||||
info = definitions.get(int(opt_id), {}) if opt_id.isdigit() else {}
|
||||
description = info.get('description') or info.get('name') or ''
|
||||
|
||||
formatted_options.append(
|
||||
{
|
||||
'id': str(method_code),
|
||||
'name': info.get('title') or info.get('name') or f'Platega {method_code}',
|
||||
'description': info.get('description') or info.get('name') or '',
|
||||
'id': opt_id,
|
||||
'name': opt_name,
|
||||
'description': description,
|
||||
}
|
||||
)
|
||||
elif method_id == 'freekassa':
|
||||
all_opts = [
|
||||
{'id': 'sbp', 'name': '🏦 NSPK СБП', 'description': 'Система быстрых платежей'},
|
||||
{'id': 'card', 'name': '💳 Карта', 'description': 'Банковская карта'},
|
||||
]
|
||||
elif method_id == 'cloudpayments':
|
||||
all_opts = [
|
||||
{'id': 'card', 'name': '💳 Карта', 'description': 'Банковская карта'},
|
||||
{'id': 'sbp', 'name': '🏦 СБП', 'description': 'Система быстрых платежей'},
|
||||
]
|
||||
else:
|
||||
return None
|
||||
|
||||
if not all_opts:
|
||||
return None
|
||||
|
||||
# Filter by sub_options config from DB
|
||||
if config_sub_options:
|
||||
all_opts = [o for o in all_opts if config_sub_options.get(o['id'], True)]
|
||||
|
||||
return all_opts if all_opts else None
|
||||
|
||||
# User promo group IDs for filtering
|
||||
user_promo_group_ids: set[int] = set()
|
||||
if hasattr(user, 'user_promo_groups') and user.user_promo_groups:
|
||||
for upg in user.user_promo_groups:
|
||||
user_promo_group_ids.add(upg.promo_group_id)
|
||||
if hasattr(user, 'promo_group_id') and user.promo_group_id:
|
||||
user_promo_group_ids.add(user.promo_group_id)
|
||||
|
||||
methods = []
|
||||
for config in configs:
|
||||
mid = config.method_id
|
||||
|
||||
# 1. Check env-var provider availability AND DB admin toggle
|
||||
if not provider_enabled.get(mid, False):
|
||||
continue
|
||||
if not config.is_enabled:
|
||||
continue
|
||||
|
||||
# 2. Check user type filter
|
||||
if config.user_type_filter == 'telegram' and user.auth_type != 'telegram':
|
||||
continue
|
||||
if config.user_type_filter == 'email' and user.auth_type != 'email':
|
||||
continue
|
||||
|
||||
# 3. Check first topup filter
|
||||
if config.first_topup_filter == 'yes' and not user.has_made_first_topup:
|
||||
continue
|
||||
if config.first_topup_filter == 'no' and user.has_made_first_topup:
|
||||
continue
|
||||
|
||||
# 4. Check promo group filter
|
||||
if config.promo_group_filter_mode == 'selected' and config.allowed_promo_groups:
|
||||
allowed_ids = {pg.id for pg in config.allowed_promo_groups}
|
||||
if not user_promo_group_ids.intersection(allowed_ids):
|
||||
continue
|
||||
|
||||
# Build the response
|
||||
method_def = defaults.get(mid, {})
|
||||
display_name = config.display_name or method_def.get('default_display_name', mid)
|
||||
min_amount = config.min_amount_kopeks or method_def.get('default_min', 1000)
|
||||
max_amount = config.max_amount_kopeks or method_def.get('default_max', 10000000)
|
||||
options = _build_options(mid, config.sub_options)
|
||||
options = formatted_options if formatted_options else None
|
||||
|
||||
methods.append(
|
||||
PaymentMethodResponse(
|
||||
id=mid,
|
||||
name=display_name,
|
||||
id=method_id,
|
||||
name=method_data['name'],
|
||||
description=None,
|
||||
min_amount_kopeks=min_amount,
|
||||
max_amount_kopeks=max_amount,
|
||||
min_amount_kopeks=method_data['min_amount_kopeks'],
|
||||
max_amount_kopeks=method_data['max_amount_kopeks'],
|
||||
is_available=True,
|
||||
options=options,
|
||||
)
|
||||
@@ -263,15 +211,6 @@ async def _get_available_payment_methods(
|
||||
return methods
|
||||
|
||||
|
||||
@router.get('/payment-methods', response_model=list[PaymentMethodResponse])
|
||||
async def get_payment_methods(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get available payment methods."""
|
||||
return await _get_available_payment_methods(db, user)
|
||||
|
||||
|
||||
@router.post('/stars-invoice', response_model=StarsInvoiceResponse)
|
||||
async def create_stars_invoice(
|
||||
request: StarsInvoiceRequest,
|
||||
@@ -373,7 +312,7 @@ async def create_topup(
|
||||
):
|
||||
"""Create payment for balance top-up."""
|
||||
# Validate payment method
|
||||
methods = await _get_available_payment_methods(db, user)
|
||||
methods = await get_payment_methods(user=user, db=db)
|
||||
method = next((m for m in methods if m.id == request.payment_method), None)
|
||||
|
||||
if not method or not method.is_available:
|
||||
@@ -401,13 +340,11 @@ async def create_topup(
|
||||
|
||||
try:
|
||||
if request.payment_method == 'yookassa':
|
||||
yookassa_service = YooKassaService()
|
||||
payment_service = PaymentService()
|
||||
yookassa_metadata = {
|
||||
'user_id': str(user.id),
|
||||
'user_telegram_id': str(user.telegram_id) if user.telegram_id else '',
|
||||
'user_username': user.username or '',
|
||||
'amount_kopeks': str(request.amount_kopeks),
|
||||
'type': 'balance_topup',
|
||||
'purpose': 'balance_topup',
|
||||
'source': 'cabinet',
|
||||
}
|
||||
|
||||
@@ -418,25 +355,25 @@ async def create_topup(
|
||||
request.amount_kopeks, telegram_user_id=user.telegram_id
|
||||
)
|
||||
if option == 'sbp':
|
||||
# Create SBP payment with QR code
|
||||
result = await yookassa_service.create_sbp_payment(
|
||||
amount=amount_rubles,
|
||||
currency='RUB',
|
||||
result = await payment_service.create_yookassa_sbp_payment(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
description=description,
|
||||
metadata=yookassa_metadata,
|
||||
)
|
||||
else:
|
||||
# Default: card payment
|
||||
result = await yookassa_service.create_payment(
|
||||
amount=amount_rubles,
|
||||
currency='RUB',
|
||||
result = await payment_service.create_yookassa_payment(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
description=description,
|
||||
metadata=yookassa_metadata,
|
||||
)
|
||||
|
||||
if result and not result.get('error'):
|
||||
if result:
|
||||
payment_url = result.get('confirmation_url')
|
||||
payment_id = result.get('id')
|
||||
payment_id = result.get('yookassa_payment_id')
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
@@ -700,6 +637,32 @@ async def create_topup(
|
||||
detail='Failed to create FreeKassa payment',
|
||||
)
|
||||
|
||||
elif request.payment_method == 'kassa_ai':
|
||||
if not settings.is_kassa_ai_enabled():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='KassaAI payment method is unavailable',
|
||||
)
|
||||
|
||||
payment_service = PaymentService()
|
||||
result = await payment_service.create_kassa_ai_payment(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
description=settings.get_balance_payment_description(request.amount_kopeks),
|
||||
email=getattr(user, 'email', None),
|
||||
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
|
||||
)
|
||||
|
||||
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 KassaAI payment',
|
||||
)
|
||||
|
||||
elif request.payment_method == 'tribute':
|
||||
if not settings.TRIBUTE_ENABLED or not settings.TRIBUTE_DONATE_LINK:
|
||||
raise HTTPException(
|
||||
@@ -840,6 +803,17 @@ def _get_status_info(record: PendingPayment) -> tuple[str, str]:
|
||||
}
|
||||
return mapping.get(status, ('❓', 'Неизвестно'))
|
||||
|
||||
if record.method == PaymentMethod.KASSA_AI:
|
||||
mapping = {
|
||||
'pending': ('⏳', 'Ожидает оплаты'),
|
||||
'success': ('✅', 'Оплачено'),
|
||||
'paid': ('✅', 'Оплачено'),
|
||||
'canceled': ('❌', 'Отменено'),
|
||||
'failed': ('❌', 'Ошибка'),
|
||||
'expired': ('⌛', 'Истёк'),
|
||||
}
|
||||
return mapping.get(status, ('❓', 'Неизвестно'))
|
||||
|
||||
return '❓', 'Неизвестно'
|
||||
|
||||
|
||||
@@ -868,6 +842,8 @@ def _is_checkable(record: PendingPayment) -> bool:
|
||||
return status in {'pending', 'authorized'}
|
||||
if record.method == PaymentMethod.FREEKASSA:
|
||||
return status in {'pending', 'created', 'processing'}
|
||||
if record.method == PaymentMethod.KASSA_AI:
|
||||
return status in {'pending', 'created', 'processing'}
|
||||
return False
|
||||
|
||||
|
||||
@@ -891,7 +867,7 @@ def _get_payment_url(record: PendingPayment) -> str | None:
|
||||
)
|
||||
elif record.method == PaymentMethod.PLATEGA:
|
||||
payment_url = getattr(payment, 'redirect_url', None) or payment_url
|
||||
elif record.method == PaymentMethod.CLOUDPAYMENTS or record.method == PaymentMethod.FREEKASSA:
|
||||
elif record.method in (PaymentMethod.CLOUDPAYMENTS, PaymentMethod.FREEKASSA, PaymentMethod.KASSA_AI):
|
||||
payment_url = getattr(payment, 'payment_url', None) or payment_url
|
||||
|
||||
return payment_url
|
||||
|
||||
@@ -36,6 +36,7 @@ EMAIL_AUTH_ENABLED_KEY = 'CABINET_EMAIL_AUTH_ENABLED' # Stores "true" or "false
|
||||
YANDEX_METRIKA_ID_KEY = 'CABINET_YANDEX_METRIKA_ID' # Stores counter ID (numeric string)
|
||||
GOOGLE_ADS_ID_KEY = 'CABINET_GOOGLE_ADS_ID' # Stores conversion ID (e.g. "AW-123456789")
|
||||
GOOGLE_ADS_LABEL_KEY = 'CABINET_GOOGLE_ADS_LABEL' # Stores conversion label (alphanumeric)
|
||||
LITE_MODE_ENABLED_KEY = 'CABINET_LITE_MODE_ENABLED' # Stores "true" or "false"
|
||||
|
||||
# Allowed image types
|
||||
ALLOWED_CONTENT_TYPES = {'image/png', 'image/jpeg', 'image/jpg', 'image/webp', 'image/svg+xml'}
|
||||
@@ -144,6 +145,18 @@ class EmailAuthEnabledUpdate(BaseModel):
|
||||
enabled: bool
|
||||
|
||||
|
||||
class LiteModeEnabledResponse(BaseModel):
|
||||
"""Lite mode enabled setting."""
|
||||
|
||||
enabled: bool = False
|
||||
|
||||
|
||||
class LiteModeEnabledUpdate(BaseModel):
|
||||
"""Request to update lite mode setting."""
|
||||
|
||||
enabled: bool
|
||||
|
||||
|
||||
class AnalyticsCountersResponse(BaseModel):
|
||||
"""Analytics counter settings."""
|
||||
|
||||
@@ -718,3 +731,39 @@ async def update_analytics_counters(
|
||||
google_ads_id=google_id,
|
||||
google_ads_label=google_label,
|
||||
)
|
||||
|
||||
|
||||
# ============ Lite Mode Routes ============
|
||||
|
||||
|
||||
@router.get('/lite-mode', response_model=LiteModeEnabledResponse)
|
||||
async def get_lite_mode_enabled(
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""
|
||||
Get lite mode enabled setting.
|
||||
This is a public endpoint - no authentication required.
|
||||
When enabled, shows simplified dashboard with minimal features.
|
||||
"""
|
||||
lite_mode_value = await get_setting_value(db, LITE_MODE_ENABLED_KEY)
|
||||
|
||||
if lite_mode_value is not None:
|
||||
enabled = lite_mode_value.lower() == 'true'
|
||||
return LiteModeEnabledResponse(enabled=enabled)
|
||||
|
||||
# Default: disabled
|
||||
return LiteModeEnabledResponse(enabled=False)
|
||||
|
||||
|
||||
@router.patch('/lite-mode', response_model=LiteModeEnabledResponse)
|
||||
async def update_lite_mode_enabled(
|
||||
payload: LiteModeEnabledUpdate,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update lite mode enabled setting. Admin only."""
|
||||
await set_setting_value(db, LITE_MODE_ENABLED_KEY, str(payload.enabled).lower())
|
||||
|
||||
logger.info(f'Admin {admin.telegram_id} set lite mode enabled: {payload.enabled}')
|
||||
|
||||
return LiteModeEnabledResponse(enabled=payload.enabled)
|
||||
|
||||
@@ -20,6 +20,30 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/info', tags=['Cabinet Info'])
|
||||
|
||||
_LANGUAGE_META: dict[str, tuple[str, str]] = {
|
||||
'ru': ('Русский', '🇷🇺'),
|
||||
'en': ('English', '🇬🇧'),
|
||||
'ua': ('Українська', '🇺🇦'),
|
||||
'zh': ('中文', '🇨🇳'),
|
||||
'fa': ('فارسی', '🇮🇷'),
|
||||
}
|
||||
|
||||
|
||||
def _normalize_language_code(value: str | None) -> str:
|
||||
return (value or '').strip().lower().split('-', 1)[0]
|
||||
|
||||
|
||||
def _get_available_language_codes() -> list[str]:
|
||||
codes: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for code in settings.get_available_languages():
|
||||
normalized = _normalize_language_code(code)
|
||||
if not normalized or normalized in seen:
|
||||
continue
|
||||
seen.add(normalized)
|
||||
codes.append(normalized)
|
||||
return codes
|
||||
|
||||
|
||||
# ============ Schemas ============
|
||||
|
||||
@@ -212,12 +236,19 @@ async def get_service_info():
|
||||
@router.get('/languages')
|
||||
async def get_available_languages():
|
||||
"""Get list of available languages."""
|
||||
codes = _get_available_language_codes()
|
||||
default_language = _normalize_language_code(getattr(settings, 'DEFAULT_LANGUAGE', 'ru') or 'ru')
|
||||
|
||||
return {
|
||||
'languages': [
|
||||
{'code': 'ru', 'name': 'Русский', 'flag': '🇷🇺'},
|
||||
{'code': 'en', 'name': 'English', 'flag': '🇬🇧'},
|
||||
{
|
||||
'code': code,
|
||||
'name': _LANGUAGE_META.get(code, (code.upper(), '🌐'))[0],
|
||||
'flag': _LANGUAGE_META.get(code, (code.upper(), '🌐'))[1],
|
||||
}
|
||||
for code in codes
|
||||
],
|
||||
'default': getattr(settings, 'DEFAULT_LANGUAGE', 'ru') or 'ru',
|
||||
'default': default_language,
|
||||
}
|
||||
|
||||
|
||||
@@ -236,16 +267,15 @@ async def update_user_language(
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update user's language preference."""
|
||||
language = request.get('language', 'ru')
|
||||
|
||||
valid_languages = ['ru', 'en']
|
||||
if language not in valid_languages:
|
||||
requested_language = _normalize_language_code(request.get('language', 'ru'))
|
||||
available_languages = _get_available_language_codes()
|
||||
if requested_language not in available_languages:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Invalid language. Supported: {", ".join(valid_languages)}',
|
||||
detail=f'Invalid language. Supported: {", ".join(available_languages)}',
|
||||
)
|
||||
|
||||
user.language = language
|
||||
user.language = requested_language
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
"""OAuth 2.0 authentication routes for cabinet."""
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.user import (
|
||||
create_user_by_oauth,
|
||||
get_user_by_email,
|
||||
get_user_by_oauth_provider,
|
||||
set_user_oauth_provider_id,
|
||||
)
|
||||
from app.database.models import User
|
||||
|
||||
from ..auth.oauth_providers import (
|
||||
OAuthUserInfo,
|
||||
generate_oauth_state,
|
||||
get_provider,
|
||||
validate_oauth_state,
|
||||
)
|
||||
from ..dependencies import get_cabinet_db
|
||||
from ..schemas.auth import AuthResponse
|
||||
from .auth import _create_auth_response, _store_refresh_token
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/auth/oauth', tags=['Cabinet OAuth'])
|
||||
|
||||
|
||||
async def _finalize_oauth_login(db: AsyncSession, user: User, provider: str) -> AuthResponse:
|
||||
"""Update last login, create tokens, store refresh token."""
|
||||
user.cabinet_last_login = datetime.now(UTC).replace(tzinfo=None)
|
||||
await db.commit()
|
||||
auth_response = _create_auth_response(user)
|
||||
await _store_refresh_token(db, user.id, auth_response.refresh_token, device_info=f'oauth:{provider}')
|
||||
return auth_response
|
||||
|
||||
|
||||
# --- Schemas ---
|
||||
|
||||
|
||||
class OAuthProviderInfo(BaseModel):
|
||||
name: str
|
||||
display_name: str
|
||||
|
||||
|
||||
class OAuthProvidersResponse(BaseModel):
|
||||
providers: list[OAuthProviderInfo]
|
||||
|
||||
|
||||
class OAuthAuthorizeResponse(BaseModel):
|
||||
authorize_url: str
|
||||
state: str
|
||||
|
||||
|
||||
class OAuthCallbackRequest(BaseModel):
|
||||
code: str = Field(..., description='Authorization code from provider')
|
||||
state: str = Field(..., description='CSRF state token')
|
||||
|
||||
|
||||
# --- Endpoints ---
|
||||
|
||||
|
||||
@router.get('/providers', response_model=OAuthProvidersResponse)
|
||||
async def get_oauth_providers():
|
||||
"""Get list of enabled OAuth providers."""
|
||||
providers_config = settings.get_oauth_providers_config()
|
||||
providers = [
|
||||
OAuthProviderInfo(name=name, display_name=cfg['display_name'])
|
||||
for name, cfg in providers_config.items()
|
||||
if cfg['enabled']
|
||||
]
|
||||
return OAuthProvidersResponse(providers=providers)
|
||||
|
||||
|
||||
@router.get('/{provider}/authorize', response_model=OAuthAuthorizeResponse)
|
||||
async def get_oauth_authorize_url(provider: str):
|
||||
"""Get authorization URL for an OAuth provider."""
|
||||
oauth_provider = get_provider(provider)
|
||||
if not oauth_provider:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'OAuth provider "{provider}" is not enabled',
|
||||
)
|
||||
|
||||
state = await generate_oauth_state(provider)
|
||||
authorize_url = oauth_provider.get_authorization_url(state)
|
||||
|
||||
return OAuthAuthorizeResponse(authorize_url=authorize_url, state=state)
|
||||
|
||||
|
||||
@router.post('/{provider}/callback', response_model=AuthResponse)
|
||||
async def oauth_callback(
|
||||
provider: str,
|
||||
request: OAuthCallbackRequest,
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Handle OAuth callback: exchange code, find/create user, return JWT."""
|
||||
# 1. Validate CSRF state
|
||||
if not await validate_oauth_state(request.state, provider):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Invalid or expired OAuth state',
|
||||
)
|
||||
|
||||
# 2. Get provider instance
|
||||
oauth_provider = get_provider(provider)
|
||||
if not oauth_provider:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'OAuth provider "{provider}" is not enabled',
|
||||
)
|
||||
|
||||
# 3. Exchange code for tokens
|
||||
try:
|
||||
token_data = await oauth_provider.exchange_code(request.code)
|
||||
except Exception as exc:
|
||||
logger.error('OAuth code exchange failed for %s: %s', provider, exc)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Failed to exchange authorization code',
|
||||
) from exc
|
||||
|
||||
# 4. Fetch user info from provider
|
||||
try:
|
||||
user_info: OAuthUserInfo = await oauth_provider.get_user_info(token_data)
|
||||
except Exception as exc:
|
||||
logger.error('OAuth user info fetch failed for %s: %s', provider, exc)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Failed to fetch user information from provider',
|
||||
) from exc
|
||||
|
||||
# 5. Find user by provider ID
|
||||
user = await get_user_by_oauth_provider(db, provider, user_info.provider_id)
|
||||
if user:
|
||||
logger.info('OAuth login via %s for existing user %s', provider, user.id)
|
||||
return await _finalize_oauth_login(db, user, provider)
|
||||
|
||||
# 6. Find user by email (if verified) and link provider
|
||||
if user_info.email and user_info.email_verified:
|
||||
user = await get_user_by_email(db, user_info.email)
|
||||
if user:
|
||||
await set_user_oauth_provider_id(db, user, provider, user_info.provider_id)
|
||||
logger.info('OAuth login via %s linked to existing email user %s', provider, user.id)
|
||||
return await _finalize_oauth_login(db, user, provider)
|
||||
|
||||
# 7. Create new user
|
||||
user = await create_user_by_oauth(
|
||||
db=db,
|
||||
provider=provider,
|
||||
provider_id=user_info.provider_id,
|
||||
email=user_info.email if user_info.email_verified else None,
|
||||
email_verified=user_info.email_verified,
|
||||
first_name=user_info.first_name,
|
||||
last_name=user_info.last_name,
|
||||
username=user_info.username,
|
||||
)
|
||||
logger.info('OAuth new user created via %s with id=%s', provider, user.id)
|
||||
return await _finalize_oauth_login(db, user, provider)
|
||||
@@ -33,6 +33,15 @@ class PromocodeActivateResponse(BaseModel):
|
||||
bonus_description: str | None = None
|
||||
|
||||
|
||||
class PromocodeDeactivateResponse(BaseModel):
|
||||
"""Response after deactivating a discount promo code."""
|
||||
|
||||
success: bool
|
||||
message: str
|
||||
deactivated_code: str | None = None
|
||||
discount_percent: int = 0
|
||||
|
||||
|
||||
@router.post('/activate', response_model=PromocodeActivateResponse)
|
||||
async def activate_promocode(
|
||||
request: PromocodeActivateRequest,
|
||||
@@ -62,6 +71,8 @@ async def activate_promocode(
|
||||
'expired': 'Promo code has expired',
|
||||
'used': 'Promo code has been fully used',
|
||||
'already_used_by_user': 'You have already used this promo code',
|
||||
'active_discount_exists': 'You already have an active discount. Deactivate it first via /deactivate-discount',
|
||||
'not_first_purchase': 'This promo code is only available for first purchase',
|
||||
'user_not_found': 'User not found',
|
||||
'server_error': 'Server error occurred',
|
||||
}
|
||||
@@ -73,3 +84,41 @@ async def activate_promocode(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=error_message,
|
||||
)
|
||||
|
||||
|
||||
@router.post('/deactivate-discount', response_model=PromocodeDeactivateResponse)
|
||||
async def deactivate_discount_promocode(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> PromocodeDeactivateResponse:
|
||||
"""Deactivate the currently active discount promo code for the current user."""
|
||||
promocode_service = PromoCodeService()
|
||||
|
||||
result = await promocode_service.deactivate_discount_promocode(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
admin_initiated=False,
|
||||
)
|
||||
|
||||
if result['success']:
|
||||
return PromocodeDeactivateResponse(
|
||||
success=True,
|
||||
message='Discount promo code deactivated successfully',
|
||||
deactivated_code=result.get('deactivated_code'),
|
||||
discount_percent=result.get('discount_percent', 0),
|
||||
)
|
||||
|
||||
error_messages = {
|
||||
'user_not_found': 'User not found',
|
||||
'no_active_discount_promocode': 'No active discount promo code found',
|
||||
'discount_already_expired': 'Discount has already expired',
|
||||
'server_error': 'Server error occurred',
|
||||
}
|
||||
|
||||
error_code = result.get('error', 'server_error')
|
||||
error_message = error_messages.get(error_code, 'Failed to deactivate promo code')
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=error_message,
|
||||
)
|
||||
|
||||
+1296
-169
File diff suppressed because it is too large
Load Diff
@@ -374,6 +374,44 @@ async def notify_user_subscription_renewed(
|
||||
)
|
||||
|
||||
|
||||
async def notify_user_devices_purchased(
|
||||
user_id: int,
|
||||
devices_added: int,
|
||||
new_device_limit: int,
|
||||
amount_kopeks: int,
|
||||
) -> None:
|
||||
"""Уведомить пользователя о покупке устройств."""
|
||||
await cabinet_ws_manager.send_to_user(
|
||||
user_id,
|
||||
{
|
||||
'type': 'subscription.devices_purchased',
|
||||
'devices_added': devices_added,
|
||||
'new_device_limit': new_device_limit,
|
||||
'amount_kopeks': amount_kopeks,
|
||||
'amount_rubles': amount_kopeks / 100,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def notify_user_traffic_purchased(
|
||||
user_id: int,
|
||||
traffic_gb_added: int,
|
||||
new_traffic_limit_gb: int,
|
||||
amount_kopeks: int,
|
||||
) -> None:
|
||||
"""Уведомить пользователя о покупке трафика."""
|
||||
await cabinet_ws_manager.send_to_user(
|
||||
user_id,
|
||||
{
|
||||
'type': 'subscription.traffic_purchased',
|
||||
'traffic_gb_added': traffic_gb_added,
|
||||
'new_traffic_limit_gb': new_traffic_limit_gb,
|
||||
'amount_kopeks': amount_kopeks,
|
||||
'amount_rubles': amount_kopeks / 100,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Уведомления об автопродлении
|
||||
# ============================================================================
|
||||
|
||||
@@ -118,3 +118,23 @@ class RegisterResponse(BaseModel):
|
||||
message: str = Field(..., description='Success message')
|
||||
email: str = Field(..., description='Email address to verify')
|
||||
requires_verification: bool = Field(True, description='Whether email verification is required')
|
||||
|
||||
|
||||
class EmailChangeRequest(BaseModel):
|
||||
"""Request to initiate email change."""
|
||||
|
||||
new_email: EmailStr = Field(..., description='New email address')
|
||||
|
||||
|
||||
class EmailChangeVerifyRequest(BaseModel):
|
||||
"""Request to verify email change with code."""
|
||||
|
||||
code: str = Field(..., min_length=6, max_length=6, description='6-digit verification code')
|
||||
|
||||
|
||||
class EmailChangeResponse(BaseModel):
|
||||
"""Response for email change initiation."""
|
||||
|
||||
message: str = Field(..., description='Success message')
|
||||
new_email: str = Field(..., description='New email address pending verification')
|
||||
expires_in_minutes: int = Field(..., description='Code expiration time in minutes')
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
"""Pydantic schemas for cabinet broadcasts."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# ============ Channel Types ============
|
||||
|
||||
BroadcastChannel = Literal['telegram', 'email', 'both']
|
||||
|
||||
|
||||
# ============ Filters ============
|
||||
|
||||
|
||||
@@ -100,7 +106,7 @@ class BroadcastResponse(BaseModel):
|
||||
|
||||
id: int
|
||||
target_type: str
|
||||
message_text: str
|
||||
message_text: str | None = None
|
||||
has_media: bool
|
||||
media_type: str | None = None
|
||||
media_file_id: str | None = None
|
||||
@@ -115,6 +121,11 @@ class BroadcastResponse(BaseModel):
|
||||
completed_at: datetime | None = None
|
||||
progress_percent: float = 0.0
|
||||
|
||||
# Email/channel fields
|
||||
channel: str = 'telegram' # telegram|email|both
|
||||
email_subject: str | None = None
|
||||
email_html_content: str | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
@@ -142,3 +153,57 @@ class BroadcastPreviewResponse(BaseModel):
|
||||
|
||||
target: str
|
||||
count: int
|
||||
|
||||
|
||||
# ============ Email Filters ============
|
||||
|
||||
|
||||
class EmailFilterItem(BaseModel):
|
||||
"""Single email filter with count."""
|
||||
|
||||
key: str
|
||||
label: str
|
||||
count: int
|
||||
group: str | None = None
|
||||
|
||||
|
||||
class EmailFiltersResponse(BaseModel):
|
||||
"""Response with all email filters and their counts."""
|
||||
|
||||
filters: list[EmailFilterItem]
|
||||
total_with_email: int
|
||||
|
||||
|
||||
# ============ Combined Broadcast ============
|
||||
|
||||
|
||||
class CombinedBroadcastCreateRequest(BaseModel):
|
||||
"""Request to create a combined (telegram/email/both) broadcast."""
|
||||
|
||||
channel: BroadcastChannel
|
||||
target: str
|
||||
|
||||
# Telegram-specific fields
|
||||
message_text: str | None = Field(default=None, max_length=4000)
|
||||
selected_buttons: list[str] = Field(default_factory=lambda: ['home'])
|
||||
media: BroadcastMediaRequest | None = None
|
||||
|
||||
# Email-specific fields
|
||||
email_subject: str | None = Field(default=None, max_length=255)
|
||||
email_html_content: str | None = Field(default=None, max_length=100000)
|
||||
|
||||
|
||||
# ============ Email Preview ============
|
||||
|
||||
|
||||
class EmailPreviewRequest(BaseModel):
|
||||
"""Request to preview email broadcast recipients."""
|
||||
|
||||
target: str
|
||||
|
||||
|
||||
class EmailPreviewResponse(BaseModel):
|
||||
"""Preview response for email broadcast."""
|
||||
|
||||
target: str
|
||||
count: int
|
||||
|
||||
@@ -24,7 +24,7 @@ class TrafficPurchaseInfo(BaseModel):
|
||||
progress_percent: float
|
||||
|
||||
|
||||
class SubscriptionResponse(BaseModel):
|
||||
class SubscriptionData(BaseModel):
|
||||
"""User subscription data."""
|
||||
|
||||
id: int
|
||||
@@ -61,6 +61,17 @@ class SubscriptionResponse(BaseModel):
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# Backward compatibility alias
|
||||
SubscriptionResponse = SubscriptionData
|
||||
|
||||
|
||||
class SubscriptionStatusResponse(BaseModel):
|
||||
"""Response for subscription status endpoint - handles users with and without subscription."""
|
||||
|
||||
has_subscription: bool
|
||||
subscription: SubscriptionData | None = None
|
||||
|
||||
|
||||
class RenewalOptionResponse(BaseModel):
|
||||
"""Available subscription renewal option."""
|
||||
|
||||
|
||||
@@ -194,6 +194,12 @@ class TariffUpdateRequest(BaseModel):
|
||||
traffic_reset_mode: str | None = None # DAY, WEEK, MONTH, NO_RESET, None = глобальная настройка
|
||||
|
||||
|
||||
class TariffSortOrderRequest(BaseModel):
|
||||
"""Request to reorder tariffs."""
|
||||
|
||||
tariff_ids: list[int] = Field(..., min_length=1, description='Ordered list of tariff IDs')
|
||||
|
||||
|
||||
class TariffToggleResponse(BaseModel):
|
||||
"""Response after toggling tariff."""
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Schemas for admin traffic usage."""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class TrafficNodeInfo(BaseModel):
|
||||
node_uuid: str
|
||||
node_name: str
|
||||
country_code: str
|
||||
|
||||
|
||||
class UserTrafficItem(BaseModel):
|
||||
user_id: int
|
||||
telegram_id: int | None
|
||||
username: str | None
|
||||
email: str | None
|
||||
full_name: str
|
||||
tariff_name: str | None
|
||||
subscription_status: str | None
|
||||
traffic_limit_gb: float
|
||||
device_limit: int
|
||||
node_traffic: dict[str, int] # {node_uuid: total_bytes}
|
||||
total_bytes: int
|
||||
|
||||
|
||||
class TrafficUsageResponse(BaseModel):
|
||||
items: list[UserTrafficItem]
|
||||
nodes: list[TrafficNodeInfo]
|
||||
total: int
|
||||
offset: int
|
||||
limit: int
|
||||
period_days: int
|
||||
available_tariffs: list[str]
|
||||
available_statuses: list[str]
|
||||
|
||||
|
||||
class UserTrafficEnrichment(BaseModel):
|
||||
devices_connected: int = 0
|
||||
total_spent_kopeks: int = 0
|
||||
subscription_start_date: str | None = None
|
||||
subscription_end_date: str | None = None
|
||||
last_node_name: str | None = None
|
||||
|
||||
|
||||
class TrafficEnrichmentResponse(BaseModel):
|
||||
data: dict[int, UserTrafficEnrichment]
|
||||
|
||||
|
||||
class ExportCsvRequest(BaseModel):
|
||||
period: int = Field(30, ge=1, le=30)
|
||||
start_date: str | None = None
|
||||
end_date: str | None = None
|
||||
tariffs: str | None = None
|
||||
statuses: str | None = None
|
||||
nodes: str | None = None
|
||||
total_threshold_gb: float | None = Field(None, ge=0, description='Total GB/day threshold for risk column')
|
||||
node_threshold_gb: float | None = Field(None, ge=0, description='Per-node GB/day threshold for risk column')
|
||||
|
||||
|
||||
class ExportCsvResponse(BaseModel):
|
||||
success: bool
|
||||
message: str
|
||||
@@ -39,6 +39,17 @@ class SortByEnum(str, Enum):
|
||||
# === User Subscription Info ===
|
||||
|
||||
|
||||
class TrafficPurchaseItem(BaseModel):
|
||||
"""Individual traffic purchase record."""
|
||||
|
||||
id: int
|
||||
traffic_gb: int
|
||||
expires_at: datetime
|
||||
created_at: datetime
|
||||
days_remaining: int
|
||||
is_expired: bool
|
||||
|
||||
|
||||
class UserSubscriptionInfo(BaseModel):
|
||||
"""User subscription information."""
|
||||
|
||||
@@ -55,6 +66,8 @@ class UserSubscriptionInfo(BaseModel):
|
||||
autopay_enabled: bool = False
|
||||
is_active: bool = False
|
||||
days_remaining: int = 0
|
||||
purchased_traffic_gb: int = 0
|
||||
traffic_purchases: list[TrafficPurchaseItem] = []
|
||||
|
||||
|
||||
class UserPromoGroupInfo(BaseModel):
|
||||
@@ -189,9 +202,58 @@ class UserDetailResponse(BaseModel):
|
||||
promo_offer_discount_source: str | None = None
|
||||
promo_offer_discount_expires_at: datetime | None = None
|
||||
|
||||
# Campaign
|
||||
campaign_name: str | None = None
|
||||
campaign_id: int | None = None
|
||||
|
||||
# Recent transactions
|
||||
recent_transactions: list[UserTransactionItem] = []
|
||||
|
||||
# Remnawave UUID
|
||||
remnawave_uuid: str | None = None
|
||||
|
||||
|
||||
# === Panel Info ===
|
||||
|
||||
|
||||
class UserPanelInfoResponse(BaseModel):
|
||||
"""Panel info for user from Remnawave."""
|
||||
|
||||
found: bool = False
|
||||
trojan_password: str | None = None
|
||||
vless_uuid: str | None = None
|
||||
ss_password: str | None = None
|
||||
subscription_url: str | None = None
|
||||
happ_link: str | None = None
|
||||
used_traffic_bytes: int = 0
|
||||
lifetime_used_traffic_bytes: int = 0
|
||||
traffic_limit_bytes: int = 0
|
||||
first_connected_at: datetime | None = None
|
||||
online_at: datetime | None = None
|
||||
last_connected_node_uuid: str | None = None
|
||||
last_connected_node_name: str | None = None
|
||||
|
||||
|
||||
# === Node Usage ===
|
||||
|
||||
|
||||
class UserNodeUsageItem(BaseModel):
|
||||
"""Per-node traffic usage item."""
|
||||
|
||||
node_uuid: str
|
||||
node_name: str
|
||||
country_code: str = ''
|
||||
total_bytes: int
|
||||
daily_bytes: list[int] = []
|
||||
|
||||
|
||||
class UserNodeUsageResponse(BaseModel):
|
||||
"""Node usage response with 30-day daily breakdown."""
|
||||
|
||||
items: list[UserNodeUsageItem]
|
||||
categories: list[str] = []
|
||||
period_days: int = 30
|
||||
|
||||
|
||||
# === User Actions ===
|
||||
|
||||
@@ -236,6 +298,12 @@ class UpdateSubscriptionRequest(BaseModel):
|
||||
# For toggle_autopay
|
||||
autopay_enabled: bool | None = Field(None, description='Enable/disable autopay')
|
||||
|
||||
# For add_traffic action
|
||||
traffic_gb: int | None = Field(None, ge=1, description='Traffic GB to add')
|
||||
|
||||
# For remove_traffic action
|
||||
traffic_purchase_id: int | None = Field(None, description='Traffic purchase ID to remove')
|
||||
|
||||
# For create new subscription
|
||||
is_trial: bool | None = Field(None, description='Is trial subscription')
|
||||
device_limit: int | None = Field(None, ge=1, description='Device limit')
|
||||
@@ -299,6 +367,56 @@ class UpdatePromoGroupResponse(BaseModel):
|
||||
message: str
|
||||
|
||||
|
||||
class UpdateReferralCommissionRequest(BaseModel):
|
||||
"""Request to update user referral commission percent."""
|
||||
|
||||
commission_percent: int | None = Field(
|
||||
None, ge=0, le=100, description='Referral commission percent (null for default)'
|
||||
)
|
||||
|
||||
|
||||
class UpdateReferralCommissionResponse(BaseModel):
|
||||
"""Response after referral commission update."""
|
||||
|
||||
success: bool
|
||||
old_commission_percent: int | None = None
|
||||
new_commission_percent: int | None = None
|
||||
message: str
|
||||
|
||||
|
||||
class DeviceInfo(BaseModel):
|
||||
"""Individual device info."""
|
||||
|
||||
hwid: str
|
||||
platform: str = ''
|
||||
device_model: str = ''
|
||||
created_at: str | None = None
|
||||
|
||||
|
||||
class UserDevicesResponse(BaseModel):
|
||||
"""User devices from panel."""
|
||||
|
||||
devices: list[DeviceInfo] = []
|
||||
total: int = 0
|
||||
device_limit: int = 0
|
||||
|
||||
|
||||
class DeleteDeviceResponse(BaseModel):
|
||||
"""Response after device deletion."""
|
||||
|
||||
success: bool
|
||||
message: str
|
||||
deleted_hwid: str | None = None
|
||||
|
||||
|
||||
class ResetDevicesResponse(BaseModel):
|
||||
"""Response after resetting all devices."""
|
||||
|
||||
success: bool
|
||||
message: str
|
||||
deleted_count: int = 0
|
||||
|
||||
|
||||
class DeleteUserRequest(BaseModel):
|
||||
"""Request to delete user."""
|
||||
|
||||
@@ -392,6 +510,15 @@ class UserAvailableTariffItem(BaseModel):
|
||||
min_days: int = 1
|
||||
max_days: int = 365
|
||||
|
||||
# Device limits
|
||||
device_price_kopeks: int | None = None
|
||||
max_device_limit: int | None = None
|
||||
|
||||
# Traffic topup
|
||||
traffic_topup_enabled: bool = False
|
||||
traffic_topup_packages: dict[str, int] = {}
|
||||
max_topup_traffic_gb: int = 0
|
||||
|
||||
# Access info
|
||||
is_available: bool = True # Available for this user's promo group
|
||||
requires_promo_group: bool = False # Requires specific promo group
|
||||
@@ -498,3 +625,72 @@ class PanelSyncStatusResponse(BaseModel):
|
||||
# Differences
|
||||
has_differences: bool = False
|
||||
differences: list[str] = []
|
||||
|
||||
|
||||
# === Admin User Management Actions ===
|
||||
|
||||
|
||||
class FullDeleteUserRequest(BaseModel):
|
||||
"""Request for full user deletion (bot + panel)."""
|
||||
|
||||
delete_from_panel: bool = Field(default=True, description='Also delete user from Remnawave panel')
|
||||
reason: str | None = Field(None, max_length=500, description='Reason for deletion')
|
||||
|
||||
|
||||
class FullDeleteUserResponse(BaseModel):
|
||||
"""Response after full user deletion."""
|
||||
|
||||
success: bool
|
||||
message: str
|
||||
deleted_from_bot: bool = False
|
||||
deleted_from_panel: bool = False
|
||||
panel_error: str | None = None
|
||||
|
||||
|
||||
class ResetTrialRequest(BaseModel):
|
||||
"""Request to reset user trial."""
|
||||
|
||||
reason: str | None = Field(None, max_length=500, description='Reason for trial reset')
|
||||
|
||||
|
||||
class ResetTrialResponse(BaseModel):
|
||||
"""Response after trial reset."""
|
||||
|
||||
success: bool
|
||||
message: str
|
||||
subscription_deleted: bool = False
|
||||
has_used_trial_reset: bool = False
|
||||
|
||||
|
||||
class ResetSubscriptionRequest(BaseModel):
|
||||
"""Request to reset user subscription."""
|
||||
|
||||
deactivate_in_panel: bool = Field(default=True, description='Also deactivate in Remnawave panel')
|
||||
reason: str | None = Field(None, max_length=500, description='Reason for subscription reset')
|
||||
|
||||
|
||||
class ResetSubscriptionResponse(BaseModel):
|
||||
"""Response after subscription reset."""
|
||||
|
||||
success: bool
|
||||
message: str
|
||||
subscription_deleted: bool = False
|
||||
panel_deactivated: bool = False
|
||||
panel_error: str | None = None
|
||||
|
||||
|
||||
class DisableUserRequest(BaseModel):
|
||||
"""Request to disable user."""
|
||||
|
||||
reason: str | None = Field(None, max_length=500, description='Reason for disabling')
|
||||
|
||||
|
||||
class DisableUserResponse(BaseModel):
|
||||
"""Response after user disable."""
|
||||
|
||||
success: bool
|
||||
message: str
|
||||
subscription_deactivated: bool = False
|
||||
panel_deactivated: bool = False
|
||||
user_blocked: bool = False
|
||||
panel_error: str | None = None
|
||||
|
||||
@@ -119,7 +119,7 @@ class EmailService:
|
||||
verification_token: Verification token
|
||||
verification_url: Base URL for verification (token will be appended)
|
||||
username: User's name for personalization
|
||||
language: Language code (ru, en, zh, ua)
|
||||
language: Language code (ru, en, zh, ua, fa)
|
||||
custom_subject: Override subject from admin template
|
||||
custom_body_html: Override body HTML from admin template (already wrapped in base template)
|
||||
|
||||
@@ -174,6 +174,16 @@ class EmailService:
|
||||
'ignore': 'Якщо ви не створювали акаунт, просто проігноруйте цей лист.',
|
||||
'regards': 'З повагою,',
|
||||
},
|
||||
'fa': {
|
||||
'greeting': f'سلام{", " + username if username else ""}!',
|
||||
'subject': 'تایید آدرس ایمیل',
|
||||
'intro': 'از ثبتنام شما سپاسگزاریم! لطفاً با کلیک روی دکمه زیر ایمیل خود را تایید کنید:',
|
||||
'button': 'تایید ایمیل',
|
||||
'or_copy': 'یا این لینک را در مرورگر خود کپی و باز کنید:',
|
||||
'expires': f'این لینک تا {expire_hours} ساعت معتبر است.',
|
||||
'ignore': 'اگر شما این حساب را ایجاد نکردهاید، این ایمیل را نادیده بگیرید.',
|
||||
'regards': 'با احترام،',
|
||||
},
|
||||
}
|
||||
|
||||
t = texts.get(language, texts['ru'])
|
||||
@@ -236,7 +246,7 @@ class EmailService:
|
||||
reset_token: Password reset token
|
||||
reset_url: Base URL for password reset (token will be appended)
|
||||
username: User's name for personalization
|
||||
language: Language code (ru, en, zh, ua)
|
||||
language: Language code (ru, en, zh, ua, fa)
|
||||
custom_subject: Override subject from admin template
|
||||
custom_body_html: Override body HTML from admin template (already wrapped in base template)
|
||||
|
||||
@@ -291,6 +301,16 @@ class EmailService:
|
||||
'warning': "Якщо ви не запитували скидання пароля, проігноруйте цей лист або зв'яжіться з підтримкою.",
|
||||
'regards': 'З повагою,',
|
||||
},
|
||||
'fa': {
|
||||
'greeting': f'سلام{", " + username if username else ""}!',
|
||||
'subject': 'بازنشانی رمز عبور',
|
||||
'intro': 'درخواستی برای بازنشانی رمز عبور شما دریافت شد. برای تعیین رمز جدید روی دکمه زیر بزنید:',
|
||||
'button': 'بازنشانی رمز عبور',
|
||||
'or_copy': 'یا این لینک را در مرورگر خود کپی و باز کنید:',
|
||||
'expires': f'این لینک تا {expire_hours} ساعت معتبر است.',
|
||||
'warning': 'اگر شما درخواست بازنشانی رمز عبور ندادهاید، این ایمیل را نادیده بگیرید یا با پشتیبانی تماس بگیرید.',
|
||||
'regards': 'با احترام،',
|
||||
},
|
||||
}
|
||||
|
||||
t = texts.get(language, texts['ru'])
|
||||
@@ -336,6 +356,131 @@ class EmailService:
|
||||
|
||||
return self.send_email(to_email, subject, body_html)
|
||||
|
||||
def send_email_change_code(
|
||||
self,
|
||||
to_email: str,
|
||||
code: str,
|
||||
username: str | None = None,
|
||||
language: str = 'ru',
|
||||
custom_subject: str | None = None,
|
||||
custom_body_html: str | None = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Send email change verification code.
|
||||
|
||||
Args:
|
||||
to_email: New email address
|
||||
code: 6-digit verification code
|
||||
username: User's name for personalization
|
||||
language: Language code (ru, en, zh, ua, fa)
|
||||
custom_subject: Override subject from admin template
|
||||
custom_body_html: Override body HTML from admin template
|
||||
|
||||
Returns:
|
||||
True if email was sent successfully, False otherwise
|
||||
"""
|
||||
if custom_subject and custom_body_html:
|
||||
return self.send_email(to_email, custom_subject, custom_body_html)
|
||||
|
||||
expire_minutes = settings.get_cabinet_email_change_code_expire_minutes()
|
||||
|
||||
texts = {
|
||||
'ru': {
|
||||
'greeting': f'Здравствуйте{", " + username if username else ""}!',
|
||||
'subject': 'Код подтверждения для смены email',
|
||||
'intro': 'Вы запросили смену email адреса. Используйте код ниже для подтверждения:',
|
||||
'code_label': 'Ваш код подтверждения:',
|
||||
'expires': f'Код действителен в течение {expire_minutes} минут.',
|
||||
'ignore': 'Если вы не запрашивали смену email, просто проигнорируйте это письмо.',
|
||||
'regards': 'С уважением,',
|
||||
},
|
||||
'en': {
|
||||
'greeting': f'Hello{", " + username if username else ""}!',
|
||||
'subject': 'Email change verification code',
|
||||
'intro': 'You requested to change your email address. Use the code below to confirm:',
|
||||
'code_label': 'Your verification code:',
|
||||
'expires': f'This code will expire in {expire_minutes} minutes.',
|
||||
'ignore': "If you didn't request an email change, you can safely ignore this email.",
|
||||
'regards': 'Best regards,',
|
||||
},
|
||||
'zh': {
|
||||
'greeting': f'您好{", " + username if username else ""}!',
|
||||
'subject': '邮箱更换验证码',
|
||||
'intro': '您请求更换邮箱地址。请使用以下验证码确认:',
|
||||
'code_label': '您的验证码:',
|
||||
'expires': f'此验证码将在 {expire_minutes} 分钟后过期。',
|
||||
'ignore': '如果您没有请求更换邮箱,请忽略此邮件。',
|
||||
'regards': '此致,',
|
||||
},
|
||||
'ua': {
|
||||
'greeting': f'Вітаємо{", " + username if username else ""}!',
|
||||
'subject': 'Код підтвердження для зміни email',
|
||||
'intro': 'Ви запросили зміну email адреси. Використовуйте код нижче для підтвердження:',
|
||||
'code_label': 'Ваш код підтвердження:',
|
||||
'expires': f'Код дійсний протягом {expire_minutes} хвилин.',
|
||||
'ignore': 'Якщо ви не запитували зміну email, просто проігноруйте цей лист.',
|
||||
'regards': 'З повагою,',
|
||||
},
|
||||
'fa': {
|
||||
'greeting': f'سلام{", " + username if username else ""}!',
|
||||
'subject': 'کد تایید تغییر ایمیل',
|
||||
'intro': 'شما درخواست تغییر ایمیل دادهاید. برای تایید از کد زیر استفاده کنید:',
|
||||
'code_label': 'کد تایید شما:',
|
||||
'expires': f'این کد تا {expire_minutes} دقیقه معتبر است.',
|
||||
'ignore': 'اگر شما درخواست تغییر ایمیل ندادهاید، این ایمیل را نادیده بگیرید.',
|
||||
'regards': 'با احترام،',
|
||||
},
|
||||
}
|
||||
|
||||
t = texts.get(language, texts['ru'])
|
||||
|
||||
subject = t['subject']
|
||||
body_html = f"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
body {{ font-family: Arial, sans-serif; line-height: 1.6; color: #333; }}
|
||||
.container {{ max-width: 600px; margin: 0 auto; padding: 20px; }}
|
||||
.code-box {{
|
||||
background-color: #f8f9fa;
|
||||
border: 2px solid #007bff;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
margin: 20px 0;
|
||||
}}
|
||||
.code {{
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
letter-spacing: 8px;
|
||||
color: #007bff;
|
||||
font-family: monospace;
|
||||
}}
|
||||
.footer {{ margin-top: 30px; font-size: 12px; color: #666; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h2>{t['greeting']}</h2>
|
||||
<p>{t['intro']}</p>
|
||||
<div class="code-box">
|
||||
<p>{t['code_label']}</p>
|
||||
<p class="code">{code}</p>
|
||||
</div>
|
||||
<p>{t['expires']}</p>
|
||||
<p>{t['ignore']}</p>
|
||||
<div class="footer">
|
||||
<p>{t['regards']}<br>{self.from_name}</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
return self.send_email(to_email, subject, body_html)
|
||||
|
||||
|
||||
# Singleton instance
|
||||
email_service = EmailService()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
Email notification templates for different notification types.
|
||||
|
||||
Supports multiple languages: ru, en, zh, ua
|
||||
Supports multiple languages: ru, en, zh, ua, fa
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
@@ -27,7 +27,7 @@ class EmailNotificationTemplates:
|
||||
|
||||
Args:
|
||||
notification_type: Type of notification
|
||||
language: Language code (ru, en, zh, ua)
|
||||
language: Language code (ru, en, zh, ua, fa)
|
||||
context: Context data for template rendering
|
||||
|
||||
Returns:
|
||||
@@ -72,6 +72,7 @@ class EmailNotificationTemplates:
|
||||
'en': 'This is an automated message. Please do not reply to this email.',
|
||||
'zh': '这是一封自动发送的邮件,请勿回复。',
|
||||
'ua': 'Це автоматичне повідомлення. Будь ласка, не відповідайте на цей лист.',
|
||||
'fa': 'این یک پیام خودکار است. لطفاً به این ایمیل پاسخ ندهید.',
|
||||
}
|
||||
footer_text = footer_texts.get(language, footer_texts['ru'])
|
||||
|
||||
@@ -182,6 +183,7 @@ class EmailNotificationTemplates:
|
||||
'en': 'Open Dashboard',
|
||||
'zh': '打开控制面板',
|
||||
'ua': 'Відкрити особистий кабінет',
|
||||
'fa': 'باز کردن پنل کاربری',
|
||||
}
|
||||
text = texts.get(language, texts['en'])
|
||||
|
||||
|
||||
+75
-22
@@ -112,6 +112,7 @@ class Settings(BaseSettings):
|
||||
TRIAL_PAYMENT_ENABLED: bool = False
|
||||
TRIAL_ACTIVATION_PRICE: int = 0
|
||||
TRIAL_USER_TAG: str | None = None
|
||||
TRIAL_DISABLED_FOR: str = 'none' # none, email, telegram, all
|
||||
DEFAULT_TRAFFIC_LIMIT_GB: int = 100
|
||||
DEFAULT_DEVICE_LIMIT: int = 1
|
||||
DEFAULT_TRAFFIC_RESET_STRATEGY: str = 'MONTH'
|
||||
@@ -236,6 +237,8 @@ class Settings(BaseSettings):
|
||||
BLACKLIST_UPDATE_INTERVAL_HOURS: int = 24
|
||||
BLACKLIST_IGNORE_ADMINS: bool = True
|
||||
|
||||
DISPOSABLE_EMAIL_CHECK_ENABLED: bool = True
|
||||
|
||||
# Настройки простой покупки
|
||||
SIMPLE_SUBSCRIPTION_ENABLED: bool = False
|
||||
SIMPLE_SUBSCRIPTION_PERIOD_DAYS: int = 30
|
||||
@@ -336,12 +339,6 @@ class Settings(BaseSettings):
|
||||
NALOGO_STORAGE_PATH: str = './nalogo_tokens.json'
|
||||
|
||||
AUTO_PURCHASE_AFTER_TOPUP_ENABLED: bool = False
|
||||
AUTO_ACTIVATE_AFTER_TOPUP_ENABLED: bool = False
|
||||
|
||||
# Показывать предупреждение об активации подписки после пополнения баланса
|
||||
# Если True - после пополнения показывает большое сообщение с кнопками:
|
||||
# "Активировать", "Продлить", "Добавить устройства"
|
||||
SHOW_ACTIVATION_PROMPT_AFTER_TOPUP: bool = False
|
||||
|
||||
# Отключение превью ссылок в сообщениях бота
|
||||
DISABLE_WEB_PAGE_PREVIEW: bool = False
|
||||
@@ -406,7 +403,6 @@ class Settings(BaseSettings):
|
||||
PAL24_SIGNATURE_TOKEN: str | None = None
|
||||
PAL24_BASE_URL: str = 'https://pal24.pro/api/v1/'
|
||||
PAL24_WEBHOOK_PATH: str = '/pal24-webhook'
|
||||
PAL24_WEBHOOK_PORT: int = 8084
|
||||
PAL24_PAYMENT_DESCRIPTION: str = 'Пополнение баланса'
|
||||
PAL24_MIN_AMOUNT_KOPEKS: int = 10000
|
||||
PAL24_MAX_AMOUNT_KOPEKS: int = 100000000
|
||||
@@ -528,7 +524,7 @@ class Settings(BaseSettings):
|
||||
SKIP_REFERRAL_CODE: bool = False
|
||||
|
||||
DEFAULT_LANGUAGE: str = 'ru'
|
||||
AVAILABLE_LANGUAGES: str = 'ru,en'
|
||||
AVAILABLE_LANGUAGES: str = 'ru,en,ua,zh,fa'
|
||||
LANGUAGE_SELECTION_ENABLED: bool = True
|
||||
|
||||
# Округление цен при отображении (≤50 коп вниз, >50 коп вверх)
|
||||
@@ -691,9 +687,27 @@ class Settings(BaseSettings):
|
||||
CABINET_EMAIL_VERIFICATION_ENABLED: bool = True
|
||||
CABINET_EMAIL_VERIFICATION_EXPIRE_HOURS: int = 24
|
||||
CABINET_PASSWORD_RESET_EXPIRE_HOURS: int = 1
|
||||
CABINET_EMAIL_CHANGE_CODE_EXPIRE_MINUTES: int = 15 # Email change verification code expiration
|
||||
CABINET_EMAIL_AUTH_ENABLED: bool = True # Enable email registration/login in cabinet
|
||||
CABINET_URL: str = 'https://example.com/cabinet' # Base URL for cabinet (used in verification emails)
|
||||
|
||||
# OAuth 2.0 provider settings for cabinet
|
||||
OAUTH_GOOGLE_CLIENT_ID: str = ''
|
||||
OAUTH_GOOGLE_CLIENT_SECRET: str = ''
|
||||
OAUTH_GOOGLE_ENABLED: bool = False
|
||||
|
||||
OAUTH_YANDEX_CLIENT_ID: str = ''
|
||||
OAUTH_YANDEX_CLIENT_SECRET: str = ''
|
||||
OAUTH_YANDEX_ENABLED: bool = False
|
||||
|
||||
OAUTH_DISCORD_CLIENT_ID: str = ''
|
||||
OAUTH_DISCORD_CLIENT_SECRET: str = ''
|
||||
OAUTH_DISCORD_ENABLED: bool = False
|
||||
|
||||
OAUTH_VK_CLIENT_ID: str = ''
|
||||
OAUTH_VK_CLIENT_SECRET: str = ''
|
||||
OAUTH_VK_ENABLED: bool = False
|
||||
|
||||
# SMTP settings for cabinet email
|
||||
SMTP_HOST: str | None = None
|
||||
SMTP_PORT: int = 587
|
||||
@@ -1030,8 +1044,9 @@ class Settings(BaseSettings):
|
||||
)
|
||||
|
||||
raw_username = template.format_map(values).strip()
|
||||
sanitized_username = re.sub(r'[^0-9A-Za-z._-]+', '_', raw_username)
|
||||
sanitized_username = re.sub(r'_+', '_', sanitized_username).strip('._-')
|
||||
# Remnawave разрешает только буквы, цифры, подчёркивания и дефисы
|
||||
sanitized_username = re.sub(r'[^0-9A-Za-z_-]+', '_', raw_username)
|
||||
sanitized_username = re.sub(r'_+', '_', sanitized_username).strip('_-')
|
||||
|
||||
if not sanitized_username:
|
||||
sanitized_username = f'user_{identifier}'
|
||||
@@ -1161,22 +1176,12 @@ class Settings(BaseSettings):
|
||||
|
||||
return bool(value)
|
||||
|
||||
def is_auto_activate_after_topup_enabled(self) -> bool:
|
||||
"""Умная автоактивация после пополнения баланса (без корзины)."""
|
||||
value = getattr(self, 'AUTO_ACTIVATE_AFTER_TOPUP_ENABLED', False)
|
||||
|
||||
if isinstance(value, str):
|
||||
normalized = value.strip().lower()
|
||||
return normalized in {'1', 'true', 'yes', 'on'}
|
||||
|
||||
return bool(value)
|
||||
|
||||
def is_quick_amount_buttons_enabled(self) -> bool:
|
||||
"""Показывать ли кнопки быстрого выбора суммы пополнения."""
|
||||
return self.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not self.DISABLE_TOPUP_BUTTONS
|
||||
|
||||
def get_available_languages(self) -> list[str]:
|
||||
defaults = ['ru', 'en', 'ua', 'zh']
|
||||
defaults = ['ru', 'en', 'ua', 'zh', 'fa']
|
||||
|
||||
try:
|
||||
langs = self.AVAILABLE_LANGUAGES
|
||||
@@ -1308,6 +1313,17 @@ class Settings(BaseSettings):
|
||||
def get_trial_user_tag(self) -> str | None:
|
||||
return self._normalize_user_tag(self.TRIAL_USER_TAG, 'TRIAL_USER_TAG')
|
||||
|
||||
def is_trial_disabled_for_user(self, auth_type: str | None) -> bool:
|
||||
disabled_for = self.TRIAL_DISABLED_FOR
|
||||
if disabled_for == 'all':
|
||||
return True
|
||||
# 'email' means all non-Telegram users (email, google, yandex, discord, vk, etc.)
|
||||
if disabled_for == 'email' and auth_type not in (None, 'telegram'):
|
||||
return True
|
||||
if disabled_for == 'telegram' and (auth_type is None or auth_type == 'telegram'):
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_paid_subscription_user_tag(self) -> str | None:
|
||||
return self._normalize_user_tag(
|
||||
self.PAID_SUBSCRIPTION_USER_TAG,
|
||||
@@ -2416,7 +2432,7 @@ class Settings(BaseSettings):
|
||||
|
||||
def get_bot_run_mode(self) -> str:
|
||||
mode = (self.BOT_RUN_MODE or 'polling').strip().lower()
|
||||
if mode not in {'polling', 'webhook', 'both'}:
|
||||
if mode not in {'polling', 'webhook'}:
|
||||
return 'polling'
|
||||
return mode
|
||||
|
||||
@@ -2498,6 +2514,9 @@ class Settings(BaseSettings):
|
||||
def get_cabinet_password_reset_expire_hours(self) -> int:
|
||||
return max(1, self.CABINET_PASSWORD_RESET_EXPIRE_HOURS)
|
||||
|
||||
def get_cabinet_email_change_code_expire_minutes(self) -> int:
|
||||
return max(1, self.CABINET_EMAIL_CHANGE_CODE_EXPIRE_MINUTES)
|
||||
|
||||
def is_cabinet_email_auth_enabled(self) -> bool:
|
||||
return bool(self.CABINET_EMAIL_AUTH_ENABLED)
|
||||
|
||||
@@ -2511,6 +2530,40 @@ class Settings(BaseSettings):
|
||||
return self.SMTP_FROM_EMAIL
|
||||
return self.SMTP_USER
|
||||
|
||||
# OAuth helpers
|
||||
def get_oauth_providers_config(self) -> dict[str, dict[str, str | bool]]:
|
||||
"""Return config for all OAuth providers (enabled or not)."""
|
||||
return {
|
||||
'google': {
|
||||
'client_id': self.OAUTH_GOOGLE_CLIENT_ID,
|
||||
'client_secret': self.OAUTH_GOOGLE_CLIENT_SECRET,
|
||||
'enabled': self.OAUTH_GOOGLE_ENABLED,
|
||||
'display_name': 'Google',
|
||||
},
|
||||
'yandex': {
|
||||
'client_id': self.OAUTH_YANDEX_CLIENT_ID,
|
||||
'client_secret': self.OAUTH_YANDEX_CLIENT_SECRET,
|
||||
'enabled': self.OAUTH_YANDEX_ENABLED,
|
||||
'display_name': 'Yandex',
|
||||
},
|
||||
'discord': {
|
||||
'client_id': self.OAUTH_DISCORD_CLIENT_ID,
|
||||
'client_secret': self.OAUTH_DISCORD_CLIENT_SECRET,
|
||||
'enabled': self.OAUTH_DISCORD_ENABLED,
|
||||
'display_name': 'Discord',
|
||||
},
|
||||
'vk': {
|
||||
'client_id': self.OAUTH_VK_CLIENT_ID,
|
||||
'client_secret': self.OAUTH_VK_CLIENT_SECRET,
|
||||
'enabled': self.OAUTH_VK_ENABLED,
|
||||
'display_name': 'VK',
|
||||
},
|
||||
}
|
||||
|
||||
def get_enabled_oauth_provider_names(self) -> list[str]:
|
||||
"""Return list of enabled OAuth provider names."""
|
||||
return [name for name, cfg in self.get_oauth_providers_config().items() if cfg['enabled']]
|
||||
|
||||
# Ban System helpers
|
||||
def is_ban_system_enabled(self) -> bool:
|
||||
return bool(self.BAN_SYSTEM_ENABLED)
|
||||
|
||||
@@ -192,6 +192,36 @@ async def delete_promocode(db: AsyncSession, promocode: PromoCode) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
async def get_active_discount_promocode_for_user(
|
||||
db: AsyncSession, user_id: int
|
||||
) -> tuple[PromoCode | None, PromoCodeUse | None]:
|
||||
"""
|
||||
Находит активный промокод на скидку, который сейчас действует у пользователя.
|
||||
Ищет по source-полю пользователя (формат 'promocode:CODE').
|
||||
|
||||
Returns:
|
||||
Кортеж (PromoCode, PromoCodeUse) или (None, None) если ничего не найдено.
|
||||
"""
|
||||
from app.database.models import User as UserModel
|
||||
|
||||
user_result = await db.execute(select(UserModel).where(UserModel.id == user_id))
|
||||
user = user_result.scalar_one_or_none()
|
||||
if not user:
|
||||
return None, None
|
||||
|
||||
source = getattr(user, 'promo_offer_discount_source', None)
|
||||
if not source or not source.startswith('promocode:'):
|
||||
return None, None
|
||||
|
||||
code = source.split(':', 1)[1]
|
||||
promocode = await get_promocode_by_code(db, code)
|
||||
if not promocode:
|
||||
return None, None
|
||||
|
||||
use = await get_promocode_use_by_user_and_code(db, user_id, promocode.id)
|
||||
return promocode, use
|
||||
|
||||
|
||||
async def get_promocode_statistics(db: AsyncSession, promocode_id: int) -> dict:
|
||||
total_uses_result = await db.execute(
|
||||
select(func.count(PromoCodeUse.id)).where(PromoCodeUse.promocode_id == promocode_id)
|
||||
|
||||
@@ -9,6 +9,7 @@ from sqlalchemy.orm import selectinload
|
||||
from app.database.models import (
|
||||
ReferralContest,
|
||||
ReferralContestEvent,
|
||||
ReferralContestVirtualParticipant,
|
||||
Transaction,
|
||||
TransactionType,
|
||||
User,
|
||||
@@ -918,3 +919,96 @@ async def cleanup_invalid_contest_events(
|
||||
'contest_start': contest_start.isoformat(),
|
||||
'contest_end': contest_end.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
# ── Виртуальные участники ──────────────────────────────────────────────
|
||||
|
||||
|
||||
async def add_virtual_participant(
|
||||
db: AsyncSession,
|
||||
contest_id: int,
|
||||
display_name: str,
|
||||
referral_count: int,
|
||||
total_amount_kopeks: int = 0,
|
||||
) -> ReferralContestVirtualParticipant:
|
||||
vp = ReferralContestVirtualParticipant(
|
||||
contest_id=contest_id,
|
||||
display_name=display_name,
|
||||
referral_count=referral_count,
|
||||
total_amount_kopeks=total_amount_kopeks,
|
||||
)
|
||||
db.add(vp)
|
||||
await db.commit()
|
||||
await db.refresh(vp)
|
||||
return vp
|
||||
|
||||
|
||||
async def list_virtual_participants(
|
||||
db: AsyncSession,
|
||||
contest_id: int,
|
||||
) -> Sequence[ReferralContestVirtualParticipant]:
|
||||
result = await db.execute(
|
||||
select(ReferralContestVirtualParticipant)
|
||||
.where(ReferralContestVirtualParticipant.contest_id == contest_id)
|
||||
.order_by(ReferralContestVirtualParticipant.referral_count.desc())
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def delete_virtual_participant(
|
||||
db: AsyncSession,
|
||||
participant_id: int,
|
||||
) -> bool:
|
||||
result = await db.execute(
|
||||
select(ReferralContestVirtualParticipant).where(ReferralContestVirtualParticipant.id == participant_id)
|
||||
)
|
||||
vp = result.scalar_one_or_none()
|
||||
if not vp:
|
||||
return False
|
||||
await db.delete(vp)
|
||||
await db.commit()
|
||||
return True
|
||||
|
||||
|
||||
async def update_virtual_participant_count(
|
||||
db: AsyncSession,
|
||||
participant_id: int,
|
||||
referral_count: int,
|
||||
) -> ReferralContestVirtualParticipant | None:
|
||||
result = await db.execute(
|
||||
select(ReferralContestVirtualParticipant).where(ReferralContestVirtualParticipant.id == participant_id)
|
||||
)
|
||||
vp = result.scalar_one_or_none()
|
||||
if not vp:
|
||||
return None
|
||||
vp.referral_count = referral_count
|
||||
await db.commit()
|
||||
await db.refresh(vp)
|
||||
return vp
|
||||
|
||||
|
||||
async def get_contest_leaderboard_with_virtual(
|
||||
db: AsyncSession,
|
||||
contest_id: int,
|
||||
*,
|
||||
limit: int | None = None,
|
||||
) -> list[tuple[str, int, int, bool]]:
|
||||
"""Лидерборд с виртуальными участниками.
|
||||
|
||||
Возвращает список кортежей (display_name, referral_count, total_amount, is_virtual).
|
||||
"""
|
||||
real = await get_contest_leaderboard(db, contest_id)
|
||||
virtual = await list_virtual_participants(db, contest_id)
|
||||
|
||||
merged: list[tuple[str, int, int, bool]] = []
|
||||
for user, score, amount in real:
|
||||
merged.append((user.full_name, score, amount, False))
|
||||
for vp in virtual:
|
||||
merged.append((vp.display_name, vp.referral_count, vp.total_amount_kopeks, True))
|
||||
|
||||
merged.sort(key=lambda x: (-x[1], -x[2]))
|
||||
|
||||
if limit:
|
||||
merged = merged[:limit]
|
||||
|
||||
return merged
|
||||
|
||||
@@ -95,6 +95,25 @@ async def create_trial_subscription(
|
||||
|
||||
end_date = datetime.utcnow() + timedelta(days=duration_days)
|
||||
|
||||
# Check for existing PENDING trial subscription (retry after failed payment)
|
||||
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.utcnow()
|
||||
existing.end_date = end_date
|
||||
existing.traffic_limit_gb = traffic_limit_gb
|
||||
existing.device_limit = device_limit
|
||||
existing.connected_squads = final_squads
|
||||
existing.tariff_id = tariff_id
|
||||
await db.commit()
|
||||
await db.refresh(existing)
|
||||
logger.info(
|
||||
'🎁 Обновлена PENDING триальная подписка %s для пользователя %s',
|
||||
existing.id,
|
||||
user_id,
|
||||
)
|
||||
return existing
|
||||
|
||||
subscription = Subscription(
|
||||
user_id=user_id,
|
||||
status=SubscriptionStatus.ACTIVE.value,
|
||||
@@ -258,6 +277,10 @@ async def replace_subscription(
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
# Очищаем старые записи об отправленных уведомлениях при замене подписки
|
||||
# (аналогично extend_subscription), чтобы новые уведомления отправлялись корректно
|
||||
await clear_notifications(db, subscription.id)
|
||||
|
||||
if update_server_counters:
|
||||
try:
|
||||
from app.database.crud.server_squad import (
|
||||
@@ -710,7 +733,10 @@ async def get_subscriptions_for_autopay(db: AsyncSession) -> list[Subscription]:
|
||||
|
||||
result = await db.execute(
|
||||
select(Subscription)
|
||||
.options(selectinload(Subscription.user))
|
||||
.options(
|
||||
selectinload(Subscription.user),
|
||||
selectinload(Subscription.tariff),
|
||||
)
|
||||
.where(
|
||||
and_(
|
||||
Subscription.status == SubscriptionStatus.ACTIVE.value,
|
||||
@@ -723,6 +749,11 @@ async def get_subscriptions_for_autopay(db: AsyncSession) -> list[Subscription]:
|
||||
|
||||
ready_for_autopay = []
|
||||
for subscription in all_autopay_subscriptions:
|
||||
# Суточные подписки имеют свой механизм продления (DailySubscriptionService),
|
||||
# глобальный autopay на них не распространяется
|
||||
if subscription.tariff and getattr(subscription.tariff, 'is_daily', False):
|
||||
continue
|
||||
|
||||
days_until_expiry = (subscription.end_date - current_time).days
|
||||
|
||||
if days_until_expiry <= subscription.autopay_days_before and subscription.end_date > current_time:
|
||||
|
||||
+28
-25
@@ -233,6 +233,8 @@ async def create_tariff(
|
||||
if promo_group_ids:
|
||||
promo_groups_result = await db.execute(select(PromoGroup).where(PromoGroup.id.in_(promo_group_ids)))
|
||||
promo_groups = promo_groups_result.scalars().all()
|
||||
# Refresh чтобы избежать lazy load в async контексте
|
||||
await db.refresh(tariff, ['allowed_promo_groups'])
|
||||
tariff.allowed_promo_groups = list(promo_groups)
|
||||
|
||||
await db.commit()
|
||||
@@ -484,16 +486,14 @@ async def reorder_tariffs(
|
||||
for order, tariff_id in enumerate(tariff_order):
|
||||
await db.execute(update(Tariff).where(Tariff.id == tariff_id).values(display_order=order))
|
||||
|
||||
await db.commit()
|
||||
|
||||
logger.info('Изменен порядок тарифов: %s', tariff_order)
|
||||
|
||||
|
||||
async def sync_default_tariff_from_config(db: AsyncSession) -> Tariff | None:
|
||||
"""
|
||||
Синхронизирует дефолтный тариф из конфига (.env) в БД.
|
||||
Создаёт тариф "Стандартный" если в БД нет тарифов.
|
||||
Обновляет цены существующего тарифа если он есть.
|
||||
Создаёт тариф "Стандартный" только если в БД нет тарифов.
|
||||
Существующий тариф НЕ перезаписывается — админ управляет им через кабинет.
|
||||
|
||||
Returns:
|
||||
Tariff или None если не требуется синхронизация
|
||||
@@ -519,13 +519,11 @@ async def sync_default_tariff_from_config(db: AsyncSession) -> Tariff | None:
|
||||
existing_tariff = result.scalar_one_or_none()
|
||||
|
||||
if existing_tariff:
|
||||
# Обновляем цены существующего тарифа
|
||||
existing_tariff.period_prices = period_prices
|
||||
existing_tariff.traffic_limit_gb = settings.DEFAULT_TRAFFIC_LIMIT_GB
|
||||
existing_tariff.device_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
await db.commit()
|
||||
await db.refresh(existing_tariff)
|
||||
logger.info("Обновлён дефолтный тариф 'Стандартный' из конфига")
|
||||
# Тариф уже существует — НЕ перезаписываем настройки из конфига.
|
||||
# Админ управляет тарифом через кабинет, синхронизация не нужна.
|
||||
logger.info(
|
||||
"Дефолтный тариф 'Стандартный' (id=%s) уже существует, пропускаем sync из конфига", existing_tariff.id
|
||||
)
|
||||
return existing_tariff
|
||||
|
||||
if tariff_count == 0:
|
||||
@@ -571,21 +569,26 @@ async def load_period_prices_from_db(db: AsyncSession) -> None:
|
||||
)
|
||||
tariff = result.scalar_one_or_none()
|
||||
|
||||
if tariff and tariff.period_prices:
|
||||
# Преобразуем строковые ключи в int
|
||||
period_prices = {int(days): int(price) for days, price in tariff.period_prices.items() if int(price) > 0}
|
||||
|
||||
if period_prices:
|
||||
set_period_prices_from_db(period_prices)
|
||||
logger.info(
|
||||
"Загружены периоды из тарифа '%s': %s",
|
||||
tariff.name,
|
||||
{f'{d}д': f'{p // 100}₽' for d, p in period_prices.items()},
|
||||
)
|
||||
else:
|
||||
logger.warning("Тариф '%s' не имеет активных периодов", tariff.name)
|
||||
else:
|
||||
if not tariff:
|
||||
logger.info('Активные тарифы не найдены, используются цены из .env')
|
||||
return
|
||||
|
||||
if not tariff.period_prices:
|
||||
logger.warning("Тариф '%s' (id=%s) найден, но period_prices пуст", tariff.name, tariff.id)
|
||||
return
|
||||
|
||||
# Преобразуем строковые ключи в int
|
||||
period_prices = {int(days): int(price) for days, price in tariff.period_prices.items() if int(price) > 0}
|
||||
|
||||
if period_prices:
|
||||
set_period_prices_from_db(period_prices)
|
||||
logger.info(
|
||||
"Загружены периоды из тарифа '%s': %s",
|
||||
tariff.name,
|
||||
{f'{d}д': f'{p // 100}₽' for d, p in period_prices.items()},
|
||||
)
|
||||
else:
|
||||
logger.warning("Тариф '%s' не имеет активных периодов (все цены = 0)", tariff.name)
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Ошибка загрузки периодов из БД: %s', e)
|
||||
|
||||
+269
-23
@@ -1,7 +1,7 @@
|
||||
import logging
|
||||
import secrets
|
||||
import string
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from sqlalchemy import and_, case, func, nullslast, or_, select, text
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
@@ -28,6 +28,13 @@ from app.utils.validators import sanitize_telegram_name
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _normalize_language_code(language: str | None, fallback: str = 'ru') -> str:
|
||||
normalized = (language or '').strip().lower()
|
||||
if '-' in normalized:
|
||||
normalized = normalized.split('-', 1)[0]
|
||||
return normalized or fallback
|
||||
|
||||
|
||||
def _build_spending_stats_select():
|
||||
"""
|
||||
Возвращает базовый SELECT для статистики трат пользователей.
|
||||
@@ -232,6 +239,7 @@ async def create_user_no_commit(
|
||||
|
||||
if not referral_code:
|
||||
referral_code = await create_unique_referral_code(db)
|
||||
normalized_language = _normalize_language_code(language)
|
||||
|
||||
default_group = await _get_or_create_default_promo_group(db)
|
||||
promo_group_id = default_group.id
|
||||
@@ -243,7 +251,7 @@ async def create_user_no_commit(
|
||||
username=username,
|
||||
first_name=safe_first,
|
||||
last_name=safe_last,
|
||||
language=language,
|
||||
language=normalized_language,
|
||||
referred_by_id=referred_by_id,
|
||||
referral_code=referral_code,
|
||||
balance_kopeks=0,
|
||||
@@ -277,6 +285,7 @@ async def create_user(
|
||||
) -> User:
|
||||
if not referral_code:
|
||||
referral_code = await create_unique_referral_code(db)
|
||||
normalized_language = _normalize_language_code(language)
|
||||
|
||||
attempts = 3
|
||||
|
||||
@@ -291,7 +300,7 @@ async def create_user(
|
||||
username=username,
|
||||
first_name=safe_first,
|
||||
last_name=safe_last,
|
||||
language=language,
|
||||
language=normalized_language,
|
||||
referred_by_id=referred_by_id,
|
||||
referral_code=referral_code,
|
||||
balance_kopeks=0,
|
||||
@@ -360,6 +369,8 @@ async def update_user(db: AsyncSession, user: User, **kwargs) -> User:
|
||||
for field, value in kwargs.items():
|
||||
if field in ('first_name', 'last_name'):
|
||||
value = sanitize_telegram_name(value)
|
||||
if field == 'language':
|
||||
value = _normalize_language_code(value)
|
||||
if hasattr(user, field):
|
||||
setattr(user, field, value)
|
||||
|
||||
@@ -407,30 +418,35 @@ async def add_user_balance(
|
||||
|
||||
# Автоматическое возобновление приостановленной суточной подписки
|
||||
try:
|
||||
from app.database.crud.subscription import resume_daily_subscription
|
||||
from app.database.crud.subscription import get_subscription_by_user_id, resume_daily_subscription
|
||||
from app.database.crud.tariff import get_tariff_by_id
|
||||
from app.database.models import SubscriptionStatus
|
||||
|
||||
subscription = user.subscription
|
||||
# Загружаем подписку явно, чтобы избежать lazy loading
|
||||
subscription = await get_subscription_by_user_id(db, user.id)
|
||||
if subscription and subscription.status == SubscriptionStatus.DISABLED.value:
|
||||
# Проверяем что это суточный тариф
|
||||
is_daily = getattr(subscription, 'is_daily_tariff', False)
|
||||
if is_daily and subscription.tariff:
|
||||
daily_price = getattr(subscription.tariff, 'daily_price_kopeks', 0)
|
||||
# Если баланс достаточный для суточной оплаты - возобновляем
|
||||
if daily_price > 0 and user.balance_kopeks >= daily_price:
|
||||
await resume_daily_subscription(db, subscription)
|
||||
logger.info(
|
||||
f'✅ Автоматически возобновлена суточная подписка {subscription.id} '
|
||||
f'после пополнения баланса (user_id={user.id})'
|
||||
)
|
||||
# Синхронизируем с RemnaWave
|
||||
try:
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
if is_daily and subscription.tariff_id:
|
||||
# Загружаем тариф явно
|
||||
tariff = await get_tariff_by_id(db, subscription.tariff_id)
|
||||
if tariff:
|
||||
daily_price = getattr(tariff, 'daily_price_kopeks', 0)
|
||||
# Если баланс достаточный для суточной оплаты - возобновляем
|
||||
if daily_price > 0 and user.balance_kopeks >= daily_price:
|
||||
await resume_daily_subscription(db, subscription)
|
||||
logger.info(
|
||||
f'✅ Автоматически возобновлена суточная подписка {subscription.id} '
|
||||
f'после пополнения баланса (user_id={user.id})'
|
||||
)
|
||||
# Синхронизируем с RemnaWave
|
||||
try:
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.update_remnawave_user(db, subscription)
|
||||
except Exception as sync_err:
|
||||
logger.warning(f'Не удалось синхронизировать с RemnaWave: {sync_err}')
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.update_remnawave_user(db, subscription)
|
||||
except Exception as sync_err:
|
||||
logger.warning(f'Не удалось синхронизировать с RemnaWave: {sync_err}')
|
||||
except Exception as resume_err:
|
||||
logger.warning(f'Ошибка при попытке возобновить суточную подписку: {resume_err}')
|
||||
|
||||
@@ -686,6 +702,7 @@ async def get_users_list(
|
||||
offset: int = 0,
|
||||
limit: int = 50,
|
||||
search: str | None = None,
|
||||
email: str | None = None,
|
||||
status: UserStatus | None = None,
|
||||
order_by_balance: bool = False,
|
||||
order_by_traffic: bool = False,
|
||||
@@ -722,6 +739,9 @@ async def get_users_list(
|
||||
|
||||
query = query.where(or_(*conditions))
|
||||
|
||||
if email:
|
||||
query = query.where(User.email.ilike(f'%{email}%'))
|
||||
|
||||
sort_flags = [
|
||||
order_by_balance,
|
||||
order_by_traffic,
|
||||
@@ -777,7 +797,9 @@ async def get_users_list(
|
||||
return users
|
||||
|
||||
|
||||
async def get_users_count(db: AsyncSession, status: UserStatus | None = None, search: str | None = None) -> int:
|
||||
async def get_users_count(
|
||||
db: AsyncSession, status: UserStatus | None = None, search: str | None = None, email: str | None = None
|
||||
) -> int:
|
||||
query = select(func.count(User.id))
|
||||
|
||||
if status:
|
||||
@@ -803,6 +825,9 @@ async def get_users_count(db: AsyncSession, status: UserStatus | None = None, se
|
||||
|
||||
query = query.where(or_(*conditions))
|
||||
|
||||
if email:
|
||||
query = query.where(User.email.ilike(f'%{email}%'))
|
||||
|
||||
result = await db.execute(query)
|
||||
return result.scalar()
|
||||
|
||||
@@ -1046,6 +1071,7 @@ async def create_user_by_email(
|
||||
Created User object
|
||||
"""
|
||||
referral_code = await create_unique_referral_code(db)
|
||||
normalized_language = _normalize_language_code(language)
|
||||
default_group = await _get_or_create_default_promo_group(db)
|
||||
|
||||
user = User(
|
||||
@@ -1057,7 +1083,7 @@ async def create_user_by_email(
|
||||
username=None,
|
||||
first_name=sanitize_telegram_name(first_name) if first_name else None,
|
||||
last_name=None,
|
||||
language=language,
|
||||
language=normalized_language,
|
||||
referred_by_id=referred_by_id,
|
||||
referral_code=referral_code,
|
||||
balance_kopeks=0,
|
||||
@@ -1099,3 +1125,223 @@ async def get_user_by_email(db: AsyncSession, email: str) -> User | None:
|
||||
"""Get user by email address."""
|
||||
result = await db.execute(select(User).where(User.email == email))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def is_email_taken(db: AsyncSession, email: str, exclude_user_id: int | None = None) -> bool:
|
||||
"""
|
||||
Check if email is already taken by another user.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
email: Email to check
|
||||
exclude_user_id: User ID to exclude from check (for current user)
|
||||
|
||||
Returns:
|
||||
True if email is taken, False otherwise
|
||||
"""
|
||||
query = select(User.id).where(User.email == email)
|
||||
if exclude_user_id:
|
||||
query = query.where(User.id != exclude_user_id)
|
||||
result = await db.execute(query)
|
||||
return result.scalar_one_or_none() is not None
|
||||
|
||||
|
||||
async def set_email_change_pending(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
new_email: str,
|
||||
code: str,
|
||||
expires_at: datetime,
|
||||
) -> User:
|
||||
"""
|
||||
Set pending email change for user.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
user: User object
|
||||
new_email: New email address
|
||||
code: 6-digit verification code
|
||||
expires_at: Code expiration datetime
|
||||
|
||||
Returns:
|
||||
Updated User object
|
||||
"""
|
||||
user.email_change_new = new_email
|
||||
user.email_change_code = code
|
||||
user.email_change_expires = expires_at
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
logger.info(f'Email change pending for user {user.id}: {user.email} -> {new_email}')
|
||||
return user
|
||||
|
||||
|
||||
async def verify_and_apply_email_change(db: AsyncSession, user: User, code: str) -> tuple[bool, str]:
|
||||
"""
|
||||
Verify email change code and apply the change.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
user: User object
|
||||
code: Verification code from user
|
||||
|
||||
Returns:
|
||||
Tuple of (success: bool, message: str)
|
||||
"""
|
||||
if not user.email_change_new or not user.email_change_code:
|
||||
return False, 'No pending email change'
|
||||
|
||||
if user.email_change_expires and datetime.utcnow() > user.email_change_expires:
|
||||
# Clear expired data
|
||||
user.email_change_new = None
|
||||
user.email_change_code = None
|
||||
user.email_change_expires = None
|
||||
await db.commit()
|
||||
return False, 'Verification code has expired'
|
||||
|
||||
if user.email_change_code != code:
|
||||
return False, 'Invalid verification code'
|
||||
|
||||
# Check if new email is still available
|
||||
existing = await get_user_by_email(db, user.email_change_new)
|
||||
if existing and existing.id != user.id:
|
||||
user.email_change_new = None
|
||||
user.email_change_code = None
|
||||
user.email_change_expires = None
|
||||
await db.commit()
|
||||
return False, 'This email is already taken'
|
||||
|
||||
old_email = user.email
|
||||
new_email = user.email_change_new
|
||||
|
||||
# Apply the change
|
||||
user.email = new_email
|
||||
user.email_verified = True
|
||||
user.email_verified_at = datetime.utcnow()
|
||||
user.email_change_new = None
|
||||
user.email_change_code = None
|
||||
user.email_change_expires = None
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
logger.info(f'Email changed for user {user.id}: {old_email} -> {new_email}')
|
||||
return True, 'Email changed successfully'
|
||||
|
||||
|
||||
async def clear_email_change_pending(db: AsyncSession, user: User) -> None:
|
||||
"""
|
||||
Clear pending email change data.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
user: User object
|
||||
"""
|
||||
user.email_change_new = None
|
||||
user.email_change_code = None
|
||||
user.email_change_expires = None
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
logger.info(f'Email change cancelled for user {user.id}')
|
||||
|
||||
|
||||
# --- OAuth provider functions ---
|
||||
|
||||
_OAUTH_PROVIDER_COLUMNS = {
|
||||
'google': 'google_id',
|
||||
'yandex': 'yandex_id',
|
||||
'discord': 'discord_id',
|
||||
'vk': 'vk_id',
|
||||
}
|
||||
|
||||
|
||||
async def get_user_by_oauth_provider(db: AsyncSession, provider: str, provider_id: str) -> User | None:
|
||||
"""Find a user by OAuth provider ID."""
|
||||
column_name = _OAUTH_PROVIDER_COLUMNS.get(provider)
|
||||
if not column_name:
|
||||
return None
|
||||
column = getattr(User, column_name)
|
||||
# VK uses BigInteger, so convert
|
||||
value: str | int = int(provider_id) if provider == 'vk' else provider_id
|
||||
result = await db.execute(select(User).where(column == value))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def set_user_oauth_provider_id(db: AsyncSession, user: User, provider: str, provider_id: str) -> None:
|
||||
"""Link an OAuth provider ID to an existing user."""
|
||||
column_name = _OAUTH_PROVIDER_COLUMNS.get(provider)
|
||||
if not column_name:
|
||||
return
|
||||
value: str | int = int(provider_id) if provider == 'vk' else provider_id
|
||||
setattr(user, column_name, value)
|
||||
user.updated_at = datetime.now(UTC).replace(tzinfo=None)
|
||||
logger.info(f'Linked {provider} (id={provider_id}) to user {user.id}')
|
||||
|
||||
|
||||
async def create_user_by_oauth(
|
||||
db: AsyncSession,
|
||||
provider: str,
|
||||
provider_id: str,
|
||||
email: str | None = None,
|
||||
email_verified: bool = False,
|
||||
first_name: str | None = None,
|
||||
last_name: str | None = None,
|
||||
username: str | None = None,
|
||||
language: str = 'ru',
|
||||
) -> User:
|
||||
"""Create a new user via OAuth provider."""
|
||||
referral_code = await create_unique_referral_code(db)
|
||||
normalized_language = _normalize_language_code(language)
|
||||
default_group = await _get_or_create_default_promo_group(db)
|
||||
|
||||
column_name = _OAUTH_PROVIDER_COLUMNS.get(provider)
|
||||
provider_value: str | int = int(provider_id) if provider == 'vk' else provider_id
|
||||
|
||||
user = User(
|
||||
telegram_id=None,
|
||||
auth_type=provider,
|
||||
email=email,
|
||||
email_verified=email_verified,
|
||||
password_hash=None,
|
||||
username=sanitize_telegram_name(username) if username else None,
|
||||
first_name=sanitize_telegram_name(first_name) if first_name else None,
|
||||
last_name=sanitize_telegram_name(last_name) if last_name else None,
|
||||
language=normalized_language,
|
||||
referral_code=referral_code,
|
||||
balance_kopeks=0,
|
||||
has_had_paid_subscription=False,
|
||||
has_made_first_topup=False,
|
||||
promo_group_id=default_group.id,
|
||||
)
|
||||
if column_name:
|
||||
setattr(user, column_name, provider_value)
|
||||
|
||||
db.add(user)
|
||||
await db.flush()
|
||||
await db.refresh(user)
|
||||
|
||||
user.promo_group = default_group
|
||||
logger.info(f'Created OAuth user via {provider} (provider_id={provider_id}) with id={user.id}')
|
||||
|
||||
try:
|
||||
from app.services.event_emitter import event_emitter
|
||||
|
||||
await event_emitter.emit(
|
||||
'user.created',
|
||||
{
|
||||
'user_id': user.id,
|
||||
'email': user.email,
|
||||
'auth_type': provider,
|
||||
'first_name': user.first_name,
|
||||
'referral_code': user.referral_code,
|
||||
},
|
||||
db=db,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.warning('Failed to emit user.created event: %s', error)
|
||||
|
||||
return user
|
||||
|
||||
@@ -2,6 +2,7 @@ import logging
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import and_, select, update
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
@@ -24,7 +25,7 @@ async def create_yookassa_payment(
|
||||
payment_method_type: str | None = None,
|
||||
yookassa_created_at: datetime | None = None,
|
||||
test_mode: bool = False,
|
||||
) -> YooKassaPayment:
|
||||
) -> YooKassaPayment | None:
|
||||
payment = YooKassaPayment(
|
||||
user_id=user_id,
|
||||
yookassa_payment_id=yookassa_payment_id,
|
||||
@@ -40,7 +41,17 @@ async def create_yookassa_payment(
|
||||
)
|
||||
|
||||
db.add(payment)
|
||||
await db.commit()
|
||||
try:
|
||||
await db.commit()
|
||||
except IntegrityError as e:
|
||||
await db.rollback()
|
||||
logger.error(
|
||||
'FK violation при создании платежа YooKassa %s: user_id=%s не существует в БД: %s',
|
||||
yookassa_payment_id,
|
||||
user_id,
|
||||
e,
|
||||
)
|
||||
return None
|
||||
await db.refresh(payment)
|
||||
|
||||
logger.info(f'Создан платеж YooKassa: {yookassa_payment_id} на {amount_kopeks / 100}₽ для пользователя {user_id}')
|
||||
|
||||
+39
-1
@@ -991,6 +991,15 @@ class User(Base):
|
||||
password_reset_token = Column(String(255), nullable=True)
|
||||
password_reset_expires = Column(DateTime, nullable=True)
|
||||
cabinet_last_login = Column(DateTime, 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
|
||||
email_change_expires = Column(DateTime, nullable=True) # Code expiration
|
||||
# OAuth provider IDs
|
||||
google_id = Column(String(255), unique=True, nullable=True, index=True)
|
||||
yandex_id = Column(String(255), unique=True, nullable=True, index=True)
|
||||
discord_id = Column(String(255), unique=True, nullable=True, index=True)
|
||||
vk_id = Column(BigInteger, unique=True, nullable=True, index=True)
|
||||
broadcasts = relationship('BroadcastHistory', back_populates='admin')
|
||||
referrals = relationship('User', backref='referrer', remote_side=[id], foreign_keys='User.referred_by_id')
|
||||
subscription = relationship('Subscription', back_populates='user', uselist=False)
|
||||
@@ -1051,6 +1060,11 @@ class User(Base):
|
||||
"""Пользователь зарегистрирован через email (без Telegram)."""
|
||||
return self.auth_type == 'email' and self.telegram_id is None
|
||||
|
||||
@property
|
||||
def is_web_user(self) -> bool:
|
||||
"""Пользователь без Telegram (email, OAuth и т.д.)."""
|
||||
return self.telegram_id is None
|
||||
|
||||
def get_primary_promo_group(self):
|
||||
"""Возвращает промогруппу с максимальным приоритетом."""
|
||||
if not self.user_promo_groups:
|
||||
@@ -1548,6 +1562,24 @@ class ReferralContestEvent(Base):
|
||||
)
|
||||
|
||||
|
||||
class ReferralContestVirtualParticipant(Base):
|
||||
__tablename__ = 'referral_contest_virtual_participants'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
contest_id = Column(Integer, ForeignKey('referral_contests.id', ondelete='CASCADE'), nullable=False)
|
||||
display_name = Column(String(255), nullable=False)
|
||||
referral_count = Column(Integer, nullable=False, default=0)
|
||||
total_amount_kopeks = Column(Integer, nullable=False, default=0)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
|
||||
contest = relationship('ReferralContest')
|
||||
|
||||
def __repr__(self):
|
||||
return (
|
||||
f"<ReferralContestVirtualParticipant id={self.id} name='{self.display_name}' count={self.referral_count}>"
|
||||
)
|
||||
|
||||
|
||||
class ContestTemplate(Base):
|
||||
__tablename__ = 'contest_templates'
|
||||
|
||||
@@ -1844,7 +1876,7 @@ class BroadcastHistory(Base):
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
target_type = Column(String(100), nullable=False)
|
||||
message_text = Column(Text, nullable=False)
|
||||
message_text = Column(Text, nullable=True) # Nullable for email-only broadcasts
|
||||
has_media = Column(Boolean, default=False)
|
||||
media_type = Column(String(20), nullable=True)
|
||||
media_file_id = Column(String(255), nullable=True)
|
||||
@@ -1857,6 +1889,12 @@ class BroadcastHistory(Base):
|
||||
admin_name = Column(String(255))
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
completed_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# Email broadcast fields
|
||||
channel = Column(String(20), default='telegram', nullable=False) # telegram|email|both
|
||||
email_subject = Column(String(255), nullable=True)
|
||||
email_html_content = Column(Text, nullable=True)
|
||||
|
||||
admin = relationship('User', back_populates='broadcasts')
|
||||
|
||||
|
||||
|
||||
@@ -1731,6 +1731,65 @@ async def create_referral_contest_events_table() -> bool:
|
||||
return False
|
||||
|
||||
|
||||
async def create_referral_contest_virtual_participants_table() -> bool:
|
||||
table_exists = await check_table_exists('referral_contest_virtual_participants')
|
||||
if table_exists:
|
||||
logger.info('Таблица referral_contest_virtual_participants уже существует')
|
||||
return True
|
||||
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
if db_type == 'sqlite':
|
||||
await conn.execute(
|
||||
text("""
|
||||
CREATE TABLE referral_contest_virtual_participants (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
contest_id INTEGER NOT NULL,
|
||||
display_name VARCHAR(255) NOT NULL,
|
||||
referral_count INTEGER NOT NULL DEFAULT 0,
|
||||
total_amount_kopeks INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY(contest_id) REFERENCES referral_contests(id) ON DELETE CASCADE
|
||||
)
|
||||
""")
|
||||
)
|
||||
elif db_type == 'postgresql':
|
||||
await conn.execute(
|
||||
text("""
|
||||
CREATE TABLE referral_contest_virtual_participants (
|
||||
id SERIAL PRIMARY KEY,
|
||||
contest_id INTEGER NOT NULL REFERENCES referral_contests(id) ON DELETE CASCADE,
|
||||
display_name VARCHAR(255) NOT NULL,
|
||||
referral_count INTEGER NOT NULL DEFAULT 0,
|
||||
total_amount_kopeks INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
)
|
||||
else:
|
||||
await conn.execute(
|
||||
text("""
|
||||
CREATE TABLE referral_contest_virtual_participants (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
contest_id INT NOT NULL,
|
||||
display_name VARCHAR(255) NOT NULL,
|
||||
referral_count INT NOT NULL DEFAULT 0,
|
||||
total_amount_kopeks INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY(contest_id) REFERENCES referral_contests(id) ON DELETE CASCADE
|
||||
)
|
||||
""")
|
||||
)
|
||||
|
||||
logger.info('✅ Таблица referral_contest_virtual_participants создана')
|
||||
return True
|
||||
except Exception as error:
|
||||
logger.error(f'Ошибка создания таблицы referral_contest_virtual_participants: {error}')
|
||||
return False
|
||||
|
||||
|
||||
async def ensure_referral_contest_summary_columns() -> bool:
|
||||
ok = True
|
||||
for column in ['daily_summary_times', 'last_daily_summary_at']:
|
||||
@@ -3298,6 +3357,54 @@ async def add_media_fields_to_broadcast_history():
|
||||
return False
|
||||
|
||||
|
||||
async def add_email_fields_to_broadcast_history():
|
||||
"""Добавление полей для email-рассылки в broadcast_history."""
|
||||
logger.info('=== ДОБАВЛЕНИЕ ПОЛЕЙ EMAIL В BROADCAST_HISTORY ===')
|
||||
|
||||
email_fields = {
|
||||
'channel': "VARCHAR(20) DEFAULT 'telegram'",
|
||||
'email_subject': 'VARCHAR(255)',
|
||||
'email_html_content': 'TEXT',
|
||||
}
|
||||
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
# Добавление новых полей
|
||||
for field_name, field_type in email_fields.items():
|
||||
field_exists = await check_column_exists('broadcast_history', field_name)
|
||||
|
||||
if not field_exists:
|
||||
logger.info(f'Добавление поля {field_name} в таблицу broadcast_history')
|
||||
|
||||
alter_sql = f'ALTER TABLE broadcast_history ADD COLUMN {field_name} {field_type}'
|
||||
await conn.execute(text(alter_sql))
|
||||
logger.info(f'✅ Поле {field_name} успешно добавлено')
|
||||
else:
|
||||
logger.info(f'Поле {field_name} уже существует в broadcast_history')
|
||||
|
||||
# Сделать message_text nullable для email-only рассылок
|
||||
try:
|
||||
if db_type == 'postgresql':
|
||||
await conn.execute(text('ALTER TABLE broadcast_history ALTER COLUMN message_text DROP NOT NULL'))
|
||||
logger.info('✅ Колонка message_text теперь nullable')
|
||||
elif db_type == 'mysql':
|
||||
await conn.execute(text('ALTER TABLE broadcast_history MODIFY COLUMN message_text TEXT NULL'))
|
||||
logger.info('✅ Колонка message_text теперь nullable')
|
||||
# SQLite не поддерживает ALTER COLUMN, но там по умолчанию nullable
|
||||
except Exception as e:
|
||||
# Игнорируем если уже nullable или другая ошибка
|
||||
logger.debug(f'message_text nullable: {e}')
|
||||
|
||||
logger.info('✅ Все поля email в broadcast_history готовы')
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка при добавлении полей email в broadcast_history: {e}')
|
||||
return False
|
||||
|
||||
|
||||
async def add_ticket_reply_block_columns():
|
||||
try:
|
||||
col_perm_exists = await check_column_exists('tickets', 'user_reply_block_permanent')
|
||||
@@ -3434,6 +3541,10 @@ async def add_user_cabinet_columns() -> bool:
|
||||
('password_reset_token', 'VARCHAR(255)', 'VARCHAR(255)', 'VARCHAR(255)'),
|
||||
('password_reset_expires', 'DATETIME', 'TIMESTAMP', 'DATETIME'),
|
||||
('cabinet_last_login', 'DATETIME', 'TIMESTAMP', 'DATETIME'),
|
||||
# Email change fields
|
||||
('email_change_new', 'VARCHAR(255)', 'VARCHAR(255)', 'VARCHAR(255)'),
|
||||
('email_change_code', 'VARCHAR(6)', 'VARCHAR(6)', 'VARCHAR(6)'),
|
||||
('email_change_expires', 'DATETIME', 'TIMESTAMP', 'DATETIME'),
|
||||
]
|
||||
|
||||
try:
|
||||
@@ -4983,6 +5094,58 @@ async def add_transaction_receipt_columns() -> bool:
|
||||
return False
|
||||
|
||||
|
||||
async def add_oauth_provider_columns() -> bool:
|
||||
"""Добавить колонки OAuth провайдеров (google_id, yandex_id, discord_id, vk_id) в users."""
|
||||
try:
|
||||
google_exists = await check_column_exists('users', 'google_id')
|
||||
yandex_exists = await check_column_exists('users', 'yandex_id')
|
||||
discord_exists = await check_column_exists('users', 'discord_id')
|
||||
vk_exists = await check_column_exists('users', 'vk_id')
|
||||
|
||||
if google_exists and yandex_exists and discord_exists and vk_exists:
|
||||
logger.info('Колонки OAuth провайдеров уже существуют в users')
|
||||
return True
|
||||
|
||||
db_type = await get_database_type()
|
||||
|
||||
async with engine.begin() as conn:
|
||||
if not google_exists:
|
||||
await conn.execute(text('ALTER TABLE users ADD COLUMN google_id VARCHAR(255)'))
|
||||
logger.info('✅ Добавлена колонка google_id в users')
|
||||
|
||||
if not yandex_exists:
|
||||
await conn.execute(text('ALTER TABLE users ADD COLUMN yandex_id VARCHAR(255)'))
|
||||
logger.info('✅ Добавлена колонка yandex_id в users')
|
||||
|
||||
if not discord_exists:
|
||||
await conn.execute(text('ALTER TABLE users ADD COLUMN discord_id VARCHAR(255)'))
|
||||
logger.info('✅ Добавлена колонка discord_id в users')
|
||||
|
||||
if not vk_exists:
|
||||
if db_type == 'postgresql':
|
||||
await conn.execute(text('ALTER TABLE users ADD COLUMN vk_id BIGINT'))
|
||||
else:
|
||||
await conn.execute(text('ALTER TABLE users ADD COLUMN vk_id INTEGER'))
|
||||
logger.info('✅ Добавлена колонка vk_id в users')
|
||||
|
||||
# Создаём уникальные индексы
|
||||
for col in ('google_id', 'yandex_id', 'discord_id', 'vk_id'):
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
if db_type in ('postgresql', 'sqlite'):
|
||||
await conn.execute(text(f'CREATE UNIQUE INDEX IF NOT EXISTS uq_users_{col} ON users ({col})'))
|
||||
else:
|
||||
await conn.execute(text(f'CREATE UNIQUE INDEX uq_users_{col} ON users ({col})'))
|
||||
except Exception as idx_error:
|
||||
logger.warning(f'Индекс uq_users_{col} возможно уже существует: {idx_error}')
|
||||
|
||||
return True
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f'❌ Ошибка добавления колонок OAuth провайдеров в users: {error}')
|
||||
return False
|
||||
|
||||
|
||||
async def create_withdrawal_requests_table() -> bool:
|
||||
"""Создаёт таблицу для заявок на вывод реферального баланса."""
|
||||
try:
|
||||
@@ -6459,6 +6622,12 @@ async def run_universal_migration():
|
||||
else:
|
||||
logger.warning('⚠️ Проблемы с таблицей referral_contest_events')
|
||||
|
||||
virtual_participants_ready = await create_referral_contest_virtual_participants_table()
|
||||
if virtual_participants_ready:
|
||||
logger.info('✅ Таблица referral_contest_virtual_participants готова')
|
||||
else:
|
||||
logger.warning('⚠️ Проблемы с таблицей referral_contest_virtual_participants')
|
||||
|
||||
contest_type_ready = await ensure_referral_contest_type_column()
|
||||
if contest_type_ready:
|
||||
logger.info('✅ Колонка contest_type для referral_contests готова')
|
||||
@@ -6632,6 +6801,13 @@ async def run_universal_migration():
|
||||
else:
|
||||
logger.warning('⚠️ Проблемы с добавлением медиа полей')
|
||||
|
||||
logger.info('=== ДОБАВЛЕНИЕ EMAIL ПОЛЕЙ В BROADCAST_HISTORY ===')
|
||||
email_fields_added = await add_email_fields_to_broadcast_history()
|
||||
if email_fields_added:
|
||||
logger.info('✅ Email поля в broadcast_history готовы')
|
||||
else:
|
||||
logger.warning('⚠️ Проблемы с добавлением email полей')
|
||||
|
||||
logger.info('=== ДОБАВЛЕНИЕ ПОЛЕЙ БЛОКИРОВКИ В TICKETS ===')
|
||||
tickets_block_cols_added = await add_ticket_reply_block_columns()
|
||||
if tickets_block_cols_added:
|
||||
@@ -6921,6 +7097,13 @@ async def run_universal_migration():
|
||||
else:
|
||||
logger.warning('⚠️ Проблемы с миграцией transaction_id_cp')
|
||||
|
||||
logger.info('=== ДОБАВЛЕНИЕ КОЛОНОК OAUTH ПРОВАЙДЕРОВ ===')
|
||||
oauth_columns_ready = await add_oauth_provider_columns()
|
||||
if oauth_columns_ready:
|
||||
logger.info('✅ Колонки OAuth провайдеров (google_id, yandex_id, discord_id, vk_id) готовы')
|
||||
else:
|
||||
logger.warning('⚠️ Проблемы с колонками OAuth провайдеров')
|
||||
|
||||
async with engine.begin() as conn:
|
||||
total_subs = await conn.execute(text('SELECT COUNT(*) FROM subscriptions'))
|
||||
unique_users = await conn.execute(text('SELECT COUNT(DISTINCT user_id) FROM subscriptions'))
|
||||
@@ -6986,6 +7169,7 @@ async def check_migration_status():
|
||||
'pinned_messages_start_mode_column': False,
|
||||
'users_last_pinned_column': False,
|
||||
'broadcast_history_media_fields': False,
|
||||
'broadcast_history_email_fields': False,
|
||||
'subscription_duplicates': False,
|
||||
'subscription_conversions_table': False,
|
||||
'subscription_events_table': False,
|
||||
@@ -7032,6 +7216,10 @@ async def check_migration_status():
|
||||
'campaign_tariff_duration_days_column': False,
|
||||
'campaign_registration_tariff_id_column': False,
|
||||
'campaign_registration_tariff_duration_days_column': False,
|
||||
'users_google_id_column': False,
|
||||
'users_yandex_id_column': False,
|
||||
'users_discord_id_column': False,
|
||||
'users_vk_id_column': False,
|
||||
}
|
||||
|
||||
status['has_made_first_topup_column'] = await check_column_exists('users', 'has_made_first_topup')
|
||||
@@ -7133,6 +7321,13 @@ async def check_migration_status():
|
||||
)
|
||||
status['broadcast_history_media_fields'] = media_fields_exist
|
||||
|
||||
email_fields_exist = (
|
||||
await check_column_exists('broadcast_history', 'channel')
|
||||
and await check_column_exists('broadcast_history', 'email_subject')
|
||||
and await check_column_exists('broadcast_history', 'email_html_content')
|
||||
)
|
||||
status['broadcast_history_email_fields'] = email_fields_exist
|
||||
|
||||
pinned_media_columns_exist = (
|
||||
status['pinned_messages_table']
|
||||
and await check_column_exists('pinned_messages', 'media_type')
|
||||
@@ -7156,6 +7351,12 @@ async def check_migration_status():
|
||||
'transactions', 'receipt_created_at'
|
||||
)
|
||||
|
||||
# Колонки OAuth провайдеров в users
|
||||
status['users_google_id_column'] = await check_column_exists('users', 'google_id')
|
||||
status['users_yandex_id_column'] = await check_column_exists('users', 'yandex_id')
|
||||
status['users_discord_id_column'] = await check_column_exists('users', 'discord_id')
|
||||
status['users_vk_id_column'] = await check_column_exists('users', 'vk_id')
|
||||
|
||||
async with engine.begin() as conn:
|
||||
duplicates_check = await conn.execute(
|
||||
text("""
|
||||
@@ -7185,6 +7386,7 @@ async def check_migration_status():
|
||||
'pinned_messages_start_mode_column': 'Режим отправки закрепа при /start',
|
||||
'users_last_pinned_column': 'Колонка last_pinned_message_id у пользователей',
|
||||
'broadcast_history_media_fields': 'Медиа поля в broadcast_history',
|
||||
'broadcast_history_email_fields': 'Email поля в broadcast_history',
|
||||
'subscription_conversions_table': 'Таблица конверсий подписок',
|
||||
'subscription_events_table': 'Таблица событий подписок',
|
||||
'subscription_duplicates': 'Отсутствие дубликатов подписок',
|
||||
@@ -7225,6 +7427,10 @@ async def check_migration_status():
|
||||
'subscription_temporary_access_table': 'Таблица subscription_temporary_access',
|
||||
'transactions_receipt_uuid_column': 'Колонка receipt_uuid в transactions',
|
||||
'transactions_receipt_created_at_column': 'Колонка receipt_created_at в transactions',
|
||||
'users_google_id_column': 'Колонка google_id в users',
|
||||
'users_yandex_id_column': 'Колонка yandex_id в users',
|
||||
'users_discord_id_column': 'Колонка discord_id в users',
|
||||
'users_vk_id_column': 'Колонка vk_id в users',
|
||||
}
|
||||
|
||||
for check_key, check_status in status.items():
|
||||
|
||||
Vendored
-166
@@ -1,166 +0,0 @@
|
||||
"""Flask webhook server for PayPalych callbacks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
from asyncio import AbstractEventLoop
|
||||
from concurrent.futures import TimeoutError as FuturesTimeoutError
|
||||
from typing import Any
|
||||
|
||||
from flask import Flask, jsonify, request
|
||||
from werkzeug.serving import make_server
|
||||
|
||||
from app.config import settings
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from app.services.pal24_service import Pal24APIError, Pal24Service
|
||||
from app.services.payment_service import PaymentService
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _normalize_payload() -> dict[str, str]:
|
||||
if request.is_json:
|
||||
payload = request.get_json(silent=True) or {}
|
||||
if isinstance(payload, dict):
|
||||
return {k: str(v) for k, v in payload.items()}
|
||||
logger.warning('Pal24 webhook JSON payload не является объектом: %s', payload)
|
||||
return {}
|
||||
|
||||
if request.form:
|
||||
return {k: v for k, v in request.form.items()}
|
||||
|
||||
try:
|
||||
raw_body = request.data.decode('utf-8')
|
||||
if raw_body:
|
||||
payload = json.loads(raw_body)
|
||||
if isinstance(payload, dict):
|
||||
return {k: str(v) for k, v in payload.items()}
|
||||
except json.JSONDecodeError:
|
||||
logger.debug('Pal24 webhook body не удалось распарсить как JSON')
|
||||
|
||||
return {}
|
||||
|
||||
|
||||
def create_pal24_flask_app(
|
||||
payment_service: PaymentService,
|
||||
loop: AbstractEventLoop,
|
||||
) -> Flask:
|
||||
pal24_service = Pal24Service()
|
||||
app = Flask(__name__)
|
||||
|
||||
@app.route(settings.PAL24_WEBHOOK_PATH, methods=['POST'])
|
||||
def pal24_webhook() -> tuple:
|
||||
if not pal24_service.is_configured:
|
||||
logger.error('Pal24 webhook получен, но сервис не настроен')
|
||||
return jsonify({'status': 'error', 'reason': 'service_not_configured'}), 503
|
||||
|
||||
logger.debug('Получен Pal24 webhook: headers=%s', dict(request.headers))
|
||||
|
||||
payload = _normalize_payload()
|
||||
if not payload:
|
||||
logger.warning('Пустой Pal24 webhook')
|
||||
return jsonify({'status': 'error', 'reason': 'empty_payload'}), 400
|
||||
|
||||
try:
|
||||
parsed_payload = pal24_service.parse_callback(payload)
|
||||
except Pal24APIError as error:
|
||||
logger.error('Ошибка валидации Pal24 webhook: %s', error)
|
||||
return jsonify({'status': 'error', 'reason': str(error)}), 400
|
||||
|
||||
async def process() -> bool:
|
||||
async with AsyncSessionLocal() as db:
|
||||
try:
|
||||
return await payment_service.process_pal24_callback(db, parsed_payload)
|
||||
except Exception:
|
||||
await db.rollback()
|
||||
raise
|
||||
|
||||
try:
|
||||
future = asyncio.run_coroutine_threadsafe(process(), loop)
|
||||
processed = future.result(timeout=settings.PAL24_REQUEST_TIMEOUT)
|
||||
except FuturesTimeoutError:
|
||||
logger.error('Обработка Pal24 webhook превысила таймаут %sс', settings.PAL24_REQUEST_TIMEOUT)
|
||||
return jsonify({'status': 'error', 'reason': 'timeout'}), 504
|
||||
except Exception as error: # pragma: no cover - defensive
|
||||
logger.exception('Критическая ошибка обработки Pal24 webhook: %s', error)
|
||||
return jsonify({'status': 'error', 'reason': 'internal_error'}), 500
|
||||
|
||||
if processed:
|
||||
return jsonify({'status': 'ok'}), 200
|
||||
return jsonify({'status': 'error', 'reason': 'not_processed'}), 400
|
||||
|
||||
@app.route(settings.PAL24_WEBHOOK_PATH, methods=['GET'])
|
||||
def pal24_health() -> tuple:
|
||||
return jsonify(
|
||||
{
|
||||
'status': 'ok',
|
||||
'service': 'pal24_webhook',
|
||||
'enabled': settings.is_pal24_enabled(),
|
||||
}
|
||||
), 200
|
||||
|
||||
@app.route('/pal24/health', methods=['GET'])
|
||||
def pal24_additional_health() -> tuple:
|
||||
return jsonify(
|
||||
{
|
||||
'status': 'ok',
|
||||
'service': 'pal24_webhook',
|
||||
'path': settings.PAL24_WEBHOOK_PATH,
|
||||
}
|
||||
), 200
|
||||
|
||||
return app
|
||||
|
||||
|
||||
class Pal24WebhookServer:
|
||||
"""Threaded Flask server for Pal24 callbacks."""
|
||||
|
||||
def __init__(self, payment_service: PaymentService, loop: AbstractEventLoop) -> None:
|
||||
self.app = create_pal24_flask_app(payment_service, loop)
|
||||
self._server: Any | None = None
|
||||
self._thread: threading.Thread | None = None
|
||||
|
||||
def start(self) -> None:
|
||||
if self._server:
|
||||
logger.warning('Pal24 webhook server уже запущен')
|
||||
return
|
||||
|
||||
self._server = make_server(
|
||||
host='0.0.0.0',
|
||||
port=settings.PAL24_WEBHOOK_PORT,
|
||||
app=self.app,
|
||||
threaded=True,
|
||||
)
|
||||
|
||||
def _serve() -> None:
|
||||
logger.info(
|
||||
'Pal24 webhook сервер запущен на %s:%s%s',
|
||||
'0.0.0.0',
|
||||
settings.PAL24_WEBHOOK_PORT,
|
||||
settings.PAL24_WEBHOOK_PATH,
|
||||
)
|
||||
self._server.serve_forever()
|
||||
|
||||
self._thread = threading.Thread(target=_serve, daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def stop(self) -> None:
|
||||
if self._server:
|
||||
logger.info('Останавливаем Pal24 webhook сервер')
|
||||
self._server.shutdown()
|
||||
self._server = None
|
||||
|
||||
if self._thread and self._thread.is_alive():
|
||||
self._thread.join(timeout=5)
|
||||
self._thread = None
|
||||
|
||||
|
||||
async def start_pal24_webhook_server(payment_service: PaymentService) -> Pal24WebhookServer:
|
||||
loop = asyncio.get_running_loop()
|
||||
server = Pal24WebhookServer(payment_service, loop)
|
||||
await loop.run_in_executor(None, server.start)
|
||||
return server
|
||||
Vendored
+102
-19
@@ -1,3 +1,4 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
@@ -366,32 +367,63 @@ class RemnaWaveAPI:
|
||||
raise RemnaWaveAPIError('Session not initialized. Use async context manager.')
|
||||
|
||||
url = f'{self.base_url}{endpoint}'
|
||||
max_retries = 3
|
||||
base_delay = 1.0
|
||||
|
||||
try:
|
||||
kwargs = {'url': url, 'params': params}
|
||||
for attempt in range(max_retries + 1):
|
||||
try:
|
||||
kwargs = {'url': url, 'params': params}
|
||||
|
||||
if data:
|
||||
kwargs['json'] = data
|
||||
if data:
|
||||
kwargs['json'] = data
|
||||
|
||||
async with self.session.request(method, **kwargs) as response:
|
||||
response_text = await response.text()
|
||||
async with self.session.request(method, **kwargs) as response:
|
||||
response_text = await response.text()
|
||||
|
||||
try:
|
||||
response_data = json.loads(response_text) if response_text else {}
|
||||
except json.JSONDecodeError:
|
||||
response_data = {'raw_response': response_text}
|
||||
try:
|
||||
response_data = json.loads(response_text) if response_text else {}
|
||||
except json.JSONDecodeError:
|
||||
response_data = {'raw_response': response_text}
|
||||
|
||||
if response.status >= 400:
|
||||
error_message = response_data.get('message', f'HTTP {response.status}')
|
||||
logger.error(f'API Error {response.status}: {error_message}')
|
||||
logger.error(f'Response: {response_text[:500]}')
|
||||
raise RemnaWaveAPIError(error_message, response.status, response_data)
|
||||
if response.status == 429 and attempt < max_retries:
|
||||
retry_after = float(response.headers.get('Retry-After', base_delay * (2**attempt)))
|
||||
logger.warning(
|
||||
'Rate limited (429) on %s %s, retry %d/%d after %.1fs',
|
||||
method,
|
||||
endpoint,
|
||||
attempt + 1,
|
||||
max_retries,
|
||||
retry_after,
|
||||
)
|
||||
await asyncio.sleep(retry_after)
|
||||
continue
|
||||
|
||||
return response_data
|
||||
if response.status >= 400:
|
||||
error_message = response_data.get('message', f'HTTP {response.status}')
|
||||
logger.error(f'API Error {response.status}: {error_message}')
|
||||
logger.error(f'Response: {response_text[:500]}')
|
||||
raise RemnaWaveAPIError(error_message, response.status, response_data)
|
||||
|
||||
except aiohttp.ClientError as e:
|
||||
logger.error(f'Request failed: {e}')
|
||||
raise RemnaWaveAPIError(f'Request failed: {e!s}')
|
||||
return response_data
|
||||
|
||||
except aiohttp.ClientError as e:
|
||||
if attempt < max_retries:
|
||||
delay = base_delay * (2**attempt)
|
||||
logger.warning(
|
||||
'Request failed on %s %s: %s, retry %d/%d after %.1fs',
|
||||
method,
|
||||
endpoint,
|
||||
e,
|
||||
attempt + 1,
|
||||
max_retries,
|
||||
delay,
|
||||
)
|
||||
await asyncio.sleep(delay)
|
||||
continue
|
||||
logger.error(f'Request failed: {e}')
|
||||
raise RemnaWaveAPIError(f'Request failed: {e!s}')
|
||||
|
||||
raise RemnaWaveAPIError(f'Max retries exceeded for {method} {endpoint}')
|
||||
|
||||
async def create_user(
|
||||
self,
|
||||
@@ -564,6 +596,33 @@ class RemnaWaveAPI:
|
||||
user = self._parse_user(response['response'])
|
||||
return await self.enrich_user_with_happ_link(user)
|
||||
|
||||
async def get_user_accessible_nodes(self, uuid: str) -> list[RemnaWaveAccessibleNode]:
|
||||
"""Получает список доступных нод для пользователя"""
|
||||
try:
|
||||
response = await self._make_request('GET', f'/api/users/{uuid}/accessible-nodes')
|
||||
nodes_data = response.get('response', {}).get('activeNodes', [])
|
||||
result = []
|
||||
for node in nodes_data:
|
||||
# Collect inbounds from activeSquads
|
||||
inbounds: list[str] = []
|
||||
for squad in node.get('activeSquads', []):
|
||||
inbounds.extend(squad.get('activeInbounds', []))
|
||||
result.append(
|
||||
RemnaWaveAccessibleNode(
|
||||
uuid=node['uuid'],
|
||||
node_name=node['nodeName'],
|
||||
country_code=node['countryCode'],
|
||||
config_profile_uuid=node.get('configProfileUuid', ''),
|
||||
config_profile_name=node.get('configProfileName', ''),
|
||||
active_inbounds=inbounds,
|
||||
)
|
||||
)
|
||||
return result
|
||||
except RemnaWaveAPIError as e:
|
||||
if e.status_code == 404:
|
||||
return []
|
||||
raise
|
||||
|
||||
async def get_all_users(self, start: int = 0, size: int = 100, enrich_happ_links: bool = False) -> dict[str, Any]:
|
||||
params = {'start': start, 'size': size}
|
||||
response = await self._make_request('GET', '/api/users', params=params)
|
||||
@@ -940,6 +999,30 @@ class RemnaWaveAPI:
|
||||
uuid=data['uuid'], name=data['name'], view_position=data['viewPosition'], config=data.get('config')
|
||||
)
|
||||
|
||||
async def get_all_hwid_devices(self) -> dict[str, Any]:
|
||||
"""GET /api/hwid/devices — all devices for all users (paginated, max 1000/page)."""
|
||||
all_devices: list[dict[str, Any]] = []
|
||||
start = 0
|
||||
page_size = 1000
|
||||
|
||||
while True:
|
||||
response = await self._make_request('GET', '/api/hwid/devices', 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)
|
||||
|
||||
return {'devices': all_devices, 'total': len(all_devices)}
|
||||
|
||||
async def get_all_panel_subscriptions(self) -> list[dict[str, Any]]:
|
||||
"""GET /api/subscriptions — all panel subscriptions."""
|
||||
response = await self._make_request('GET', '/api/subscriptions')
|
||||
return response.get('response') or []
|
||||
|
||||
async def get_user_devices(self, user_uuid: str) -> dict[str, Any]:
|
||||
try:
|
||||
response = await self._make_request('GET', f'/api/hwid/devices/{user_uuid}')
|
||||
|
||||
Vendored
+1
-1
@@ -91,7 +91,7 @@ class TributeService:
|
||||
amount_kopeks = data.get('amount', 0)
|
||||
telegram_user_id = data.get('telegram_user_id')
|
||||
|
||||
if event_name == 'new_donation':
|
||||
if event_name in ('new_donation', 'recurrent_donation'):
|
||||
status = 'paid'
|
||||
elif event_name == 'cancelled_subscription':
|
||||
status = 'cancelled'
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
from . import (
|
||||
backup,
|
||||
blacklist,
|
||||
blocked_users,
|
||||
bot_configuration,
|
||||
bulk_ban,
|
||||
campaigns,
|
||||
|
||||
@@ -0,0 +1,728 @@
|
||||
"""
|
||||
Хендлеры админ-панели для управления заблокированными пользователями.
|
||||
|
||||
Позволяет сканировать пользователей, выявлять тех, кто заблокировал бота,
|
||||
и выполнять очистку БД и панели Remnawave.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
from aiogram import Bot, Dispatcher, F, types
|
||||
from aiogram.enums import ParseMode
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import User
|
||||
from app.services.blocked_users_service import (
|
||||
BlockCheckResult,
|
||||
BlockedUserAction,
|
||||
BlockedUsersService,
|
||||
)
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Enums для текстов и callback_data
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class BlockedUsersText(Enum):
|
||||
"""Тексты для сообщений модуля заблокированных пользователей."""
|
||||
|
||||
MENU_TITLE = '🔒 <b>Проверка заблокированных пользователей</b>'
|
||||
MENU_DESCRIPTION = (
|
||||
'\n\nЗдесь вы можете проверить, какие пользователи заблокировали бота, '
|
||||
'и очистить их из базы данных и панели Remnawave.\n\n'
|
||||
'<b>Как это работает:</b>\n'
|
||||
'1. Сканирование отправляет тестовый запрос каждому пользователю\n'
|
||||
'2. Если пользователь заблокировал бота - получаем ошибку\n'
|
||||
'3. Можно удалить таких пользователей из БД и/или Remnawave'
|
||||
)
|
||||
|
||||
SCAN_STARTED = '🔄 <b>Сканирование запущено...</b>\n\nЭто может занять несколько минут.'
|
||||
SCAN_PROGRESS = '🔄 <b>Сканирование:</b> {checked}/{total} ({percent}%)'
|
||||
SCAN_COMPLETE = (
|
||||
'✅ <b>Сканирование завершено</b>\n\n'
|
||||
'📊 <b>Результаты:</b>\n'
|
||||
'• Проверено: {total_checked}\n'
|
||||
'• Заблокировали бота: {blocked_count}\n'
|
||||
'• Активных: {active_users}\n'
|
||||
'• Ошибок: {errors}\n'
|
||||
'• Без Telegram ID: {skipped}\n\n'
|
||||
'⏱ Время сканирования: {duration:.1f}с'
|
||||
)
|
||||
SCAN_NO_BLOCKED = '✅ <b>Отлично!</b>\n\nНе найдено пользователей, заблокировавших бота.'
|
||||
|
||||
BLOCKED_LIST_TITLE = '🔒 <b>Заблокированные пользователи</b> ({count})\n\n'
|
||||
BLOCKED_USER_ROW = '• {name} (ID: <code>{telegram_id}</code>)\n'
|
||||
|
||||
CLEANUP_CONFIRM_TITLE = '⚠️ <b>Подтверждение действия</b>\n\n'
|
||||
CLEANUP_CONFIRM_DELETE_DB = (
|
||||
'Вы собираетесь <b>удалить из БД</b> {count} пользователей.\n'
|
||||
'Это действие необратимо!\n\n'
|
||||
'Будут удалены:\n'
|
||||
'• Профили пользователей\n'
|
||||
'• Подписки\n'
|
||||
'• Транзакции\n'
|
||||
'• Реферальные данные'
|
||||
)
|
||||
CLEANUP_CONFIRM_DELETE_REMNAWAVE = (
|
||||
'Вы собираетесь <b>удалить из Remnawave</b> {count} пользователей.\nИх VPN доступ будет полностью отключен.'
|
||||
)
|
||||
CLEANUP_CONFIRM_DELETE_BOTH = (
|
||||
'Вы собираетесь <b>полностью удалить</b> {count} пользователей:\n'
|
||||
'• Из базы данных бота\n'
|
||||
'• Из панели Remnawave\n\n'
|
||||
'Это действие необратимо!'
|
||||
)
|
||||
CLEANUP_CONFIRM_MARK = (
|
||||
'Вы собираетесь <b>пометить как заблокированных</b> {count} пользователей.\n'
|
||||
'Они останутся в БД, но будут помечены статусом "blocked".'
|
||||
)
|
||||
|
||||
CLEANUP_PROGRESS = '🗑 <b>Очистка:</b> {processed}/{total}'
|
||||
CLEANUP_COMPLETE = (
|
||||
'✅ <b>Очистка завершена</b>\n\n'
|
||||
'📊 <b>Результаты:</b>\n'
|
||||
'• Удалено из БД: {deleted_db}\n'
|
||||
'• Удалено из Remnawave: {deleted_remnawave}\n'
|
||||
'• Помечено как заблокированные: {marked}\n'
|
||||
'• Ошибок: {errors}'
|
||||
)
|
||||
|
||||
BUTTON_START_SCAN = '🔍 Начать сканирование'
|
||||
BUTTON_VIEW_BLOCKED = '👥 Список заблокированных ({count})'
|
||||
BUTTON_DELETE_DB = '🗑 Удалить из БД'
|
||||
BUTTON_DELETE_REMNAWAVE = '🌐 Удалить из Remnawave'
|
||||
BUTTON_DELETE_BOTH = '💀 Удалить везде'
|
||||
BUTTON_MARK_BLOCKED = '🚫 Пометить как заблокированных'
|
||||
BUTTON_CONFIRM = '✅ Подтвердить'
|
||||
BUTTON_CANCEL = '❌ Отмена'
|
||||
BUTTON_BACK = '⬅️ Назад'
|
||||
BUTTON_BACK_TO_USERS = '⬅️ К пользователям'
|
||||
|
||||
|
||||
class BlockedUsersCallback(Enum):
|
||||
"""Callback data для кнопок модуля."""
|
||||
|
||||
MENU = 'admin_blocked_users'
|
||||
START_SCAN = 'admin_blocked_scan'
|
||||
VIEW_LIST = 'admin_blocked_list'
|
||||
VIEW_LIST_PAGE = 'admin_blocked_list_page_'
|
||||
ACTION_DELETE_DB = 'admin_blocked_action_db'
|
||||
ACTION_DELETE_REMNAWAVE = 'admin_blocked_action_rw'
|
||||
ACTION_DELETE_BOTH = 'admin_blocked_action_both'
|
||||
ACTION_MARK = 'admin_blocked_action_mark'
|
||||
CONFIRM_PREFIX = 'admin_blocked_confirm_'
|
||||
CANCEL = 'admin_blocked_cancel'
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# FSM States
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class BlockedUsersStates(StatesGroup):
|
||||
"""Состояния FSM для модуля заблокированных пользователей."""
|
||||
|
||||
scanning = State()
|
||||
viewing_results = State()
|
||||
confirming_action = State()
|
||||
processing_cleanup = State()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Keyboards
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def get_blocked_users_menu_keyboard(
|
||||
scan_result: dict[str, Any] | None = None,
|
||||
) -> InlineKeyboardMarkup:
|
||||
"""Клавиатура главного меню модуля."""
|
||||
buttons = [
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=BlockedUsersText.BUTTON_START_SCAN.value,
|
||||
callback_data=BlockedUsersCallback.START_SCAN.value,
|
||||
)
|
||||
]
|
||||
]
|
||||
|
||||
blocked_count = scan_result.get('blocked_count', 0) if scan_result else 0
|
||||
if blocked_count > 0:
|
||||
buttons.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=BlockedUsersText.BUTTON_VIEW_BLOCKED.value.format(count=blocked_count),
|
||||
callback_data=BlockedUsersCallback.VIEW_LIST.value,
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
buttons.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=BlockedUsersText.BUTTON_BACK_TO_USERS.value,
|
||||
callback_data='admin_users',
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
|
||||
def get_blocked_list_keyboard(
|
||||
page: int = 1,
|
||||
total_pages: int = 1,
|
||||
has_blocked: bool = True,
|
||||
) -> InlineKeyboardMarkup:
|
||||
"""Клавиатура списка заблокированных пользователей."""
|
||||
buttons = []
|
||||
|
||||
# Пагинация
|
||||
if total_pages > 1:
|
||||
nav_row = []
|
||||
if page > 1:
|
||||
nav_row.append(
|
||||
InlineKeyboardButton(
|
||||
text='⬅️',
|
||||
callback_data=f'{BlockedUsersCallback.VIEW_LIST_PAGE.value}{page - 1}',
|
||||
)
|
||||
)
|
||||
nav_row.append(
|
||||
InlineKeyboardButton(
|
||||
text=f'{page}/{total_pages}',
|
||||
callback_data='noop',
|
||||
)
|
||||
)
|
||||
if page < total_pages:
|
||||
nav_row.append(
|
||||
InlineKeyboardButton(
|
||||
text='➡️',
|
||||
callback_data=f'{BlockedUsersCallback.VIEW_LIST_PAGE.value}{page + 1}',
|
||||
)
|
||||
)
|
||||
buttons.append(nav_row)
|
||||
|
||||
# Действия
|
||||
if has_blocked:
|
||||
buttons.extend(
|
||||
[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=BlockedUsersText.BUTTON_DELETE_DB.value,
|
||||
callback_data=BlockedUsersCallback.ACTION_DELETE_DB.value,
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text=BlockedUsersText.BUTTON_DELETE_REMNAWAVE.value,
|
||||
callback_data=BlockedUsersCallback.ACTION_DELETE_REMNAWAVE.value,
|
||||
),
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=BlockedUsersText.BUTTON_DELETE_BOTH.value,
|
||||
callback_data=BlockedUsersCallback.ACTION_DELETE_BOTH.value,
|
||||
),
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=BlockedUsersText.BUTTON_MARK_BLOCKED.value,
|
||||
callback_data=BlockedUsersCallback.ACTION_MARK.value,
|
||||
),
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
buttons.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=BlockedUsersText.BUTTON_BACK.value,
|
||||
callback_data=BlockedUsersCallback.MENU.value,
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
|
||||
def get_confirm_keyboard(action: BlockedUserAction) -> InlineKeyboardMarkup:
|
||||
"""Клавиатура подтверждения действия."""
|
||||
action_map = {
|
||||
BlockedUserAction.DELETE_FROM_DB: 'db',
|
||||
BlockedUserAction.DELETE_FROM_REMNAWAVE: 'rw',
|
||||
BlockedUserAction.DELETE_BOTH: 'both',
|
||||
BlockedUserAction.MARK_AS_BLOCKED: 'mark',
|
||||
}
|
||||
|
||||
return InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=BlockedUsersText.BUTTON_CONFIRM.value,
|
||||
callback_data=f'{BlockedUsersCallback.CONFIRM_PREFIX.value}{action_map[action]}',
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text=BlockedUsersText.BUTTON_CANCEL.value,
|
||||
callback_data=BlockedUsersCallback.CANCEL.value,
|
||||
),
|
||||
]
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Handlers
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_blocked_users_menu(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
) -> None:
|
||||
"""Показывает главное меню модуля заблокированных пользователей."""
|
||||
data = await state.get_data()
|
||||
scan_result = data.get('blocked_users_scan_result')
|
||||
|
||||
text = BlockedUsersText.MENU_TITLE.value + BlockedUsersText.MENU_DESCRIPTION.value
|
||||
|
||||
if scan_result:
|
||||
text += (
|
||||
f'\n\n📊 <b>Последнее сканирование:</b>\n'
|
||||
f'• Заблокированных: {scan_result.get("blocked_count", 0)}\n'
|
||||
f'• Активных: {scan_result.get("active_users", 0)}'
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
parse_mode=ParseMode.HTML,
|
||||
reply_markup=get_blocked_users_menu_keyboard(scan_result),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_scan(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
bot: Bot,
|
||||
) -> None:
|
||||
"""Запускает сканирование пользователей."""
|
||||
await state.set_state(BlockedUsersStates.scanning)
|
||||
|
||||
# Отправляем начальное сообщение
|
||||
await callback.message.edit_text(
|
||||
BlockedUsersText.SCAN_STARTED.value,
|
||||
parse_mode=ParseMode.HTML,
|
||||
)
|
||||
|
||||
service = BlockedUsersService(bot)
|
||||
last_update_time = datetime.now(tz=UTC)
|
||||
|
||||
async def progress_callback(checked: int, total: int) -> None:
|
||||
nonlocal last_update_time
|
||||
now = datetime.now(tz=UTC)
|
||||
# Обновляем сообщение не чаще раза в 3 секунды
|
||||
if (now - last_update_time).total_seconds() >= 3:
|
||||
last_update_time = now
|
||||
percent = int(checked / total * 100) if total > 0 else 0
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
BlockedUsersText.SCAN_PROGRESS.value.format(
|
||||
checked=checked,
|
||||
total=total,
|
||||
percent=percent,
|
||||
),
|
||||
parse_mode=ParseMode.HTML,
|
||||
)
|
||||
except Exception:
|
||||
pass # Игнорируем ошибки обновления сообщения
|
||||
|
||||
# Выполняем сканирование
|
||||
result = await service.scan_all_users(
|
||||
db,
|
||||
only_active=True,
|
||||
progress_callback=progress_callback,
|
||||
)
|
||||
|
||||
# Сериализуем результат в dict для Redis и keyboard
|
||||
scan_result_dict = {
|
||||
'total_checked': result.total_checked,
|
||||
'blocked_count': result.blocked_count,
|
||||
'active_users': result.active_users,
|
||||
'errors': result.errors,
|
||||
'skipped_no_telegram': result.skipped_no_telegram,
|
||||
'scan_duration_seconds': result.scan_duration_seconds,
|
||||
}
|
||||
|
||||
# Сохраняем результат в state
|
||||
await state.update_data(
|
||||
blocked_users_scan_result=scan_result_dict,
|
||||
blocked_users_list=[
|
||||
{
|
||||
'user_id': u.user_id,
|
||||
'telegram_id': u.telegram_id,
|
||||
'username': u.username,
|
||||
'full_name': u.full_name,
|
||||
'remnawave_uuid': u.remnawave_uuid,
|
||||
}
|
||||
for u in result.blocked_users
|
||||
],
|
||||
)
|
||||
|
||||
await state.set_state(BlockedUsersStates.viewing_results)
|
||||
|
||||
# Формируем итоговое сообщение
|
||||
if result.blocked_count == 0:
|
||||
text = BlockedUsersText.SCAN_NO_BLOCKED.value
|
||||
else:
|
||||
text = BlockedUsersText.SCAN_COMPLETE.value.format(
|
||||
total_checked=result.total_checked,
|
||||
blocked_count=result.blocked_count,
|
||||
active_users=result.active_users,
|
||||
errors=result.errors,
|
||||
skipped=result.skipped_no_telegram,
|
||||
duration=result.scan_duration_seconds,
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
parse_mode=ParseMode.HTML,
|
||||
reply_markup=get_blocked_users_menu_keyboard(scan_result_dict),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_blocked_list(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
page: int = 1,
|
||||
) -> None:
|
||||
"""Показывает список заблокированных пользователей."""
|
||||
data = await state.get_data()
|
||||
blocked_list: list[dict[str, Any]] = data.get('blocked_users_list', [])
|
||||
|
||||
if not blocked_list:
|
||||
await callback.answer('Нет заблокированных пользователей', show_alert=True)
|
||||
return
|
||||
|
||||
# Пагинация
|
||||
per_page = 15
|
||||
total_pages = (len(blocked_list) + per_page - 1) // per_page
|
||||
page = max(1, min(page, total_pages))
|
||||
start_idx = (page - 1) * per_page
|
||||
end_idx = start_idx + per_page
|
||||
page_users = blocked_list[start_idx:end_idx]
|
||||
|
||||
text = BlockedUsersText.BLOCKED_LIST_TITLE.value.format(count=len(blocked_list))
|
||||
|
||||
for user_data in page_users:
|
||||
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,
|
||||
telegram_id=telegram_id,
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
parse_mode=ParseMode.HTML,
|
||||
reply_markup=get_blocked_list_keyboard(page, total_pages, bool(blocked_list)),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def handle_blocked_list_pagination(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
) -> None:
|
||||
"""Обрабатывает пагинацию списка заблокированных."""
|
||||
try:
|
||||
page = int(callback.data.split('_')[-1])
|
||||
except (ValueError, IndexError):
|
||||
page = 1
|
||||
|
||||
await show_blocked_list(callback, db_user, state, page)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_action_confirm(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
action: BlockedUserAction,
|
||||
) -> None:
|
||||
"""Показывает подтверждение действия."""
|
||||
data = await state.get_data()
|
||||
blocked_list = data.get('blocked_users_list', [])
|
||||
count = len(blocked_list)
|
||||
|
||||
if count == 0:
|
||||
await callback.answer('Нет пользователей для обработки', show_alert=True)
|
||||
return
|
||||
|
||||
await state.set_state(BlockedUsersStates.confirming_action)
|
||||
await state.update_data(pending_action=action.value)
|
||||
|
||||
text = BlockedUsersText.CLEANUP_CONFIRM_TITLE.value
|
||||
|
||||
if action == BlockedUserAction.DELETE_FROM_DB:
|
||||
text += BlockedUsersText.CLEANUP_CONFIRM_DELETE_DB.value.format(count=count)
|
||||
elif action == BlockedUserAction.DELETE_FROM_REMNAWAVE:
|
||||
text += BlockedUsersText.CLEANUP_CONFIRM_DELETE_REMNAWAVE.value.format(count=count)
|
||||
elif action == BlockedUserAction.DELETE_BOTH:
|
||||
text += BlockedUsersText.CLEANUP_CONFIRM_DELETE_BOTH.value.format(count=count)
|
||||
elif action == BlockedUserAction.MARK_AS_BLOCKED:
|
||||
text += BlockedUsersText.CLEANUP_CONFIRM_MARK.value.format(count=count)
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
parse_mode=ParseMode.HTML,
|
||||
reply_markup=get_confirm_keyboard(action),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def handle_action_delete_db(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
) -> None:
|
||||
"""Обрабатывает выбор удаления из БД."""
|
||||
await show_action_confirm(callback, db_user, state, BlockedUserAction.DELETE_FROM_DB)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def handle_action_delete_remnawave(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
) -> None:
|
||||
"""Обрабатывает выбор удаления из Remnawave."""
|
||||
await show_action_confirm(callback, db_user, state, BlockedUserAction.DELETE_FROM_REMNAWAVE)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def handle_action_delete_both(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
) -> None:
|
||||
"""Обрабатывает выбор полного удаления."""
|
||||
await show_action_confirm(callback, db_user, state, BlockedUserAction.DELETE_BOTH)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def handle_action_mark(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
) -> None:
|
||||
"""Обрабатывает выбор пометки как заблокированных."""
|
||||
await show_action_confirm(callback, db_user, state, BlockedUserAction.MARK_AS_BLOCKED)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def handle_confirm_action(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
bot: Bot,
|
||||
) -> None:
|
||||
"""Выполняет подтвержденное действие."""
|
||||
data = await state.get_data()
|
||||
blocked_list = data.get('blocked_users_list', [])
|
||||
|
||||
# Определяем действие из callback_data
|
||||
action_code = callback.data.replace(BlockedUsersCallback.CONFIRM_PREFIX.value, '')
|
||||
action_map = {
|
||||
'db': BlockedUserAction.DELETE_FROM_DB,
|
||||
'rw': BlockedUserAction.DELETE_FROM_REMNAWAVE,
|
||||
'both': BlockedUserAction.DELETE_BOTH,
|
||||
'mark': BlockedUserAction.MARK_AS_BLOCKED,
|
||||
}
|
||||
action = action_map.get(action_code)
|
||||
|
||||
if not action:
|
||||
await callback.answer('Неизвестное действие', show_alert=True)
|
||||
return
|
||||
|
||||
if not blocked_list:
|
||||
await callback.answer('Нет пользователей для обработки', show_alert=True)
|
||||
return
|
||||
|
||||
await state.set_state(BlockedUsersStates.processing_cleanup)
|
||||
|
||||
# Преобразуем обратно в BlockCheckResult
|
||||
blocked_results = [
|
||||
BlockCheckResult(
|
||||
user_id=u['user_id'],
|
||||
telegram_id=u['telegram_id'],
|
||||
username=u['username'],
|
||||
full_name=u['full_name'],
|
||||
status=None, # type: ignore
|
||||
remnawave_uuid=u['remnawave_uuid'],
|
||||
)
|
||||
for u in blocked_list
|
||||
]
|
||||
|
||||
service = BlockedUsersService(bot)
|
||||
last_update_time = datetime.now(tz=UTC)
|
||||
|
||||
async def progress_callback(processed: int, total_count: int) -> None:
|
||||
nonlocal last_update_time
|
||||
now = datetime.now(tz=UTC)
|
||||
if (now - last_update_time).total_seconds() >= 2:
|
||||
last_update_time = now
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
BlockedUsersText.CLEANUP_PROGRESS.value.format(
|
||||
processed=processed,
|
||||
total=total_count,
|
||||
),
|
||||
parse_mode=ParseMode.HTML,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Выполняем очистку
|
||||
result = await service.cleanup_blocked_users(
|
||||
db,
|
||||
blocked_results,
|
||||
action,
|
||||
progress_callback=progress_callback,
|
||||
)
|
||||
|
||||
# Очищаем сохраненные данные
|
||||
await state.update_data(
|
||||
blocked_users_scan_result=None,
|
||||
blocked_users_list=[],
|
||||
pending_action=None,
|
||||
)
|
||||
await state.set_state(None)
|
||||
|
||||
# Показываем результат
|
||||
text = BlockedUsersText.CLEANUP_COMPLETE.value.format(
|
||||
deleted_db=result.deleted_from_db,
|
||||
deleted_remnawave=result.deleted_from_remnawave,
|
||||
marked=result.marked_as_blocked,
|
||||
errors=len(result.errors),
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
parse_mode=ParseMode.HTML,
|
||||
reply_markup=get_blocked_users_menu_keyboard(),
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f'Очистка заблокированных пользователей завершена: '
|
||||
f'DB={result.deleted_from_db}, RW={result.deleted_from_remnawave}, '
|
||||
f'marked={result.marked_as_blocked}, errors={len(result.errors)}'
|
||||
)
|
||||
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def handle_cancel(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
) -> None:
|
||||
"""Отменяет текущее действие и возвращает в меню."""
|
||||
await state.update_data(pending_action=None)
|
||||
await state.set_state(BlockedUsersStates.viewing_results)
|
||||
await show_blocked_users_menu(callback, db_user, state)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Registration
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher) -> None:
|
||||
"""Регистрирует хендлеры модуля заблокированных пользователей."""
|
||||
|
||||
# Главное меню
|
||||
dp.callback_query.register(
|
||||
show_blocked_users_menu,
|
||||
F.data == BlockedUsersCallback.MENU.value,
|
||||
)
|
||||
|
||||
# Сканирование
|
||||
dp.callback_query.register(
|
||||
start_scan,
|
||||
F.data == BlockedUsersCallback.START_SCAN.value,
|
||||
)
|
||||
|
||||
# Список заблокированных
|
||||
dp.callback_query.register(
|
||||
show_blocked_list,
|
||||
F.data == BlockedUsersCallback.VIEW_LIST.value,
|
||||
)
|
||||
|
||||
# Пагинация списка
|
||||
dp.callback_query.register(
|
||||
handle_blocked_list_pagination,
|
||||
F.data.startswith(BlockedUsersCallback.VIEW_LIST_PAGE.value),
|
||||
)
|
||||
|
||||
# Выбор действий
|
||||
dp.callback_query.register(
|
||||
handle_action_delete_db,
|
||||
F.data == BlockedUsersCallback.ACTION_DELETE_DB.value,
|
||||
)
|
||||
dp.callback_query.register(
|
||||
handle_action_delete_remnawave,
|
||||
F.data == BlockedUsersCallback.ACTION_DELETE_REMNAWAVE.value,
|
||||
)
|
||||
dp.callback_query.register(
|
||||
handle_action_delete_both,
|
||||
F.data == BlockedUsersCallback.ACTION_DELETE_BOTH.value,
|
||||
)
|
||||
dp.callback_query.register(
|
||||
handle_action_mark,
|
||||
F.data == BlockedUsersCallback.ACTION_MARK.value,
|
||||
)
|
||||
|
||||
# Подтверждение действий
|
||||
dp.callback_query.register(
|
||||
handle_confirm_action,
|
||||
F.data.startswith(BlockedUsersCallback.CONFIRM_PREFIX.value),
|
||||
)
|
||||
|
||||
# Отмена
|
||||
dp.callback_query.register(
|
||||
handle_cancel,
|
||||
F.data == BlockedUsersCallback.CANCEL.value,
|
||||
)
|
||||
@@ -9,15 +9,19 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.referral_contest import (
|
||||
add_virtual_participant,
|
||||
create_referral_contest,
|
||||
delete_referral_contest,
|
||||
delete_virtual_participant,
|
||||
get_contest_events_count,
|
||||
get_contest_leaderboard,
|
||||
get_contest_leaderboard_with_virtual,
|
||||
get_referral_contest,
|
||||
get_referral_contests_count,
|
||||
list_referral_contests,
|
||||
list_virtual_participants,
|
||||
toggle_referral_contest,
|
||||
update_referral_contest,
|
||||
update_virtual_participant_count,
|
||||
)
|
||||
from app.keyboards.admin import (
|
||||
get_admin_contests_keyboard,
|
||||
@@ -240,8 +244,10 @@ async def show_contest_details(
|
||||
return
|
||||
|
||||
tz = _ensure_timezone(contest.timezone or settings.TIMEZONE)
|
||||
leaderboard = await get_contest_leaderboard(db, contest.id, limit=5)
|
||||
total_events = await get_contest_events_count(db, contest.id)
|
||||
leaderboard = await get_contest_leaderboard_with_virtual(db, contest.id, limit=5)
|
||||
virtual_list = await list_virtual_participants(db, contest.id)
|
||||
virtual_count = sum(vp.referral_count for vp in virtual_list)
|
||||
total_events = await get_contest_events_count(db, contest.id) + virtual_count
|
||||
|
||||
lines = [
|
||||
f'🏆 <b>{contest.title}</b>',
|
||||
@@ -256,8 +262,9 @@ async def show_contest_details(
|
||||
if leaderboard:
|
||||
lines.append('')
|
||||
lines.append(texts.t('ADMIN_CONTEST_LEADERBOARD_TITLE', '📊 Топ участников:'))
|
||||
for idx, (user, score, _) in enumerate(leaderboard, start=1):
|
||||
lines.append(f'{idx}. {user.full_name} — {score}')
|
||||
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}')
|
||||
|
||||
await callback.message.edit_text(
|
||||
'\n'.join(lines),
|
||||
@@ -427,7 +434,7 @@ async def show_leaderboard(
|
||||
await callback.answer(texts.t('ADMIN_CONTEST_NOT_FOUND', 'Конкурс не найден.'), show_alert=True)
|
||||
return
|
||||
|
||||
leaderboard = await get_contest_leaderboard(db, contest_id, limit=10)
|
||||
leaderboard = await get_contest_leaderboard_with_virtual(db, contest_id, limit=10)
|
||||
if not leaderboard:
|
||||
await callback.answer(texts.t('ADMIN_CONTEST_EMPTY_LEADERBOARD', 'Пока нет участников.'), show_alert=True)
|
||||
return
|
||||
@@ -435,9 +442,9 @@ async def show_leaderboard(
|
||||
lines = [
|
||||
texts.t('ADMIN_CONTEST_LEADERBOARD_TITLE', '📊 Топ участников:'),
|
||||
]
|
||||
for idx, (user, score, _) in enumerate(leaderboard, start=1):
|
||||
user_id_display = user.telegram_id or user.email or f'#{user.id}'
|
||||
lines.append(f'{idx}. {user.full_name} ({user_id_display}) — {score}')
|
||||
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}')
|
||||
|
||||
await callback.message.edit_text(
|
||||
'\n'.join(lines),
|
||||
@@ -676,6 +683,9 @@ async def show_detailed_stats(
|
||||
from app.services.referral_contest_service import referral_contest_service
|
||||
|
||||
stats = await referral_contest_service.get_detailed_contest_stats(db, contest_id)
|
||||
virtual = await list_virtual_participants(db, contest_id)
|
||||
virtual_count = len(virtual)
|
||||
virtual_referrals = sum(vp.referral_count for vp in virtual)
|
||||
|
||||
# Общее сообщение с основной статистикой
|
||||
general_lines = [
|
||||
@@ -693,6 +703,10 @@ async def show_detailed_stats(
|
||||
f' 📥 Пополнения баланса: <b>{stats.get("deposit_total", 0) // 100} руб.</b>',
|
||||
]
|
||||
|
||||
if virtual_count > 0:
|
||||
general_lines.append('')
|
||||
general_lines.append(f'👻 Виртуальных: <b>{virtual_count}</b> (рефералов: {virtual_referrals})')
|
||||
|
||||
await callback.message.edit_text(
|
||||
'\n'.join(general_lines),
|
||||
reply_markup=get_referral_contest_manage_keyboard(
|
||||
@@ -970,6 +984,439 @@ async def debug_contest_transactions(
|
||||
)
|
||||
|
||||
|
||||
# ── Виртуальные участники ──────────────────────────────────────────────
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_virtual_participants(
|
||||
callback: types.CallbackQuery,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
):
|
||||
contest_id = int(callback.data.split('_')[-1])
|
||||
contest = await get_referral_contest(db, contest_id)
|
||||
if not contest:
|
||||
await callback.answer('Конкурс не найден.', show_alert=True)
|
||||
return
|
||||
|
||||
vps = await list_virtual_participants(db, contest_id)
|
||||
|
||||
lines = [f'👻 <b>Виртуальные участники</b> — {contest.title}', '']
|
||||
if vps:
|
||||
for vp in vps:
|
||||
lines.append(f'• {vp.display_name} — {vp.referral_count} реф.')
|
||||
else:
|
||||
lines.append('Пока нет виртуальных участников.')
|
||||
|
||||
rows = [
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text='➕ Добавить',
|
||||
callback_data=f'admin_contest_vp_add_{contest_id}',
|
||||
),
|
||||
types.InlineKeyboardButton(
|
||||
text='🎭 Массовка',
|
||||
callback_data=f'admin_contest_vp_mass_{contest_id}',
|
||||
),
|
||||
],
|
||||
]
|
||||
if vps:
|
||||
for vp in vps:
|
||||
rows.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=f'✏️ {vp.display_name}',
|
||||
callback_data=f'admin_contest_vp_edit_{vp.id}',
|
||||
),
|
||||
types.InlineKeyboardButton(
|
||||
text='🗑',
|
||||
callback_data=f'admin_contest_vp_del_{vp.id}',
|
||||
),
|
||||
]
|
||||
)
|
||||
rows.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text='⬅️ Назад',
|
||||
callback_data=f'admin_contest_view_{contest_id}',
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
'\n'.join(lines),
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=rows),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_add_virtual_participant(
|
||||
callback: types.CallbackQuery,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
contest_id = int(callback.data.split('_')[-1])
|
||||
await state.set_state(AdminStates.adding_virtual_participant_name)
|
||||
await state.update_data(vp_contest_id=contest_id)
|
||||
await callback.message.edit_text(
|
||||
'👻 Введите отображаемое имя виртуального участника:',
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text='❌ Отмена', callback_data=f'admin_contest_vp_{contest_id}')],
|
||||
]
|
||||
),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_virtual_participant_name(
|
||||
message: types.Message,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
name = message.text.strip()
|
||||
if not name or len(name) > 200:
|
||||
await message.answer('Имя должно быть от 1 до 200 символов. Попробуйте ещё раз:')
|
||||
return
|
||||
await state.update_data(vp_name=name)
|
||||
await state.set_state(AdminStates.adding_virtual_participant_count)
|
||||
await message.answer(f'Имя: <b>{name}</b>\n\nВведите количество рефералов (число):')
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_virtual_participant_count(
|
||||
message: types.Message,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
try:
|
||||
count = int(message.text.strip())
|
||||
if count < 1:
|
||||
raise ValueError
|
||||
except (ValueError, TypeError):
|
||||
await message.answer('Введите положительное целое число:')
|
||||
return
|
||||
|
||||
data = await state.get_data()
|
||||
contest_id = data['vp_contest_id']
|
||||
display_name = data['vp_name']
|
||||
await state.clear()
|
||||
|
||||
vp = await add_virtual_participant(db, contest_id, display_name, count)
|
||||
await message.answer(
|
||||
f'✅ Виртуальный участник добавлен:\nИмя: <b>{vp.display_name}</b>\nРефералов: <b>{vp.referral_count}</b>',
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text='👻 К списку', callback_data=f'admin_contest_vp_{contest_id}')],
|
||||
[types.InlineKeyboardButton(text='⬅️ К конкурсу', callback_data=f'admin_contest_view_{contest_id}')],
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def delete_virtual_participant_handler(
|
||||
callback: types.CallbackQuery,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
):
|
||||
vp_id = int(callback.data.split('_')[-1])
|
||||
|
||||
# Получим contest_id до удаления
|
||||
from sqlalchemy import select as sa_select
|
||||
|
||||
from app.database.models import ReferralContestVirtualParticipant
|
||||
|
||||
result = await db.execute(
|
||||
sa_select(ReferralContestVirtualParticipant).where(ReferralContestVirtualParticipant.id == vp_id)
|
||||
)
|
||||
vp = result.scalar_one_or_none()
|
||||
if not vp:
|
||||
await callback.answer('Участник не найден.', show_alert=True)
|
||||
return
|
||||
|
||||
contest_id = vp.contest_id
|
||||
deleted = await delete_virtual_participant(db, vp_id)
|
||||
if deleted:
|
||||
await callback.answer('✅ Удалён', show_alert=False)
|
||||
else:
|
||||
await callback.answer('Не удалось удалить.', show_alert=True)
|
||||
|
||||
# Вернуться к списку
|
||||
vps = await list_virtual_participants(db, contest_id)
|
||||
contest = await get_referral_contest(db, contest_id)
|
||||
|
||||
lines = [f'👻 <b>Виртуальные участники</b> — {contest.title}', '']
|
||||
if vps:
|
||||
for v in vps:
|
||||
lines.append(f'• {v.display_name} — {v.referral_count} реф.')
|
||||
else:
|
||||
lines.append('Пока нет виртуальных участников.')
|
||||
|
||||
rows = [
|
||||
[
|
||||
types.InlineKeyboardButton(text='➕ Добавить', callback_data=f'admin_contest_vp_add_{contest_id}'),
|
||||
types.InlineKeyboardButton(text='🎭 Массовка', callback_data=f'admin_contest_vp_mass_{contest_id}'),
|
||||
],
|
||||
]
|
||||
if vps:
|
||||
for v in vps:
|
||||
rows.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=f'✏️ {v.display_name}', callback_data=f'admin_contest_vp_edit_{v.id}'
|
||||
),
|
||||
types.InlineKeyboardButton(text='🗑', callback_data=f'admin_contest_vp_del_{v.id}'),
|
||||
]
|
||||
)
|
||||
rows.append([types.InlineKeyboardButton(text='⬅️ Назад', callback_data=f'admin_contest_view_{contest_id}')])
|
||||
|
||||
await callback.message.edit_text(
|
||||
'\n'.join(lines),
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=rows),
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_mass_virtual_participants(
|
||||
callback: types.CallbackQuery,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
"""Начинает массовое создание виртуальных участников (массовка)."""
|
||||
contest_id = int(callback.data.split('_')[-1])
|
||||
await state.set_state(AdminStates.adding_mass_virtual_count)
|
||||
await state.update_data(mass_vp_contest_id=contest_id)
|
||||
|
||||
text = """
|
||||
🎭 <b>Массовка — массовое создание виртуальных участников</b>
|
||||
|
||||
<i>Для чего это нужно?</i>
|
||||
Виртуальные участники (призраки) позволяют создать видимость активности в конкурсе. Они отображаются в таблице лидеров наравне с реальными участниками, но помечаются значком 👻.
|
||||
|
||||
Это помогает:
|
||||
• Мотивировать реальных участников соревноваться
|
||||
• Задать планку для участия
|
||||
• Сделать конкурс более живым
|
||||
|
||||
<b>Введите количество призраков для создания:</b>
|
||||
<i>(от 1 до 50)</i>
|
||||
"""
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text='❌ Отмена', callback_data=f'admin_contest_vp_{contest_id}')],
|
||||
]
|
||||
),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_mass_virtual_count(
|
||||
message: types.Message,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
"""Обрабатывает количество призраков для массового создания."""
|
||||
try:
|
||||
count = int(message.text.strip())
|
||||
if count < 1 or count > 50:
|
||||
await message.answer(
|
||||
'❌ Введите число от 1 до 50:',
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text='❌ Отмена', callback_data='admin_contests_ref')],
|
||||
]
|
||||
),
|
||||
)
|
||||
return
|
||||
except ValueError:
|
||||
await message.answer(
|
||||
'❌ Введите корректное число от 1 до 50:',
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text='❌ Отмена', callback_data='admin_contests_ref')],
|
||||
]
|
||||
),
|
||||
)
|
||||
return
|
||||
|
||||
await state.update_data(mass_vp_count=count)
|
||||
await state.set_state(AdminStates.adding_mass_virtual_referrals)
|
||||
|
||||
data = await state.get_data()
|
||||
contest_id = data.get('mass_vp_contest_id')
|
||||
|
||||
await message.answer(
|
||||
f'✅ Будет создано <b>{count}</b> призраков.\n\n'
|
||||
f'<b>Введите количество рефералов у каждого:</b>\n'
|
||||
f'<i>(от 1 до 100)</i>',
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text='❌ Отмена', callback_data=f'admin_contest_vp_{contest_id}')],
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_mass_virtual_referrals(
|
||||
message: types.Message,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
"""Создаёт массовку призраков с рандомными именами."""
|
||||
import random
|
||||
import string
|
||||
|
||||
try:
|
||||
referrals_count = int(message.text.strip())
|
||||
if referrals_count < 1 or referrals_count > 100:
|
||||
await message.answer('❌ Введите число от 1 до 100:')
|
||||
return
|
||||
except ValueError:
|
||||
await message.answer('❌ Введите корректное число от 1 до 100:')
|
||||
return
|
||||
|
||||
data = await state.get_data()
|
||||
contest_id = data.get('mass_vp_contest_id')
|
||||
ghost_count = data.get('mass_vp_count', 1)
|
||||
|
||||
await state.clear()
|
||||
|
||||
# Генерируем и создаём призраков
|
||||
created = []
|
||||
for _ in range(ghost_count):
|
||||
# Рандомное имя до 5 символов (буквы + цифры)
|
||||
name_length = random.randint(3, 5)
|
||||
name = ''.join(random.choices(string.ascii_letters + string.digits, k=name_length))
|
||||
|
||||
vp = await add_virtual_participant(db, contest_id, name, referrals_count)
|
||||
created.append(vp)
|
||||
|
||||
# Показываем результат
|
||||
text = f"""
|
||||
✅ <b>Массовка создана!</b>
|
||||
|
||||
📊 <b>Результат:</b>
|
||||
• Создано призраков: {len(created)}
|
||||
• Рефералов у каждого: {referrals_count}
|
||||
• Всего виртуальных рефералов: {len(created) * referrals_count}
|
||||
|
||||
👻 <b>Созданные призраки:</b>
|
||||
"""
|
||||
for vp in created[:10]:
|
||||
text += f'• {vp.display_name} — {vp.referral_count} реф.\n'
|
||||
|
||||
if len(created) > 10:
|
||||
text += f'<i>... и ещё {len(created) - 10}</i>\n'
|
||||
|
||||
await message.answer(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text='👻 К списку призраков', callback_data=f'admin_contest_vp_{contest_id}'
|
||||
)
|
||||
],
|
||||
[types.InlineKeyboardButton(text='⬅️ К конкурсу', callback_data=f'admin_contest_view_{contest_id}')],
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_edit_virtual_participant(
|
||||
callback: types.CallbackQuery,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
vp_id = int(callback.data.split('_')[-1])
|
||||
|
||||
from sqlalchemy import select as sa_select
|
||||
|
||||
from app.database.models import ReferralContestVirtualParticipant
|
||||
|
||||
result = await db.execute(
|
||||
sa_select(ReferralContestVirtualParticipant).where(ReferralContestVirtualParticipant.id == vp_id)
|
||||
)
|
||||
vp = result.scalar_one_or_none()
|
||||
if not vp:
|
||||
await callback.answer('Участник не найден.', show_alert=True)
|
||||
return
|
||||
|
||||
await state.set_state(AdminStates.editing_virtual_participant_count)
|
||||
await state.update_data(vp_edit_id=vp_id, vp_edit_contest_id=vp.contest_id)
|
||||
await callback.message.edit_text(
|
||||
f'✏️ <b>{vp.display_name}</b>\n'
|
||||
f'Текущее кол-во рефералов: <b>{vp.referral_count}</b>\n\n'
|
||||
f'Введите новое количество:',
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text='❌ Отмена', callback_data=f'admin_contest_vp_{vp.contest_id}')],
|
||||
]
|
||||
),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_edit_virtual_participant_count(
|
||||
message: types.Message,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
try:
|
||||
count = int(message.text.strip())
|
||||
if count < 1:
|
||||
raise ValueError
|
||||
except (ValueError, TypeError):
|
||||
await message.answer('Введите положительное целое число:')
|
||||
return
|
||||
|
||||
data = await state.get_data()
|
||||
vp_id = data['vp_edit_id']
|
||||
contest_id = data['vp_edit_contest_id']
|
||||
await state.clear()
|
||||
|
||||
vp = await update_virtual_participant_count(db, vp_id, count)
|
||||
if vp:
|
||||
await message.answer(
|
||||
f'✅ Обновлено: <b>{vp.display_name}</b> — {vp.referral_count} реф.',
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text='👻 К списку', callback_data=f'admin_contest_vp_{contest_id}')],
|
||||
]
|
||||
),
|
||||
)
|
||||
else:
|
||||
await message.answer('Участник не найден.')
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(show_contests_menu, F.data == 'admin_contests')
|
||||
dp.callback_query.register(show_referral_contests_menu, F.data == 'admin_contests_referral')
|
||||
@@ -996,3 +1443,14 @@ def register_handlers(dp: Dispatcher):
|
||||
dp.message.register(process_end_date, AdminStates.creating_referral_contest_end)
|
||||
dp.message.register(finalize_contest_creation, AdminStates.creating_referral_contest_time)
|
||||
dp.message.register(process_edit_summary_times, AdminStates.editing_referral_contest_summary_times)
|
||||
|
||||
dp.callback_query.register(start_add_virtual_participant, F.data.startswith('admin_contest_vp_add_'))
|
||||
dp.callback_query.register(delete_virtual_participant_handler, F.data.startswith('admin_contest_vp_del_'))
|
||||
dp.callback_query.register(start_edit_virtual_participant, F.data.startswith('admin_contest_vp_edit_'))
|
||||
dp.callback_query.register(start_mass_virtual_participants, F.data.startswith('admin_contest_vp_mass_'))
|
||||
dp.callback_query.register(show_virtual_participants, F.data.regexp(r'^admin_contest_vp_\d+$'))
|
||||
dp.message.register(process_virtual_participant_name, AdminStates.adding_virtual_participant_name)
|
||||
dp.message.register(process_virtual_participant_count, AdminStates.adding_virtual_participant_count)
|
||||
dp.message.register(process_edit_virtual_participant_count, AdminStates.editing_virtual_participant_count)
|
||||
dp.message.register(process_mass_virtual_count, AdminStates.adding_mass_virtual_count)
|
||||
dp.message.register(process_mass_virtual_referrals, AdminStates.adding_mass_virtual_referrals)
|
||||
|
||||
+295
-143
@@ -127,58 +127,78 @@ def create_broadcast_keyboard(selected_buttons: list, language: str = 'ru') -> t
|
||||
|
||||
|
||||
async def _persist_broadcast_result(
|
||||
db: AsyncSession,
|
||||
broadcast_history: BroadcastHistory,
|
||||
broadcast_id: int,
|
||||
sent_count: int,
|
||||
failed_count: int,
|
||||
status: str,
|
||||
) -> None:
|
||||
"""Сохраняет результаты рассылки с повторной попыткой при обрыве соединения."""
|
||||
"""
|
||||
Сохраняет результаты рассылки в НОВОЙ сессии.
|
||||
|
||||
# Сохраняем ID и время завершения в локальные переменные ДО операций с БД,
|
||||
# чтобы избежать обращения к атрибутам отсоединенного объекта при потере соединения
|
||||
broadcast_id = broadcast_history.id
|
||||
ВАЖНО: Используем свежую сессию вместо переданной, потому что за время
|
||||
долгой рассылки (минуты/часы) оригинальное соединение гарантированно
|
||||
закроется по таймауту PostgreSQL (idle_in_transaction_session_timeout).
|
||||
|
||||
Args:
|
||||
broadcast_id: ID записи BroadcastHistory (не ORM-объект!)
|
||||
sent_count: Количество успешно отправленных сообщений
|
||||
failed_count: Количество неудачных отправок
|
||||
status: Финальный статус рассылки ('completed', 'partial', 'failed')
|
||||
"""
|
||||
completed_at = datetime.utcnow()
|
||||
max_retries = 3
|
||||
retry_delay = 1.0
|
||||
|
||||
broadcast_history.sent_count = sent_count
|
||||
broadcast_history.failed_count = failed_count
|
||||
broadcast_history.status = status
|
||||
broadcast_history.completed_at = completed_at
|
||||
for attempt in range(1, max_retries + 1):
|
||||
try:
|
||||
async with AsyncSessionLocal() as session:
|
||||
broadcast_history = await session.get(BroadcastHistory, broadcast_id)
|
||||
if not broadcast_history:
|
||||
logger.critical(
|
||||
'Не удалось найти запись BroadcastHistory #%s для записи результатов',
|
||||
broadcast_id,
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
await db.commit()
|
||||
return
|
||||
except InterfaceError as error:
|
||||
logger.warning(
|
||||
'Соединение с БД потеряно при сохранении результатов рассылки, пробуем еще раз',
|
||||
exc_info=error,
|
||||
)
|
||||
await db.rollback()
|
||||
broadcast_history.sent_count = sent_count
|
||||
broadcast_history.failed_count = failed_count
|
||||
broadcast_history.status = status
|
||||
broadcast_history.completed_at = completed_at
|
||||
await session.commit()
|
||||
|
||||
try:
|
||||
async with AsyncSessionLocal() as retry_session:
|
||||
retry_history = await retry_session.get(BroadcastHistory, broadcast_id)
|
||||
if not retry_history:
|
||||
logger.critical(
|
||||
'Не удалось найти запись BroadcastHistory #%s для повторной записи результатов',
|
||||
logger.info(
|
||||
'Результаты рассылки сохранены (id=%s, sent=%d, failed=%d, status=%s)',
|
||||
broadcast_id,
|
||||
sent_count,
|
||||
failed_count,
|
||||
status,
|
||||
)
|
||||
return
|
||||
|
||||
retry_history.sent_count = sent_count
|
||||
retry_history.failed_count = failed_count
|
||||
retry_history.status = status
|
||||
retry_history.completed_at = completed_at
|
||||
await retry_session.commit()
|
||||
logger.info(
|
||||
'Результаты рассылки успешно сохранены после повторного подключения к БД (id=%s)',
|
||||
broadcast_id,
|
||||
except InterfaceError as error:
|
||||
logger.warning(
|
||||
'Ошибка соединения при сохранении результатов рассылки (попытка %d/%d): %s',
|
||||
attempt,
|
||||
max_retries,
|
||||
error,
|
||||
)
|
||||
except Exception as retry_error:
|
||||
logger.critical(
|
||||
'Не удалось сохранить результаты рассылки после восстановления подключения',
|
||||
exc_info=retry_error,
|
||||
)
|
||||
if attempt < max_retries:
|
||||
await asyncio.sleep(retry_delay)
|
||||
retry_delay *= 2
|
||||
else:
|
||||
logger.critical(
|
||||
'Не удалось сохранить результаты рассылки после %d попыток (id=%s)',
|
||||
max_retries,
|
||||
broadcast_id,
|
||||
)
|
||||
|
||||
except Exception as error:
|
||||
logger.critical(
|
||||
'Неожиданная ошибка при сохранении результатов рассылки (id=%s)',
|
||||
broadcast_id,
|
||||
exc_info=error,
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
@admin_required
|
||||
@@ -1113,15 +1133,38 @@ async def confirm_broadcast(callback: types.CallbackQuery, db_user: User, state:
|
||||
media_file_id = data.get('media_file_id')
|
||||
media_caption = data.get('media_caption')
|
||||
|
||||
# =========================================================================
|
||||
# КРИТИЧНО: Извлекаем ВСЕ скалярные значения из ORM-объектов СЕЙЧАС,
|
||||
# пока сессия активна. После начала рассылки соединение с БД может
|
||||
# закрыться по таймауту, и любое обращение к атрибутам ORM вызовет:
|
||||
# - MissingGreenlet (lazy loading вне async контекста)
|
||||
# - InterfaceError (соединение закрыто)
|
||||
# =========================================================================
|
||||
admin_id: int = db_user.id
|
||||
admin_name: str = db_user.full_name # property, читает first_name/last_name
|
||||
admin_telegram_id: int | None = db_user.telegram_id
|
||||
admin_language: str = db_user.language
|
||||
|
||||
await safe_edit_or_send_text(
|
||||
callback, '📨 Начинаю рассылку...\n\n⏳ Это может занять несколько минут.', reply_markup=None, parse_mode='HTML'
|
||||
callback,
|
||||
'📨 <b>Подготовка рассылки...</b>\n\n⏳ Загружаю список получателей...',
|
||||
reply_markup=None,
|
||||
parse_mode='HTML',
|
||||
)
|
||||
|
||||
# Загружаем пользователей и сразу извлекаем telegram_id в список
|
||||
# чтобы не обращаться к ORM-объектам во время долгой рассылки
|
||||
if target.startswith('custom_'):
|
||||
users = await get_custom_users(db, target.replace('custom_', ''))
|
||||
users_orm = await get_custom_users(db, target.replace('custom_', ''))
|
||||
else:
|
||||
users = await get_target_users(db, target)
|
||||
users_orm = await get_target_users(db, target)
|
||||
|
||||
# Извлекаем только telegram_id - это всё что нужно для отправки
|
||||
# Фильтруем None (email-only пользователи)
|
||||
recipient_telegram_ids: list[int] = [user.telegram_id for user in users_orm if user.telegram_id is not None]
|
||||
total_users_count = len(users_orm)
|
||||
|
||||
# Создаём запись истории рассылки
|
||||
broadcast_history = BroadcastHistory(
|
||||
target_type=target,
|
||||
message_text=message_text,
|
||||
@@ -1129,145 +1172,252 @@ async def confirm_broadcast(callback: types.CallbackQuery, db_user: User, state:
|
||||
media_type=media_type,
|
||||
media_file_id=media_file_id,
|
||||
media_caption=media_caption,
|
||||
total_count=len(users),
|
||||
total_count=total_users_count,
|
||||
sent_count=0,
|
||||
failed_count=0,
|
||||
admin_id=db_user.id,
|
||||
admin_name=db_user.full_name,
|
||||
admin_id=admin_id,
|
||||
admin_name=admin_name,
|
||||
status='in_progress',
|
||||
)
|
||||
db.add(broadcast_history)
|
||||
await db.commit()
|
||||
await db.refresh(broadcast_history)
|
||||
|
||||
# Сохраняем ID - это единственное что нам нужно после коммита
|
||||
broadcast_id: int = broadcast_history.id
|
||||
|
||||
# =========================================================================
|
||||
# С этого момента НЕ используем db сессию и ORM-объекты!
|
||||
# Работаем только со скалярными значениями.
|
||||
# =========================================================================
|
||||
|
||||
sent_count = 0
|
||||
failed_count = 0
|
||||
|
||||
broadcast_keyboard = create_broadcast_keyboard(selected_buttons, db_user.language)
|
||||
broadcast_keyboard = create_broadcast_keyboard(selected_buttons, admin_language)
|
||||
|
||||
# Ограничение на количество одновременных отправок и базовая задержка между сообщениями,
|
||||
# чтобы избежать перегрузки бота и лимитов Telegram при больших рассылках
|
||||
max_concurrent_sends = 5
|
||||
per_message_delay = 0.05
|
||||
semaphore = asyncio.Semaphore(max_concurrent_sends)
|
||||
# =========================================================================
|
||||
# Rate limiting: Telegram допускает ~30 msg/sec для бота.
|
||||
# Используем batch_size=25 + 1 сек задержка между батчами = ~25 msg/sec
|
||||
# с запасом, чтобы не получать FloodWait.
|
||||
# Semaphore=25 — все сообщения батча отправляются параллельно.
|
||||
# =========================================================================
|
||||
_BATCH_SIZE = 25
|
||||
_BATCH_DELAY = 1.0 # секунда между батчами
|
||||
_MAX_SEND_RETRIES = 3
|
||||
# Обновляем прогресс каждые N батчей (не каждое сообщение — иначе FloodWait на edit_text)
|
||||
_PROGRESS_UPDATE_INTERVAL = max(1, 500 // _BATCH_SIZE) # ~каждые 500 сообщений
|
||||
# Минимальный интервал между обновлениями прогресса (секунды)
|
||||
_PROGRESS_MIN_INTERVAL = 5.0
|
||||
|
||||
async def send_single_broadcast(user):
|
||||
"""Отправляет одно сообщение рассылки с семафором ограничения"""
|
||||
# Skip email-only users (no telegram_id)
|
||||
if not user.telegram_id:
|
||||
logger.debug('Пропуск email-пользователя %s при рассылке', user.id)
|
||||
return False, None
|
||||
# Глобальная пауза при FloodWait — тормозим ВСЕ отправки, а не один слот семафора
|
||||
flood_wait_until: float = 0.0
|
||||
|
||||
async with semaphore:
|
||||
for attempt in range(3):
|
||||
try:
|
||||
if has_media and media_file_id:
|
||||
if media_type == 'photo':
|
||||
await callback.bot.send_photo(
|
||||
chat_id=user.telegram_id,
|
||||
photo=media_file_id,
|
||||
caption=message_text,
|
||||
parse_mode='HTML',
|
||||
reply_markup=broadcast_keyboard,
|
||||
)
|
||||
elif media_type == 'video':
|
||||
await callback.bot.send_video(
|
||||
chat_id=user.telegram_id,
|
||||
video=media_file_id,
|
||||
caption=message_text,
|
||||
parse_mode='HTML',
|
||||
reply_markup=broadcast_keyboard,
|
||||
)
|
||||
elif media_type == 'document':
|
||||
await callback.bot.send_document(
|
||||
chat_id=user.telegram_id,
|
||||
document=media_file_id,
|
||||
caption=message_text,
|
||||
parse_mode='HTML',
|
||||
reply_markup=broadcast_keyboard,
|
||||
)
|
||||
async def send_single_broadcast(telegram_id: int) -> bool:
|
||||
"""Отправляет одно сообщение. Возвращает True при успехе."""
|
||||
nonlocal flood_wait_until
|
||||
|
||||
for attempt in range(_MAX_SEND_RETRIES):
|
||||
# Глобальная пауза при FloodWait
|
||||
now = asyncio.get_event_loop().time()
|
||||
if flood_wait_until > now:
|
||||
await asyncio.sleep(flood_wait_until - now)
|
||||
|
||||
try:
|
||||
if has_media and media_file_id:
|
||||
send_method = {
|
||||
'photo': callback.bot.send_photo,
|
||||
'video': callback.bot.send_video,
|
||||
'document': callback.bot.send_document,
|
||||
}.get(media_type)
|
||||
if send_method:
|
||||
media_kwarg = {
|
||||
'photo': 'photo',
|
||||
'video': 'video',
|
||||
'document': 'document',
|
||||
}[media_type]
|
||||
await send_method(
|
||||
chat_id=telegram_id,
|
||||
**{media_kwarg: media_file_id},
|
||||
caption=message_text,
|
||||
parse_mode='HTML',
|
||||
reply_markup=broadcast_keyboard,
|
||||
)
|
||||
else:
|
||||
# Неизвестный media_type — отправляем как текст
|
||||
await callback.bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
chat_id=telegram_id,
|
||||
text=message_text,
|
||||
parse_mode='HTML',
|
||||
reply_markup=broadcast_keyboard,
|
||||
)
|
||||
|
||||
await asyncio.sleep(per_message_delay)
|
||||
return True, user.telegram_id
|
||||
except TelegramRetryAfter as e:
|
||||
retry_delay = min(e.retry_after + 1, 30)
|
||||
logger.warning(f'Превышен лимит Telegram для {user.telegram_id}, ожидание {retry_delay} сек.')
|
||||
await asyncio.sleep(retry_delay)
|
||||
except TelegramForbiddenError:
|
||||
# Пользователь мог удалить бота или запретить сообщения
|
||||
logger.info(f'Рассылка недоступна для пользователя {user.telegram_id}: Forbidden')
|
||||
return False, user.telegram_id
|
||||
except TelegramBadRequest as e:
|
||||
logger.error(f'Некорректный запрос при рассылке пользователю {user.telegram_id}: {e}')
|
||||
return False, user.telegram_id
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f'Ошибка отправки рассылки пользователю {user.telegram_id} (попытка {attempt + 1}/3): {e}'
|
||||
else:
|
||||
await callback.bot.send_message(
|
||||
chat_id=telegram_id,
|
||||
text=message_text,
|
||||
parse_mode='HTML',
|
||||
reply_markup=broadcast_keyboard,
|
||||
)
|
||||
return True
|
||||
|
||||
except TelegramRetryAfter as e:
|
||||
# Глобальная пауза — тормозим все корутины
|
||||
wait_seconds = e.retry_after + 1
|
||||
flood_wait_until = asyncio.get_event_loop().time() + wait_seconds
|
||||
logger.warning(
|
||||
'FloodWait: Telegram просит подождать %d сек (пользователь %d, попытка %d/%d)',
|
||||
e.retry_after,
|
||||
telegram_id,
|
||||
attempt + 1,
|
||||
_MAX_SEND_RETRIES,
|
||||
)
|
||||
await asyncio.sleep(wait_seconds)
|
||||
|
||||
except TelegramForbiddenError:
|
||||
return False
|
||||
|
||||
except TelegramBadRequest as e:
|
||||
logger.debug('BadRequest при рассылке пользователю %d: %s', telegram_id, e)
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
'Ошибка отправки пользователю %d (попытка %d/%d): %s',
|
||||
telegram_id,
|
||||
attempt + 1,
|
||||
_MAX_SEND_RETRIES,
|
||||
e,
|
||||
)
|
||||
if attempt < _MAX_SEND_RETRIES - 1:
|
||||
await asyncio.sleep(0.5 * (attempt + 1))
|
||||
|
||||
return False, user.telegram_id
|
||||
return False
|
||||
|
||||
# Отправляем сообщения пакетами для эффективности
|
||||
batch_size = 50
|
||||
for i in range(0, len(users), batch_size):
|
||||
batch = users[i : i + batch_size]
|
||||
tasks = [send_single_broadcast(user) for user in batch]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
# =========================================================================
|
||||
# Прогресс-бар в реальном времени (как в сканере заблокированных)
|
||||
# =========================================================================
|
||||
total_recipients = len(recipient_telegram_ids)
|
||||
last_progress_update: float = 0.0
|
||||
# ID сообщения, которое обновляем (может быть заменено при ошибке)
|
||||
progress_message = callback.message
|
||||
|
||||
def _build_progress_text(
|
||||
current_sent: int,
|
||||
current_failed: int,
|
||||
total: int,
|
||||
phase: str = 'sending',
|
||||
) -> str:
|
||||
processed = current_sent + current_failed
|
||||
percent = round(processed / total * 100, 1) if total > 0 else 0
|
||||
bar_length = 20
|
||||
filled = int(bar_length * processed / total) if total > 0 else 0
|
||||
bar = '█' * filled + '░' * (bar_length - filled)
|
||||
|
||||
if phase == 'sending':
|
||||
return (
|
||||
f'📨 <b>Рассылка в процессе...</b>\n\n'
|
||||
f'[{bar}] {percent}%\n\n'
|
||||
f'📊 <b>Прогресс:</b>\n'
|
||||
f'• Отправлено: {current_sent}\n'
|
||||
f'• Ошибок: {current_failed}\n'
|
||||
f'• Обработано: {processed}/{total}\n\n'
|
||||
f'⏳ Не закрывайте диалог — рассылка продолжается...'
|
||||
)
|
||||
return ''
|
||||
|
||||
async def _update_progress_message(current_sent: int, current_failed: int) -> None:
|
||||
"""Безопасно обновляет сообщение с прогрессом."""
|
||||
nonlocal last_progress_update, progress_message
|
||||
now = asyncio.get_event_loop().time()
|
||||
if now - last_progress_update < _PROGRESS_MIN_INTERVAL:
|
||||
return
|
||||
last_progress_update = now
|
||||
|
||||
text = _build_progress_text(current_sent, current_failed, total_recipients)
|
||||
try:
|
||||
await progress_message.edit_text(text, parse_mode='HTML')
|
||||
except TelegramRetryAfter as e:
|
||||
# Не паникуем — пропускаем обновление прогресса
|
||||
logger.debug('FloodWait при обновлении прогресса, пропускаем: %d сек', e.retry_after)
|
||||
except TelegramBadRequest:
|
||||
# Сообщение удалено или контент не изменился — отправляем новое
|
||||
try:
|
||||
progress_message = await callback.bot.send_message(
|
||||
chat_id=callback.message.chat.id,
|
||||
text=text,
|
||||
parse_mode='HTML',
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass # Не ломаем рассылку из-за ошибок обновления прогресса
|
||||
|
||||
# Первое обновление прогресса
|
||||
await _update_progress_message(0, 0)
|
||||
|
||||
# =========================================================================
|
||||
# Основной цикл рассылки — батчами по _BATCH_SIZE
|
||||
# =========================================================================
|
||||
for batch_idx, i in enumerate(range(0, total_recipients, _BATCH_SIZE)):
|
||||
batch = recipient_telegram_ids[i : i + _BATCH_SIZE]
|
||||
|
||||
# Отправляем батч параллельно
|
||||
results = await asyncio.gather(
|
||||
*[send_single_broadcast(tid) for tid in batch],
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
for result in results:
|
||||
if isinstance(result, tuple): # (success, telegram_id)
|
||||
success, _ = result
|
||||
if success:
|
||||
if isinstance(result, bool):
|
||||
if result:
|
||||
sent_count += 1
|
||||
else:
|
||||
failed_count += 1
|
||||
elif isinstance(result, Exception):
|
||||
failed_count += 1
|
||||
logger.error('Необработанное исключение в рассылке: %s', result)
|
||||
|
||||
# Небольшая задержка между пакетами для снижения нагрузки на API
|
||||
await asyncio.sleep(0.25)
|
||||
# Обновляем прогресс каждые _PROGRESS_UPDATE_INTERVAL батчей
|
||||
if batch_idx % _PROGRESS_UPDATE_INTERVAL == 0:
|
||||
await _update_progress_message(sent_count, failed_count)
|
||||
|
||||
# Задержка между батчами для соблюдения rate limits
|
||||
await asyncio.sleep(_BATCH_DELAY)
|
||||
|
||||
# Учитываем пропущенных email-only пользователей
|
||||
skipped_email_users = total_users_count - total_recipients
|
||||
if skipped_email_users > 0:
|
||||
logger.info('Пропущено %d email-only пользователей при рассылке', skipped_email_users)
|
||||
|
||||
status = 'completed' if failed_count == 0 else 'partial'
|
||||
|
||||
# Сохраняем результат в НОВОЙ сессии (старая уже мертва)
|
||||
await _persist_broadcast_result(
|
||||
db=db,
|
||||
broadcast_history=broadcast_history,
|
||||
broadcast_id=broadcast_id,
|
||||
sent_count=sent_count,
|
||||
failed_count=failed_count,
|
||||
status=status,
|
||||
)
|
||||
|
||||
media_info = ''
|
||||
if has_media:
|
||||
media_info = f'\n🖼️ <b>Медиафайл:</b> {media_type}'
|
||||
success_rate = round(sent_count / total_users_count * 100, 1) if total_users_count else 0
|
||||
media_info = f'\n🖼️ <b>Медиафайл:</b> {media_type}' if has_media else ''
|
||||
|
||||
result_text = f"""
|
||||
✅ <b>Рассылка завершена!</b>
|
||||
result_text = (
|
||||
f'✅ <b>Рассылка завершена!</b>\n\n'
|
||||
f'📊 <b>Результат:</b>\n'
|
||||
f'• Отправлено: {sent_count}\n'
|
||||
f'• Не доставлено: {failed_count}\n'
|
||||
f'• Всего пользователей: {total_users_count}\n'
|
||||
f'• Успешность: {success_rate}%{media_info}\n\n'
|
||||
f'<b>Администратор:</b> {admin_name}'
|
||||
)
|
||||
|
||||
📊 <b>Результат:</b>
|
||||
- Отправлено: {sent_count}
|
||||
- Не доставлено: {failed_count}
|
||||
- Всего пользователей: {len(users)}
|
||||
- Успешность: {round(sent_count / len(users) * 100, 1) if users else 0}%{media_info}
|
||||
|
||||
<b>Администратор:</b> {db_user.full_name}
|
||||
"""
|
||||
back_keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[[types.InlineKeyboardButton(text='📨 К рассылкам', callback_data='admin_messages')]]
|
||||
)
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
result_text,
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[[types.InlineKeyboardButton(text='📨 К рассылкам', callback_data='admin_messages')]]
|
||||
),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
await progress_message.edit_text(result_text, reply_markup=back_keyboard, parse_mode='HTML')
|
||||
except TelegramBadRequest as e:
|
||||
error_msg = str(e).lower()
|
||||
if (
|
||||
@@ -1275,22 +1425,24 @@ async def confirm_broadcast(callback: types.CallbackQuery, db_user: User, state:
|
||||
or 'there is no text' in error_msg
|
||||
or "message can't be edited" in error_msg
|
||||
):
|
||||
# Сообщение удалено или это медиа - отправляем новое
|
||||
await callback.bot.send_message(
|
||||
chat_id=callback.message.chat.id,
|
||||
text=result_text,
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text='📨 К рассылкам', callback_data='admin_messages')]
|
||||
]
|
||||
),
|
||||
reply_markup=back_keyboard,
|
||||
parse_mode='HTML',
|
||||
)
|
||||
else:
|
||||
raise
|
||||
|
||||
await state.clear()
|
||||
logger.info(f'Рассылка выполнена админом {db_user.telegram_id}: {sent_count}/{len(users)} (медиа: {has_media})')
|
||||
logger.info(
|
||||
'Рассылка завершена админом %s: sent=%d, failed=%d, total=%d (медиа: %s)',
|
||||
admin_telegram_id,
|
||||
sent_count,
|
||||
failed_count,
|
||||
total_users_count,
|
||||
has_media,
|
||||
)
|
||||
|
||||
|
||||
async def get_target_users_count(db: AsyncSession, target: str) -> int:
|
||||
|
||||
@@ -42,6 +42,10 @@ def _method_display(method: PaymentMethod) -> str:
|
||||
return 'CryptoBot'
|
||||
if method == PaymentMethod.TELEGRAM_STARS:
|
||||
return 'Telegram Stars'
|
||||
if method == PaymentMethod.KASSA_AI:
|
||||
return settings.get_kassa_ai_display_name()
|
||||
if method == PaymentMethod.FREEKASSA:
|
||||
return settings.get_freekassa_display_name()
|
||||
return method.value
|
||||
|
||||
|
||||
@@ -144,6 +148,18 @@ def _status_info(
|
||||
}
|
||||
return mapping.get(status, ('❓', texts.t('ADMIN_PAYMENT_STATUS_UNKNOWN', '❓ Unknown')))
|
||||
|
||||
if record.method == PaymentMethod.KASSA_AI:
|
||||
mapping = {
|
||||
'pending': ('⏳', texts.t('ADMIN_PAYMENT_STATUS_PENDING', '⏳ Pending')),
|
||||
'created': ('⏳', texts.t('ADMIN_PAYMENT_STATUS_PENDING', '⏳ Pending')),
|
||||
'processing': ('⌛', texts.t('ADMIN_PAYMENT_STATUS_PROCESSING', '⌛ Processing')),
|
||||
'success': ('✅', texts.t('ADMIN_PAYMENT_STATUS_PAID', '✅ Paid')),
|
||||
'paid': ('✅', texts.t('ADMIN_PAYMENT_STATUS_PAID', '✅ Paid')),
|
||||
'canceled': ('❌', texts.t('ADMIN_PAYMENT_STATUS_CANCELED', '❌ Cancelled')),
|
||||
'error': ('❌', texts.t('ADMIN_PAYMENT_STATUS_FAILED', '❌ Failed')),
|
||||
}
|
||||
return mapping.get(status, ('❓', texts.t('ADMIN_PAYMENT_STATUS_UNKNOWN', '❓ Unknown')))
|
||||
|
||||
return '❓', texts.t('ADMIN_PAYMENT_STATUS_UNKNOWN', '❓ Unknown')
|
||||
|
||||
|
||||
@@ -168,7 +184,9 @@ def _is_checkable(record: PendingPayment) -> bool:
|
||||
if record.method == PaymentMethod.CRYPTOBOT:
|
||||
return status in {'active'}
|
||||
if record.method == PaymentMethod.FREEKASSA:
|
||||
return status in {'pending', ''}
|
||||
return status in {'pending', 'created', ''}
|
||||
if record.method == PaymentMethod.KASSA_AI:
|
||||
return status in {'pending', 'created', 'processing', ''}
|
||||
return False
|
||||
|
||||
|
||||
@@ -184,6 +202,7 @@ def _build_list_keyboard(
|
||||
page: int,
|
||||
total_pages: int,
|
||||
language: str,
|
||||
has_checkable: bool = False,
|
||||
) -> InlineKeyboardMarkup:
|
||||
buttons: list[list[InlineKeyboardButton]] = []
|
||||
texts = get_texts(language)
|
||||
@@ -204,6 +223,28 @@ def _build_list_keyboard(
|
||||
]
|
||||
)
|
||||
|
||||
# Кнопка "Проверить все" если есть что проверять
|
||||
if has_checkable:
|
||||
buttons.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('ADMIN_PAYMENTS_CHECK_ALL', '🔄 Проверить все'),
|
||||
callback_data='admin_payments_check_all',
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
# Кнопка экспорта если есть платежи
|
||||
if records:
|
||||
buttons.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('ADMIN_PAYMENTS_EXPORT', '📥 Выгрузить в файл'),
|
||||
callback_data='admin_payments_export',
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
if total_pages > 1:
|
||||
navigation_row: list[InlineKeyboardButton] = []
|
||||
if page > 1:
|
||||
@@ -485,11 +526,22 @@ async def show_payments_overview(
|
||||
|
||||
lines = [header, '', description]
|
||||
|
||||
# Проверяем есть ли платежи для массовой проверки
|
||||
checkable_records = [r for r in records if _is_checkable(r) and not r.is_paid]
|
||||
has_checkable = len(checkable_records) > 0
|
||||
|
||||
if page_records:
|
||||
for idx, record in enumerate(page_records, start=start_index + 1):
|
||||
lines.extend(_build_record_lines(record, index=idx, texts=texts, language=db_user.language))
|
||||
lines.append('')
|
||||
lines.append(notice)
|
||||
if has_checkable:
|
||||
lines.append('')
|
||||
lines.append(
|
||||
texts.t('ADMIN_PAYMENTS_CHECKABLE_COUNT', '🔄 Доступно для проверки: {count}').format(
|
||||
count=len(checkable_records)
|
||||
)
|
||||
)
|
||||
else:
|
||||
empty_text = texts.t('ADMIN_PAYMENTS_EMPTY', 'No pending top-ups in the last 24 hours.')
|
||||
lines.append('')
|
||||
@@ -500,6 +552,7 @@ async def show_payments_overview(
|
||||
page=page,
|
||||
total_pages=total_pages,
|
||||
language=db_user.language,
|
||||
has_checkable=has_checkable,
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
@@ -550,28 +603,42 @@ async def manual_check_payment(
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
) -> None:
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info('manual_check_payment called: %s', callback.data)
|
||||
|
||||
parsed = _parse_method_and_id(callback.data, prefix='admin_payment_check_')
|
||||
if not parsed:
|
||||
logger.warning('Failed to parse: %s', callback.data)
|
||||
await callback.answer('❌ Invalid payment reference', show_alert=True)
|
||||
return
|
||||
|
||||
method, payment_id = parsed
|
||||
logger.info('Checking payment: method=%s, id=%s', method, payment_id)
|
||||
|
||||
record = await get_payment_record(db, method, payment_id)
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not record:
|
||||
logger.warning('Payment not found: method=%s, id=%s', method, payment_id)
|
||||
await callback.answer(texts.t('ADMIN_PAYMENT_NOT_FOUND', 'Payment not found.'), show_alert=True)
|
||||
return
|
||||
|
||||
logger.info('Record found: status=%s, is_paid=%s', record.status, record.is_paid)
|
||||
|
||||
if not _is_checkable(record):
|
||||
logger.info('Payment not checkable: method=%s, status=%s', method, record.status)
|
||||
await callback.answer(
|
||||
texts.t('ADMIN_PAYMENT_CHECK_NOT_AVAILABLE', 'Manual check is not available for this invoice.'),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
logger.info('Running manual check...')
|
||||
payment_service = PaymentService(callback.bot)
|
||||
updated = await run_manual_check(db, method, payment_id, payment_service)
|
||||
logger.info('Check result: updated=%s', updated is not None)
|
||||
|
||||
if not updated:
|
||||
await callback.answer(
|
||||
@@ -597,7 +664,189 @@ async def manual_check_payment(
|
||||
await callback.answer(message, show_alert=True)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def check_all_payments(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
) -> None:
|
||||
"""Массовая проверка всех ожидающих платежей."""
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info('check_all_payments called')
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
# Получаем все ожидающие платежи
|
||||
records = await list_recent_pending_payments(db)
|
||||
logger.info('Found %d total records', len(records))
|
||||
|
||||
checkable_records = [r for r in records if _is_checkable(r) and not r.is_paid]
|
||||
logger.info('Found %d checkable records', len(checkable_records))
|
||||
|
||||
if not checkable_records:
|
||||
await callback.answer(
|
||||
texts.t('ADMIN_PAYMENTS_NO_CHECKABLE', 'Нет платежей для проверки'),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
await callback.answer(
|
||||
texts.t('ADMIN_PAYMENTS_CHECKING_ALL', '🔄 Проверяю {count} платежей...').format(count=len(checkable_records)),
|
||||
)
|
||||
|
||||
payment_service = PaymentService(callback.bot)
|
||||
checked = 0
|
||||
confirmed = 0
|
||||
failed = 0
|
||||
|
||||
for record in checkable_records:
|
||||
try:
|
||||
logger.info('Checking %s payment id=%s', record.method.value, record.local_id)
|
||||
updated = await run_manual_check(db, record.method, record.local_id, payment_service)
|
||||
checked += 1
|
||||
logger.info('Check result: is_paid=%s', updated.is_paid if updated else None)
|
||||
if updated and updated.is_paid and not record.is_paid:
|
||||
confirmed += 1
|
||||
except Exception as e:
|
||||
logger.error('Check failed for %s id=%s: %s', record.method.value, record.local_id, e, exc_info=True)
|
||||
failed += 1
|
||||
|
||||
logger.info('Check complete: checked=%d, confirmed=%d, failed=%d', checked, confirmed, failed)
|
||||
|
||||
# Показываем результат
|
||||
result_lines = [
|
||||
texts.t('ADMIN_PAYMENTS_CHECK_ALL_RESULT', '🔄 <b>Результат проверки</b>'),
|
||||
'',
|
||||
texts.t('ADMIN_PAYMENTS_CHECK_ALL_CHECKED', '✅ Проверено: {count}').format(count=checked),
|
||||
texts.t('ADMIN_PAYMENTS_CHECK_ALL_CONFIRMED', '💰 Подтверждено: {count}').format(count=confirmed),
|
||||
]
|
||||
if failed:
|
||||
result_lines.append(texts.t('ADMIN_PAYMENTS_CHECK_ALL_FAILED', '❌ Ошибок: {count}').format(count=failed))
|
||||
|
||||
# Перезагружаем список платежей
|
||||
records = await list_recent_pending_payments(db)
|
||||
total = len(records)
|
||||
total_pages = max(1, (total + PAGE_SIZE - 1) // PAGE_SIZE)
|
||||
page_records = records[:PAGE_SIZE]
|
||||
checkable_records = [r for r in records if _is_checkable(r) and not r.is_paid]
|
||||
|
||||
result_lines.append('')
|
||||
result_lines.append(texts.t('ADMIN_PAYMENTS_TITLE', '💳 <b>Top-up verification</b>'))
|
||||
|
||||
if page_records:
|
||||
result_lines.append('')
|
||||
for idx, record in enumerate(page_records, start=1):
|
||||
result_lines.extend(_build_record_lines(record, index=idx, texts=texts, language=db_user.language))
|
||||
result_lines.append('')
|
||||
|
||||
keyboard = _build_list_keyboard(
|
||||
page_records,
|
||||
page=1,
|
||||
total_pages=total_pages,
|
||||
language=db_user.language,
|
||||
has_checkable=len(checkable_records) > 0,
|
||||
)
|
||||
|
||||
logger.info('Updating message with results...')
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
'\n'.join(result_lines),
|
||||
parse_mode='HTML',
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
logger.info('Message updated successfully')
|
||||
except Exception as e:
|
||||
logger.error('Failed to update message: %s', e, exc_info=True)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def export_payments(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
) -> None:
|
||||
"""Экспорт данных платежей в JSON файл."""
|
||||
import json
|
||||
|
||||
from aiogram.types import BufferedInputFile
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
records = await list_recent_pending_payments(db)
|
||||
|
||||
if not records:
|
||||
await callback.answer(
|
||||
texts.t('ADMIN_PAYMENTS_EXPORT_EMPTY', 'Нет платежей для экспорта'),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
# Формируем данные для экспорта
|
||||
export_data = []
|
||||
for record in records:
|
||||
payment = record.payment
|
||||
user = record.user
|
||||
|
||||
payment_data = {
|
||||
'id': record.local_id,
|
||||
'method': record.method.value,
|
||||
'method_display': _method_display(record.method),
|
||||
'identifier': record.identifier,
|
||||
'amount_kopeks': record.amount_kopeks,
|
||||
'amount_rubles': record.amount_kopeks / 100,
|
||||
'status': record.status,
|
||||
'is_paid': record.is_paid,
|
||||
'created_at': record.created_at.isoformat() if record.created_at else None,
|
||||
'expires_at': record.expires_at.isoformat() if record.expires_at else None,
|
||||
'user': {
|
||||
'id': user.id,
|
||||
'telegram_id': user.telegram_id,
|
||||
'username': user.username,
|
||||
'full_name': user.full_name,
|
||||
},
|
||||
}
|
||||
|
||||
# Добавляем специфичные поля в зависимости от метода
|
||||
if hasattr(payment, 'order_id'):
|
||||
payment_data['order_id'] = payment.order_id
|
||||
if hasattr(payment, 'payment_url'):
|
||||
payment_data['payment_url'] = payment.payment_url
|
||||
if hasattr(payment, 'callback_payload'):
|
||||
payment_data['callback_payload'] = payment.callback_payload
|
||||
|
||||
export_data.append(payment_data)
|
||||
|
||||
# Создаём JSON файл
|
||||
json_content = json.dumps(export_data, ensure_ascii=False, indent=2, default=str)
|
||||
file_bytes = json_content.encode('utf-8')
|
||||
|
||||
# Отправляем файл
|
||||
from datetime import datetime
|
||||
|
||||
filename = f'payments_export_{datetime.now().strftime("%Y%m%d_%H%M%S")}.json'
|
||||
|
||||
await callback.message.answer_document(
|
||||
document=BufferedInputFile(file_bytes, filename=filename),
|
||||
caption=texts.t(
|
||||
'ADMIN_PAYMENTS_EXPORT_CAPTION',
|
||||
'📥 Экспорт платежей\n\n📊 Всего записей: {count}\n💰 Оплачено: {paid}\n⏳ Ожидают: {pending}',
|
||||
).format(
|
||||
count=len(export_data),
|
||||
paid=sum(1 for r in export_data if r['is_paid']),
|
||||
pending=sum(1 for r in export_data if not r['is_paid']),
|
||||
),
|
||||
)
|
||||
|
||||
await callback.answer(texts.t('ADMIN_PAYMENTS_EXPORT_SUCCESS', '✅ Файл отправлен'))
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher) -> None:
|
||||
dp.callback_query.register(check_all_payments, F.data == 'admin_payments_check_all')
|
||||
dp.callback_query.register(export_payments, F.data == 'admin_payments_export')
|
||||
dp.callback_query.register(manual_check_payment, F.data.startswith('admin_payment_check_'))
|
||||
dp.callback_query.register(
|
||||
show_payment_details,
|
||||
|
||||
@@ -83,6 +83,7 @@ async def show_referral_statistics(callback: types.CallbackQuery, db_user: User,
|
||||
keyboard_rows = [
|
||||
[types.InlineKeyboardButton(text='🔄 Обновить', callback_data='admin_referrals')],
|
||||
[types.InlineKeyboardButton(text='👥 Топ рефереров', callback_data='admin_referrals_top')],
|
||||
[types.InlineKeyboardButton(text='🔍 Диагностика логов', callback_data='admin_referral_diagnostics')],
|
||||
]
|
||||
|
||||
# Кнопка заявок на вывод (если функция включена)
|
||||
@@ -650,11 +651,812 @@ async def process_test_referral_earning(message: types.Message, db_user: User, d
|
||||
)
|
||||
|
||||
|
||||
def _get_period_dates(period: str) -> tuple[datetime.datetime, datetime.datetime]:
|
||||
"""Возвращает начальную и конечную даты для заданного периода."""
|
||||
now = datetime.datetime.now()
|
||||
today = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
if period == 'today':
|
||||
start_date = today
|
||||
end_date = today + datetime.timedelta(days=1)
|
||||
elif period == 'yesterday':
|
||||
start_date = today - datetime.timedelta(days=1)
|
||||
end_date = today
|
||||
elif period == 'week':
|
||||
start_date = today - datetime.timedelta(days=7)
|
||||
end_date = today + datetime.timedelta(days=1)
|
||||
elif period == 'month':
|
||||
start_date = today - datetime.timedelta(days=30)
|
||||
end_date = today + datetime.timedelta(days=1)
|
||||
else:
|
||||
# По умолчанию — сегодня
|
||||
start_date = today
|
||||
end_date = today + datetime.timedelta(days=1)
|
||||
|
||||
return start_date, end_date
|
||||
|
||||
|
||||
def _get_period_display_name(period: str) -> str:
|
||||
"""Возвращает человекочитаемое название периода."""
|
||||
names = {'today': 'сегодня', 'yesterday': 'вчера', 'week': '7 дней', 'month': '30 дней'}
|
||||
return names.get(period, 'сегодня')
|
||||
|
||||
|
||||
async def _show_diagnostics_for_period(callback: types.CallbackQuery, db: AsyncSession, state: FSMContext, period: str):
|
||||
"""Внутренняя функция для отображения диагностики за указанный период."""
|
||||
try:
|
||||
await callback.answer('Анализирую логи...')
|
||||
|
||||
from app.services.referral_diagnostics_service import referral_diagnostics_service
|
||||
|
||||
# Сохраняем период в state
|
||||
await state.update_data(diagnostics_period=period)
|
||||
from app.states import AdminStates
|
||||
|
||||
await state.set_state(AdminStates.referral_diagnostics_period)
|
||||
|
||||
# Получаем даты периода
|
||||
start_date, end_date = _get_period_dates(period)
|
||||
|
||||
# Анализируем логи
|
||||
report = await referral_diagnostics_service.analyze_period(db, start_date, end_date)
|
||||
|
||||
# Формируем отчёт
|
||||
period_display = _get_period_display_name(period)
|
||||
|
||||
text = f"""
|
||||
🔍 <b>Диагностика рефералов — {period_display}</b>
|
||||
|
||||
<b>📊 Статистика переходов:</b>
|
||||
• Всего кликов по реф-ссылкам: {report.total_ref_clicks}
|
||||
• Уникальных пользователей: {report.unique_users_clicked}
|
||||
• Потерянных рефералов: {len(report.lost_referrals)}
|
||||
"""
|
||||
|
||||
if report.lost_referrals:
|
||||
text += '\n<b>❌ Потерянные рефералы:</b>\n'
|
||||
text += '<i>(пришли по ссылке, но реферер не засчитался)</i>\n\n'
|
||||
|
||||
for i, lost in enumerate(report.lost_referrals[:15], 1):
|
||||
# Статус пользователя
|
||||
if not lost.registered:
|
||||
status = '⚠️ Не в БД'
|
||||
elif not lost.has_referrer:
|
||||
status = '❌ Без реферера'
|
||||
else:
|
||||
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}'
|
||||
|
||||
# Ожидаемый реферер
|
||||
referrer_info = ''
|
||||
if lost.expected_referrer_name:
|
||||
referrer_info = f' → {lost.expected_referrer_name}'
|
||||
elif lost.expected_referrer_id:
|
||||
referrer_info = f' → ID{lost.expected_referrer_id}'
|
||||
|
||||
# Время
|
||||
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'
|
||||
|
||||
if len(report.lost_referrals) > 15:
|
||||
text += f'\n<i>... и ещё {len(report.lost_referrals) - 15}</i>\n'
|
||||
else:
|
||||
text += '\n✅ <b>Все рефералы засчитаны!</b>\n'
|
||||
|
||||
# Информация о логах
|
||||
log_path = referral_diagnostics_service.log_path
|
||||
log_exists = log_path.exists()
|
||||
log_size = log_path.stat().st_size if log_exists else 0
|
||||
|
||||
text += f'\n<i>📂 {log_path.name}'
|
||||
if log_exists:
|
||||
text += f' ({log_size / 1024:.0f} KB)'
|
||||
text += f' | Строк: {report.lines_in_period}'
|
||||
else:
|
||||
text += ' (не найден!)'
|
||||
text += '</i>'
|
||||
|
||||
# Кнопки: только "Сегодня" (текущий лог) и "Загрузить файл" (старые логи)
|
||||
keyboard_rows = [
|
||||
[
|
||||
types.InlineKeyboardButton(text='📅 Сегодня (текущий лог)', callback_data='admin_ref_diag:today'),
|
||||
],
|
||||
[types.InlineKeyboardButton(text='📤 Загрузить лог-файл', callback_data='admin_ref_diag_upload')],
|
||||
[types.InlineKeyboardButton(text='🔍 Проверить бонусы (по БД)', callback_data='admin_ref_check_bonuses')],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text='🏆 Синхронизировать с конкурсом', callback_data='admin_ref_sync_contest'
|
||||
)
|
||||
],
|
||||
]
|
||||
|
||||
# Кнопки действий (только если есть потерянные рефералы)
|
||||
if report.lost_referrals:
|
||||
keyboard_rows.append(
|
||||
[types.InlineKeyboardButton(text='📋 Предпросмотр исправлений', callback_data='admin_ref_fix_preview')]
|
||||
)
|
||||
|
||||
keyboard_rows.extend(
|
||||
[
|
||||
[types.InlineKeyboardButton(text='🔄 Обновить', callback_data=f'admin_ref_diag:{period}')],
|
||||
[types.InlineKeyboardButton(text='⬅️ К статистике', callback_data='admin_referrals')],
|
||||
]
|
||||
)
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
|
||||
|
||||
await callback.message.edit_text(text, reply_markup=keyboard)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка в _show_diagnostics_for_period: {e}', exc_info=True)
|
||||
await callback.answer('Ошибка при анализе логов', show_alert=True)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_referral_diagnostics(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
|
||||
"""Показывает диагностику реферальной системы по логам."""
|
||||
# Определяем период из callback_data или используем "today" по умолчанию
|
||||
if ':' in callback.data:
|
||||
period = callback.data.split(':')[1]
|
||||
else:
|
||||
period = 'today'
|
||||
|
||||
await _show_diagnostics_for_period(callback, db, state, period)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def preview_referral_fixes(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
|
||||
"""Показывает предпросмотр исправлений потерянных рефералов."""
|
||||
try:
|
||||
await callback.answer('Анализирую...')
|
||||
|
||||
# Получаем период из state
|
||||
state_data = await state.get_data()
|
||||
period = state_data.get('diagnostics_period', 'today')
|
||||
|
||||
from app.services.referral_diagnostics_service import DiagnosticReport, referral_diagnostics_service
|
||||
|
||||
# Проверяем, работаем ли с загруженным файлом
|
||||
if period == 'uploaded_file':
|
||||
# Используем сохранённый отчёт из загруженного файла (десериализуем)
|
||||
report_data = state_data.get('uploaded_file_report')
|
||||
if not report_data:
|
||||
await callback.answer('Отчёт загруженного файла не найден', show_alert=True)
|
||||
return
|
||||
report = DiagnosticReport.from_dict(report_data)
|
||||
period_display = 'загруженный файл'
|
||||
else:
|
||||
# Получаем даты периода
|
||||
start_date, end_date = _get_period_dates(period)
|
||||
|
||||
# Анализируем логи
|
||||
report = await referral_diagnostics_service.analyze_period(db, start_date, end_date)
|
||||
period_display = _get_period_display_name(period)
|
||||
|
||||
if not report.lost_referrals:
|
||||
await callback.answer('Нет потерянных рефералов для исправления', show_alert=True)
|
||||
return
|
||||
|
||||
# Запускаем предпросмотр исправлений
|
||||
fix_report = await referral_diagnostics_service.fix_lost_referrals(db, report.lost_referrals, apply=False)
|
||||
|
||||
# Формируем отчёт
|
||||
text = f"""
|
||||
📋 <b>Предпросмотр исправлений — {period_display}</b>
|
||||
|
||||
<b>📊 Что будет сделано:</b>
|
||||
• Исправлено рефералов: {fix_report.users_fixed}
|
||||
• Бонусов рефералам: {settings.format_price(fix_report.bonuses_to_referrals)}
|
||||
• Бонусов рефереам: {settings.format_price(fix_report.bonuses_to_referrers)}
|
||||
• Ошибок: {fix_report.errors}
|
||||
|
||||
<b>🔍 Детали:</b>
|
||||
"""
|
||||
|
||||
# Показываем первые 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}'
|
||||
|
||||
if detail.error:
|
||||
text += f'{i}. {user_name} — ❌ {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'
|
||||
if detail.had_first_topup:
|
||||
text += f' • Первое пополнение: {settings.format_price(detail.topup_amount_kopeks)}\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:
|
||||
text += f' • Бонус рефереру: {settings.format_price(detail.bonus_to_referrer_kopeks)}\n'
|
||||
|
||||
if len(fix_report.details) > 10:
|
||||
text += f'\n<i>... и ещё {len(fix_report.details) - 10}</i>\n'
|
||||
|
||||
text += '\n⚠️ <b>Внимание!</b> Это только предпросмотр. Нажмите "Применить", чтобы выполнить исправления.'
|
||||
|
||||
# Кнопка назад зависит от источника
|
||||
back_button_text = '⬅️ К диагностике'
|
||||
back_button_callback = f'admin_ref_diag:{period}' if period != 'uploaded_file' else 'admin_referral_diagnostics'
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text='✅ Применить исправления', callback_data='admin_ref_fix_apply')],
|
||||
[types.InlineKeyboardButton(text=back_button_text, callback_data=back_button_callback)],
|
||||
]
|
||||
)
|
||||
|
||||
await callback.message.edit_text(text, reply_markup=keyboard)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка в preview_referral_fixes: {e}', exc_info=True)
|
||||
await callback.answer('Ошибка при создании предпросмотра', show_alert=True)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def apply_referral_fixes(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
|
||||
"""Применяет исправления потерянных рефералов."""
|
||||
try:
|
||||
await callback.answer('Применяю исправления...')
|
||||
|
||||
# Получаем период из state
|
||||
state_data = await state.get_data()
|
||||
period = state_data.get('diagnostics_period', 'today')
|
||||
|
||||
from app.services.referral_diagnostics_service import DiagnosticReport, referral_diagnostics_service
|
||||
|
||||
# Проверяем, работаем ли с загруженным файлом
|
||||
if period == 'uploaded_file':
|
||||
# Используем сохранённый отчёт из загруженного файла (десериализуем)
|
||||
report_data = state_data.get('uploaded_file_report')
|
||||
if not report_data:
|
||||
await callback.answer('Отчёт загруженного файла не найден', show_alert=True)
|
||||
return
|
||||
report = DiagnosticReport.from_dict(report_data)
|
||||
period_display = 'загруженный файл'
|
||||
else:
|
||||
# Получаем даты периода
|
||||
start_date, end_date = _get_period_dates(period)
|
||||
|
||||
# Анализируем логи
|
||||
report = await referral_diagnostics_service.analyze_period(db, start_date, end_date)
|
||||
period_display = _get_period_display_name(period)
|
||||
|
||||
if not report.lost_referrals:
|
||||
await callback.answer('Нет потерянных рефералов для исправления', show_alert=True)
|
||||
return
|
||||
|
||||
# Применяем исправления
|
||||
fix_report = await referral_diagnostics_service.fix_lost_referrals(db, report.lost_referrals, apply=True)
|
||||
|
||||
# Формируем отчёт
|
||||
text = f"""
|
||||
✅ <b>Исправления применены — {period_display}</b>
|
||||
|
||||
<b>📊 Результаты:</b>
|
||||
• Исправлено рефералов: {fix_report.users_fixed}
|
||||
• Бонусов рефералам: {settings.format_price(fix_report.bonuses_to_referrals)}
|
||||
• Бонусов рефереам: {settings.format_price(fix_report.bonuses_to_referrers)}
|
||||
• Ошибок: {fix_report.errors}
|
||||
|
||||
<b>🔍 Детали:</b>
|
||||
"""
|
||||
|
||||
# Показываем первые 10 успешных деталей
|
||||
success_count = 0
|
||||
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}'
|
||||
|
||||
text += f'{success_count}. {user_name}\n'
|
||||
if detail.referred_by_set:
|
||||
text += f' • Реферер: {detail.referrer_name or f"ID{detail.referrer_id}"}\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:
|
||||
text += f' • Бонус рефереру: {settings.format_price(detail.bonus_to_referrer_kopeks)}\n'
|
||||
|
||||
if fix_report.users_fixed > 10:
|
||||
text += f'\n<i>... и ещё {fix_report.users_fixed - 10} исправлений</i>\n'
|
||||
|
||||
# Показываем ошибки
|
||||
if fix_report.errors > 0:
|
||||
text += '\n<b>❌ Ошибки:</b>\n'
|
||||
error_count = 0
|
||||
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 fix_report.errors > 5:
|
||||
text += f'<i>... и ещё {fix_report.errors - 5} ошибок</i>\n'
|
||||
|
||||
# Кнопки зависят от источника
|
||||
keyboard_rows = []
|
||||
if period != 'uploaded_file':
|
||||
keyboard_rows.append(
|
||||
[types.InlineKeyboardButton(text='🔄 Обновить диагностику', callback_data=f'admin_ref_diag:{period}')]
|
||||
)
|
||||
keyboard_rows.append([types.InlineKeyboardButton(text='⬅️ К статистике', callback_data='admin_referrals')])
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
|
||||
|
||||
await callback.message.edit_text(text, reply_markup=keyboard)
|
||||
|
||||
# Очищаем сохранённый отчёт из state
|
||||
if period == 'uploaded_file':
|
||||
await state.update_data(uploaded_file_report=None)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка в apply_referral_fixes: {e}', exc_info=True)
|
||||
await callback.answer('Ошибка при применении исправлений', show_alert=True)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Проверка бонусов по БД
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def check_missing_bonuses(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
|
||||
"""Проверяет по БД — всем ли рефералам начислены бонусы."""
|
||||
from app.services.referral_diagnostics_service import (
|
||||
referral_diagnostics_service,
|
||||
)
|
||||
|
||||
await callback.answer('🔍 Проверяю бонусы...')
|
||||
|
||||
try:
|
||||
report = await referral_diagnostics_service.check_missing_bonuses(db)
|
||||
|
||||
# Сохраняем отчёт в state для последующего применения
|
||||
await state.update_data(missing_bonuses_report=report.to_dict())
|
||||
|
||||
text = f"""
|
||||
🔍 <b>Проверка бонусов по БД</b>
|
||||
|
||||
📊 <b>Статистика:</b>
|
||||
• Всего рефералов: {report.total_referrals_checked}
|
||||
• С пополнением ≥ минимума: {report.referrals_with_topup}
|
||||
• <b>Без бонусов: {len(report.missing_bonuses)}</b>
|
||||
"""
|
||||
|
||||
if report.missing_bonuses:
|
||||
text += f"""
|
||||
💰 <b>Требуется начислить:</b>
|
||||
• Рефералам: {report.total_missing_to_referrals / 100:.0f}₽
|
||||
• Рефереерам: {report.total_missing_to_referrers / 100:.0f}₽
|
||||
• <b>Итого: {(report.total_missing_to_referrals + report.total_missing_to_referrers) / 100:.0f}₽</b>
|
||||
|
||||
👤 <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)
|
||||
text += f'\n{i}. <b>{referral_name}</b>'
|
||||
text += f'\n └ Пригласил: {referrer_name}'
|
||||
text += f'\n └ Пополнение: {mb.first_topup_amount_kopeks / 100:.0f}₽'
|
||||
text += f'\n └ Бонусы: {mb.referral_bonus_amount / 100:.0f}₽ + {mb.referrer_bonus_amount / 100:.0f}₽'
|
||||
|
||||
if len(report.missing_bonuses) > 15:
|
||||
text += f'\n\n<i>... и ещё {len(report.missing_bonuses) - 15} чел.</i>'
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text='✅ Начислить все бонусы', callback_data='admin_ref_bonus_apply')],
|
||||
[types.InlineKeyboardButton(text='🔄 Обновить', callback_data='admin_ref_check_bonuses')],
|
||||
[types.InlineKeyboardButton(text='⬅️ К диагностике', callback_data='admin_referral_diagnostics')],
|
||||
]
|
||||
)
|
||||
else:
|
||||
text += '\n✅ <b>Все бонусы начислены!</b>'
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text='🔄 Обновить', callback_data='admin_ref_check_bonuses')],
|
||||
[types.InlineKeyboardButton(text='⬅️ К диагностике', callback_data='admin_referral_diagnostics')],
|
||||
]
|
||||
)
|
||||
|
||||
await callback.message.edit_text(text, reply_markup=keyboard)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка в check_missing_bonuses: {e}', exc_info=True)
|
||||
await callback.answer('Ошибка при проверке бонусов', show_alert=True)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def apply_missing_bonuses(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
|
||||
"""Применяет начисление пропущенных бонусов."""
|
||||
from app.services.referral_diagnostics_service import (
|
||||
MissingBonusReport,
|
||||
referral_diagnostics_service,
|
||||
)
|
||||
|
||||
await callback.answer('💰 Начисляю бонусы...')
|
||||
|
||||
try:
|
||||
# Получаем сохранённый отчёт
|
||||
data = await state.get_data()
|
||||
report_dict = data.get('missing_bonuses_report')
|
||||
|
||||
if not report_dict:
|
||||
await callback.answer('❌ Отчёт не найден. Обновите проверку.', show_alert=True)
|
||||
return
|
||||
|
||||
report = MissingBonusReport.from_dict(report_dict)
|
||||
|
||||
if not report.missing_bonuses:
|
||||
await callback.answer('✅ Нет бонусов для начисления', show_alert=True)
|
||||
return
|
||||
|
||||
# Применяем исправления
|
||||
fix_report = await referral_diagnostics_service.fix_missing_bonuses(db, report.missing_bonuses, apply=True)
|
||||
|
||||
text = f"""
|
||||
✅ <b>Бонусы начислены!</b>
|
||||
|
||||
📊 <b>Результат:</b>
|
||||
• Обработано: {fix_report.users_fixed} пользователей
|
||||
• Начислено рефералам: {fix_report.bonuses_to_referrals / 100:.0f}₽
|
||||
• Начислено рефереерам: {fix_report.bonuses_to_referrers / 100:.0f}₽
|
||||
• <b>Итого: {(fix_report.bonuses_to_referrals + fix_report.bonuses_to_referrers) / 100:.0f}₽</b>
|
||||
"""
|
||||
|
||||
if fix_report.errors > 0:
|
||||
text += f'\n⚠️ Ошибок: {fix_report.errors}'
|
||||
|
||||
# Очищаем отчёт из state
|
||||
await state.update_data(missing_bonuses_report=None)
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text='🔍 Проверить снова', callback_data='admin_ref_check_bonuses')],
|
||||
[types.InlineKeyboardButton(text='⬅️ К диагностике', callback_data='admin_referral_diagnostics')],
|
||||
]
|
||||
)
|
||||
|
||||
await callback.message.edit_text(text, reply_markup=keyboard)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка в apply_missing_bonuses: {e}', exc_info=True)
|
||||
await callback.answer('Ошибка при начислении бонусов', show_alert=True)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def sync_referrals_with_contest(
|
||||
callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext
|
||||
):
|
||||
"""Синхронизирует всех рефералов с активными конкурсами."""
|
||||
from app.database.crud.referral_contest import get_contests_for_events
|
||||
from app.services.referral_contest_service import referral_contest_service
|
||||
|
||||
await callback.answer('🏆 Синхронизирую с конкурсами...')
|
||||
|
||||
try:
|
||||
from datetime import datetime
|
||||
|
||||
now_utc = datetime.utcnow()
|
||||
|
||||
# Получаем активные конкурсы
|
||||
paid_contests = await get_contests_for_events(db, now_utc, contest_types=['referral_paid'])
|
||||
reg_contests = await get_contests_for_events(db, now_utc, contest_types=['referral_registered'])
|
||||
|
||||
all_contests = list(paid_contests) + list(reg_contests)
|
||||
|
||||
if not all_contests:
|
||||
await callback.message.edit_text(
|
||||
'❌ <b>Нет активных конкурсов рефералов</b>\n\n'
|
||||
'Создайте конкурс в разделе "Конкурсы" для синхронизации.',
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text='⬅️ К диагностике', callback_data='admin_referral_diagnostics')]
|
||||
]
|
||||
),
|
||||
)
|
||||
return
|
||||
|
||||
# Синхронизируем каждый конкурс
|
||||
total_created = 0
|
||||
total_updated = 0
|
||||
total_skipped = 0
|
||||
contest_results = []
|
||||
|
||||
for contest in all_contests:
|
||||
stats = await referral_contest_service.sync_contest(db, contest.id)
|
||||
if 'error' not in stats:
|
||||
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)} новых')
|
||||
else:
|
||||
contest_results.append(f'• {contest.title}: ошибка')
|
||||
|
||||
text = f"""
|
||||
🏆 <b>Синхронизация с конкурсами завершена!</b>
|
||||
|
||||
📊 <b>Результат:</b>
|
||||
• Конкурсов обработано: {len(all_contests)}
|
||||
• Новых событий добавлено: {total_created}
|
||||
• Обновлено: {total_updated}
|
||||
• Пропущено (уже есть): {total_skipped}
|
||||
|
||||
📋 <b>По конкурсам:</b>
|
||||
"""
|
||||
text += '\n'.join(contest_results)
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text='🔄 Синхронизировать снова', callback_data='admin_ref_sync_contest')],
|
||||
[types.InlineKeyboardButton(text='⬅️ К диагностике', callback_data='admin_referral_diagnostics')],
|
||||
]
|
||||
)
|
||||
|
||||
await callback.message.edit_text(text, reply_markup=keyboard)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка в sync_referrals_with_contest: {e}', exc_info=True)
|
||||
await callback.answer('Ошибка при синхронизации', show_alert=True)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def request_log_file_upload(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
|
||||
"""Запрашивает загрузку лог-файла для анализа."""
|
||||
await state.set_state(AdminStates.waiting_for_log_file)
|
||||
|
||||
text = """
|
||||
📤 <b>Загрузка лог-файла для анализа</b>
|
||||
|
||||
Отправьте файл лога (расширение .log или .txt).
|
||||
|
||||
Файл будет проанализирован на наличие потерянных рефералов за ВСЕ время, записанное в логе.
|
||||
|
||||
⚠️ <b>Важно:</b>
|
||||
• Файл должен быть текстовым (.log, .txt)
|
||||
• Максимальный размер: 50 MB
|
||||
• После анализа файл будет автоматически удалён
|
||||
|
||||
Если ротация логов удалила старые данные — загрузите резервную копию.
|
||||
"""
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[[types.InlineKeyboardButton(text='❌ Отмена', callback_data='admin_referral_diagnostics')]]
|
||||
)
|
||||
|
||||
await callback.message.edit_text(text, reply_markup=keyboard)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def receive_log_file(message: types.Message, db_user: User, db: AsyncSession, state: FSMContext):
|
||||
"""Получает и анализирует загруженный лог-файл."""
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
if not message.document:
|
||||
await message.answer(
|
||||
'❌ Пожалуйста, отправьте файл документом.',
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text='❌ Отмена', callback_data='admin_referral_diagnostics')]
|
||||
]
|
||||
),
|
||||
)
|
||||
return
|
||||
|
||||
# Проверяем расширение файла
|
||||
file_name = message.document.file_name or 'unknown'
|
||||
file_ext = Path(file_name).suffix.lower()
|
||||
|
||||
if file_ext not in ['.log', '.txt']:
|
||||
await message.answer(
|
||||
f'❌ Неверный формат файла: {file_ext}\n\nПоддерживаются только текстовые файлы (.log, .txt)',
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text='❌ Отмена', callback_data='admin_referral_diagnostics')]
|
||||
]
|
||||
),
|
||||
)
|
||||
return
|
||||
|
||||
# Проверяем размер файла
|
||||
max_size = 50 * 1024 * 1024 # 50 MB
|
||||
if message.document.file_size > max_size:
|
||||
await message.answer(
|
||||
f'❌ Файл слишком большой: {message.document.file_size / 1024 / 1024:.1f} MB\n\nМаксимальный размер: 50 MB',
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text='❌ Отмена', callback_data='admin_referral_diagnostics')]
|
||||
]
|
||||
),
|
||||
)
|
||||
return
|
||||
|
||||
# Информируем о начале загрузки
|
||||
status_message = await message.answer(
|
||||
f'📥 Загружаю файл {file_name} ({message.document.file_size / 1024 / 1024:.1f} MB)...'
|
||||
)
|
||||
|
||||
temp_file_path = None
|
||||
|
||||
try:
|
||||
# Скачиваем файл во временную директорию
|
||||
temp_dir = tempfile.gettempdir()
|
||||
temp_file_path = str(Path(temp_dir) / f'ref_diagnostics_{message.from_user.id}_{file_name}')
|
||||
|
||||
# Скачиваем файл
|
||||
file = await message.bot.get_file(message.document.file_id)
|
||||
await message.bot.download_file(file.file_path, temp_file_path)
|
||||
|
||||
logger.info(f'📥 Файл загружен: {temp_file_path} ({message.document.file_size} байт)')
|
||||
|
||||
# Обновляем статус
|
||||
await status_message.edit_text(f'🔍 Анализирую файл {file_name}...\n\nЭто может занять некоторое время.')
|
||||
|
||||
# Анализируем файл
|
||||
from app.services.referral_diagnostics_service import referral_diagnostics_service
|
||||
|
||||
report = await referral_diagnostics_service.analyze_file(db, temp_file_path)
|
||||
|
||||
# Формируем отчёт
|
||||
text = f"""
|
||||
🔍 <b>Анализ лог-файла: {file_name}</b>
|
||||
|
||||
<b>📊 Статистика переходов:</b>
|
||||
• Всего кликов по реф-ссылкам: {report.total_ref_clicks}
|
||||
• Уникальных пользователей: {report.unique_users_clicked}
|
||||
• Потерянных рефералов: {len(report.lost_referrals)}
|
||||
• Строк в файле: {report.lines_in_period}
|
||||
"""
|
||||
|
||||
if report.lost_referrals:
|
||||
text += '\n<b>❌ Потерянные рефералы:</b>\n'
|
||||
text += '<i>(пришли по ссылке, но реферер не засчитался)</i>\n\n'
|
||||
|
||||
for i, lost in enumerate(report.lost_referrals[:15], 1):
|
||||
# Статус пользователя
|
||||
if not lost.registered:
|
||||
status = '⚠️ Не в БД'
|
||||
elif not lost.has_referrer:
|
||||
status = '❌ Без реферера'
|
||||
else:
|
||||
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}'
|
||||
|
||||
# Ожидаемый реферер
|
||||
referrer_info = ''
|
||||
if lost.expected_referrer_name:
|
||||
referrer_info = f' → {lost.expected_referrer_name}'
|
||||
elif lost.expected_referrer_id:
|
||||
referrer_info = f' → ID{lost.expected_referrer_id}'
|
||||
|
||||
# Время
|
||||
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'
|
||||
|
||||
if len(report.lost_referrals) > 15:
|
||||
text += f'\n<i>... и ещё {len(report.lost_referrals) - 15}</i>\n'
|
||||
else:
|
||||
text += '\n✅ <b>Все рефералы засчитаны!</b>\n'
|
||||
|
||||
# Сохраняем отчёт в state для дальнейшего использования (сериализуем в dict)
|
||||
await state.update_data(
|
||||
diagnostics_period='uploaded_file',
|
||||
uploaded_file_report=report.to_dict(),
|
||||
)
|
||||
|
||||
# Кнопки действий
|
||||
keyboard_rows = []
|
||||
|
||||
if report.lost_referrals:
|
||||
keyboard_rows.append(
|
||||
[types.InlineKeyboardButton(text='📋 Предпросмотр исправлений', callback_data='admin_ref_fix_preview')]
|
||||
)
|
||||
|
||||
keyboard_rows.extend(
|
||||
[
|
||||
[types.InlineKeyboardButton(text='⬅️ К диагностике', callback_data='admin_referral_diagnostics')],
|
||||
[types.InlineKeyboardButton(text='⬅️ К статистике', callback_data='admin_referrals')],
|
||||
]
|
||||
)
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
|
||||
|
||||
# Удаляем статусное сообщение
|
||||
await status_message.delete()
|
||||
|
||||
# Отправляем результат
|
||||
await message.answer(text, reply_markup=keyboard)
|
||||
|
||||
# Очищаем состояние
|
||||
await state.set_state(AdminStates.referral_diagnostics_period)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'❌ Ошибка при обработке файла: {e}', exc_info=True)
|
||||
|
||||
try:
|
||||
await status_message.edit_text(
|
||||
f'❌ <b>Ошибка при анализе файла</b>\n\n'
|
||||
f'Файл: {file_name}\n'
|
||||
f'Ошибка: {e!s}\n\n'
|
||||
f'Проверьте, что файл является текстовым логом бота.',
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text='🔄 Попробовать снова', callback_data='admin_ref_diag_upload'
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text='⬅️ К диагностике', callback_data='admin_referral_diagnostics'
|
||||
)
|
||||
],
|
||||
]
|
||||
),
|
||||
)
|
||||
except:
|
||||
await message.answer(
|
||||
f'❌ Ошибка при анализе файла: {e!s}',
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_referral_diagnostics')]
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
finally:
|
||||
# Удаляем временный файл
|
||||
if temp_file_path and Path(temp_file_path).exists():
|
||||
try:
|
||||
Path(temp_file_path).unlink()
|
||||
logger.info(f'🗑️ Временный файл удалён: {temp_file_path}')
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка удаления временного файла: {e}')
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(show_referral_statistics, F.data == 'admin_referrals')
|
||||
dp.callback_query.register(show_top_referrers, F.data == 'admin_referrals_top')
|
||||
dp.callback_query.register(show_top_referrers_filtered, F.data.startswith('admin_top_ref:'))
|
||||
dp.callback_query.register(show_referral_settings, F.data == 'admin_referrals_settings')
|
||||
dp.callback_query.register(show_referral_diagnostics, F.data == 'admin_referral_diagnostics')
|
||||
dp.callback_query.register(show_referral_diagnostics, F.data.startswith('admin_ref_diag:'))
|
||||
dp.callback_query.register(preview_referral_fixes, F.data == 'admin_ref_fix_preview')
|
||||
dp.callback_query.register(apply_referral_fixes, F.data == 'admin_ref_fix_apply')
|
||||
|
||||
# Загрузка лог-файла
|
||||
dp.callback_query.register(request_log_file_upload, F.data == 'admin_ref_diag_upload')
|
||||
dp.message.register(receive_log_file, AdminStates.waiting_for_log_file)
|
||||
|
||||
# Проверка бонусов по БД
|
||||
dp.callback_query.register(check_missing_bonuses, F.data == 'admin_ref_check_bonuses')
|
||||
dp.callback_query.register(apply_missing_bonuses, F.data == 'admin_ref_bonus_apply')
|
||||
dp.callback_query.register(sync_referrals_with_contest, F.data == 'admin_ref_sync_contest')
|
||||
|
||||
# Хендлеры заявок на вывод
|
||||
dp.callback_query.register(show_pending_withdrawal_requests, F.data == 'admin_withdrawal_requests')
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import logging
|
||||
import re
|
||||
|
||||
from aiogram import Dispatcher, F, types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
@@ -11,6 +12,14 @@ from app.utils.decorators import admin_required, error_handler
|
||||
from app.utils.validators import get_html_help_text, validate_html_tags
|
||||
|
||||
|
||||
def _safe_preview(html_text: str, limit: int = 500) -> str:
|
||||
"""Создаёт превью текста, безопасно обрезая HTML-теги."""
|
||||
plain = re.sub(r'<[^>]+>', '', html_text)
|
||||
if len(plain) <= limit:
|
||||
return plain
|
||||
return plain[:limit] + '...'
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -79,7 +88,7 @@ async def start_edit_rules(callback: types.CallbackQuery, db_user: User, state:
|
||||
try:
|
||||
current_rules = await get_current_rules_content(db, db_user.language)
|
||||
|
||||
preview = current_rules[:500] + ('...' if len(current_rules) > 500 else '')
|
||||
preview = _safe_preview(current_rules, 500)
|
||||
|
||||
text = (
|
||||
'✏️ <b>Редактирование правил</b>\n\n'
|
||||
@@ -139,7 +148,7 @@ async def process_rules_edit(message: types.Message, db_user: User, state: FSMCo
|
||||
if len(preview_text) > 4000:
|
||||
preview_text = (
|
||||
'📋 <b>Предварительный просмотр новых правил:</b>\n\n'
|
||||
f'{new_rules[:500]}...\n\n'
|
||||
f'{_safe_preview(new_rules, 500)}\n\n'
|
||||
f'⚠️ <b>Внимание!</b> Новые правила будут показываться всем пользователям.\n\n'
|
||||
f'Текст правил: {len(new_rules)} символов\n'
|
||||
f'Сохранить изменения?'
|
||||
|
||||
@@ -1993,6 +1993,15 @@ async def process_edit_traffic_topup_packages(
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
if not message.text:
|
||||
await message.answer(
|
||||
'Пожалуйста, отправьте текстовое сообщение.\n\n'
|
||||
'Формат: <code>ГБ:цена_в_копейках</code>\n'
|
||||
'Пример: <code>5:5000, 10:9000, 20:15000</code>',
|
||||
parse_mode='HTML',
|
||||
)
|
||||
return
|
||||
|
||||
packages = _parse_traffic_topup_packages(message.text.strip())
|
||||
|
||||
if not packages:
|
||||
|
||||
@@ -1045,10 +1045,11 @@ async def notify_user_about_ticket_reply(bot: Bot, ticket: Ticket, reply_text: s
|
||||
return
|
||||
|
||||
if not getattr(user, 'telegram_id', None):
|
||||
logger.error(
|
||||
'Cannot notify ticket #%s user without telegram_id (username=%s)',
|
||||
logger.warning(
|
||||
'Cannot notify ticket #%s user without telegram_id (username=%s, auth_type=%s)',
|
||||
ticket.id,
|
||||
getattr(user, 'username', None),
|
||||
getattr(user, 'auth_type', None),
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
+160
-192
@@ -65,11 +65,8 @@ class UserFilterType(Enum):
|
||||
"""Типы фильтрации пользователей."""
|
||||
|
||||
BALANCE = 'balance'
|
||||
TRAFFIC = 'traffic'
|
||||
ACTIVITY = 'activity'
|
||||
SPENDING = 'spending'
|
||||
PURCHASES = 'purchases'
|
||||
CAMPAIGN = 'campaign'
|
||||
POTENTIAL_CUSTOMERS = 'potential_customers'
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -92,34 +89,6 @@ USER_FILTER_CONFIGS: dict[UserFilterType, UserFilterConfig] = {
|
||||
pagination_prefix='admin_users_balance_list',
|
||||
order_param='order_by_balance',
|
||||
),
|
||||
UserFilterType.TRAFFIC: UserFilterConfig(
|
||||
fsm_state=AdminStates.viewing_user_from_traffic_list,
|
||||
title='👥 <b>Список пользователей по использованному трафику</b>',
|
||||
empty_message='📶 Пользователи с трафиком не найдены',
|
||||
pagination_prefix='admin_users_traffic_list',
|
||||
order_param='order_by_traffic',
|
||||
),
|
||||
UserFilterType.ACTIVITY: UserFilterConfig(
|
||||
fsm_state=AdminStates.viewing_user_from_last_activity_list,
|
||||
title='👥 <b>Пользователи по активности</b>',
|
||||
empty_message='🕒 Пользователи с активностью не найдены',
|
||||
pagination_prefix='admin_users_activity_list',
|
||||
order_param='order_by_last_activity',
|
||||
),
|
||||
UserFilterType.SPENDING: UserFilterConfig(
|
||||
fsm_state=AdminStates.viewing_user_from_spending_list,
|
||||
title='👥 <b>Пользователи по сумме трат</b>',
|
||||
empty_message='💳 Пользователи с тратами не найдены',
|
||||
pagination_prefix='admin_users_spending_list',
|
||||
order_param='order_by_total_spent',
|
||||
),
|
||||
UserFilterType.PURCHASES: UserFilterConfig(
|
||||
fsm_state=AdminStates.viewing_user_from_purchases_list,
|
||||
title='👥 <b>Пользователи по количеству покупок</b>',
|
||||
empty_message='🛒 Пользователи с покупками не найдены',
|
||||
pagination_prefix='admin_users_purchases_list',
|
||||
order_param='order_by_purchase_count',
|
||||
),
|
||||
UserFilterType.CAMPAIGN: UserFilterConfig(
|
||||
fsm_state=AdminStates.viewing_user_from_campaign_list,
|
||||
title='👥 <b>Пользователи по кампании регистрации</b>',
|
||||
@@ -127,6 +96,13 @@ USER_FILTER_CONFIGS: dict[UserFilterType, UserFilterConfig] = {
|
||||
pagination_prefix='admin_users_campaign_list',
|
||||
order_param='', # использует специальный метод
|
||||
),
|
||||
UserFilterType.POTENTIAL_CUSTOMERS: UserFilterConfig(
|
||||
fsm_state=AdminStates.viewing_user_from_potential_customers_list,
|
||||
title='👥 <b>Потенциальные клиенты</b>',
|
||||
empty_message='💰 Потенциальные клиенты не найдены',
|
||||
pagination_prefix='admin_users_potential_customers_list',
|
||||
order_param='', # использует специальный метод
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -173,34 +149,6 @@ def _build_user_button_text(
|
||||
days_left = (user.subscription.end_date - datetime.utcnow()).days
|
||||
button_text += f' | 📅 {days_left}д'
|
||||
|
||||
elif filter_type == UserFilterType.TRAFFIC:
|
||||
if user.subscription:
|
||||
sub = user.subscription
|
||||
used = sub.traffic_used_gb or 0.0
|
||||
if sub.traffic_limit_gb and sub.traffic_limit_gb > 0:
|
||||
limit_display = f'{sub.traffic_limit_gb}'
|
||||
else:
|
||||
limit_display = '♾️'
|
||||
traffic_display = f'{used:.1f}/{limit_display} ГБ'
|
||||
else:
|
||||
traffic_display = 'нет подписки'
|
||||
button_text = f'{status_emoji} {sub_emoji} {user.full_name} | 📶 {traffic_display}'
|
||||
if user.balance_kopeks > 0:
|
||||
button_text += f' | 💰 {settings.format_price(user.balance_kopeks)}'
|
||||
|
||||
elif filter_type == UserFilterType.ACTIVITY:
|
||||
activity_display = format_time_ago(user.last_activity, language) if user.last_activity else 'неизвестно'
|
||||
button_text = f'{status_emoji} {sub_emoji} {user.full_name} | 🕒 {activity_display}'
|
||||
|
||||
elif filter_type in (UserFilterType.SPENDING, UserFilterType.PURCHASES):
|
||||
stats = extra_data.get(user.id, {'total_spent': 0, 'purchase_count': 0}) if extra_data else {}
|
||||
total_spent = stats.get('total_spent', 0)
|
||||
purchases = stats.get('purchase_count', 0)
|
||||
if filter_type == UserFilterType.SPENDING:
|
||||
button_text = f'{status_emoji} {user.full_name} | 💳 {settings.format_price(total_spent)} | 🛒 {purchases}'
|
||||
else:
|
||||
button_text = f'{status_emoji} {user.full_name} | 🛒 {purchases} | 💳 {settings.format_price(total_spent)}'
|
||||
|
||||
elif filter_type == UserFilterType.CAMPAIGN:
|
||||
info = extra_data.get(user.id, {}) if extra_data else {}
|
||||
campaign_name = info.get('campaign_name') or 'Без кампании'
|
||||
@@ -219,18 +167,6 @@ def _build_user_button_text(
|
||||
button_text = f'{status_emoji} {sub_emoji} {short_name}'
|
||||
if user.balance_kopeks > 0:
|
||||
button_text += f' | 💰 {settings.format_price(user.balance_kopeks)}'
|
||||
elif filter_type == UserFilterType.TRAFFIC:
|
||||
if user.subscription:
|
||||
sub = user.subscription
|
||||
used = sub.traffic_used_gb or 0.0
|
||||
if sub.traffic_limit_gb and sub.traffic_limit_gb > 0:
|
||||
limit_display = f'{sub.traffic_limit_gb}'
|
||||
else:
|
||||
limit_display = '♾️'
|
||||
traffic_display = f'{used:.1f}/{limit_display} ГБ'
|
||||
else:
|
||||
traffic_display = 'нет'
|
||||
button_text = f'{status_emoji} {sub_emoji} {short_name} | 📶 {traffic_display}'
|
||||
else:
|
||||
button_text = f'{status_emoji} {short_name}'
|
||||
|
||||
@@ -280,10 +216,6 @@ async def _show_users_list_filtered(
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
# Для spending/purchases нужны дополнительные данные
|
||||
if filter_type in (UserFilterType.SPENDING, UserFilterType.PURCHASES):
|
||||
extra_data = await user_service.get_user_spending_stats_map(db, [user.id for user in users])
|
||||
|
||||
# Формируем текст заголовка
|
||||
text = f'{config.title} (стр. {page}/{users_data["total_pages"]})\n\n'
|
||||
text += 'Нажмите на пользователя для управления:'
|
||||
@@ -576,38 +508,122 @@ async def show_users_ready_to_renew(
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_users_list_by_traffic(
|
||||
async def show_potential_customers(
|
||||
callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext, page: int = 1
|
||||
):
|
||||
"""Список пользователей, отсортированный по использованному трафику (убывание)."""
|
||||
await _show_users_list_filtered(callback, db_user, db, state, UserFilterType.TRAFFIC, page)
|
||||
"""Показывает пользователей без активной подписки с балансом >= месячной цены."""
|
||||
await state.set_state(AdminStates.viewing_user_from_potential_customers_list)
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
from app.config import PERIOD_PRICES
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_users_list_by_last_activity(
|
||||
callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext, page: int = 1
|
||||
):
|
||||
"""Список пользователей, отсортированный по последней активности."""
|
||||
await _show_users_list_filtered(callback, db_user, db, state, UserFilterType.ACTIVITY, page)
|
||||
monthly_price = PERIOD_PRICES.get(30, 99000)
|
||||
|
||||
user_service = UserService()
|
||||
users_data = await user_service.get_potential_customers(
|
||||
db,
|
||||
min_balance_kopeks=monthly_price,
|
||||
page=page,
|
||||
limit=10,
|
||||
)
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_users_list_by_spending(
|
||||
callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext, page: int = 1
|
||||
):
|
||||
"""Список пользователей, отсортированный по сумме трат (убывание)."""
|
||||
await _show_users_list_filtered(callback, db_user, db, state, UserFilterType.SPENDING, page)
|
||||
amount_text = settings.format_price(monthly_price)
|
||||
header = texts.t(
|
||||
'ADMIN_USERS_FILTER_POTENTIAL_CUSTOMERS_TITLE',
|
||||
'💰 Потенциальные клиенты',
|
||||
)
|
||||
description = texts.t(
|
||||
'ADMIN_USERS_FILTER_POTENTIAL_CUSTOMERS_DESC',
|
||||
'Пользователи без активной подписки с балансом {amount} или больше.',
|
||||
).format(amount=amount_text)
|
||||
|
||||
if not users_data['users']:
|
||||
empty_text = texts.t(
|
||||
'ADMIN_USERS_FILTER_POTENTIAL_CUSTOMERS_EMPTY',
|
||||
'Сейчас нет пользователей, которые подходят под этот фильтр.',
|
||||
)
|
||||
await callback.message.edit_text(
|
||||
f'{header}\n\n{description}\n\n{empty_text}',
|
||||
reply_markup=get_admin_users_keyboard(db_user.language),
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_users_list_by_purchases(
|
||||
callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext, page: int = 1
|
||||
):
|
||||
"""Список пользователей, отсортированный по количеству покупок (убывание)."""
|
||||
await _show_users_list_filtered(callback, db_user, db, state, UserFilterType.PURCHASES, page)
|
||||
text = f'{header}\n\n{description}\n\n'
|
||||
text += 'Нажмите на пользователя для управления:'
|
||||
|
||||
keyboard = []
|
||||
|
||||
for user in users_data['users']:
|
||||
subscription = user.subscription
|
||||
status_emoji = '✅' if user.status == UserStatus.ACTIVE.value else '🚫'
|
||||
subscription_emoji = '❌'
|
||||
|
||||
if subscription:
|
||||
if subscription.is_trial:
|
||||
subscription_emoji = '🎁'
|
||||
elif subscription.is_active:
|
||||
subscription_emoji = '💎'
|
||||
else:
|
||||
subscription_emoji = '⏰'
|
||||
|
||||
button_text = (
|
||||
f'{status_emoji} {subscription_emoji} {user.full_name} | 💰 {settings.format_price(user.balance_kopeks)}'
|
||||
)
|
||||
|
||||
if len(button_text) > 60:
|
||||
short_name = user.full_name
|
||||
if len(short_name) > 20:
|
||||
short_name = short_name[:17] + '...'
|
||||
button_text = (
|
||||
f'{status_emoji} {subscription_emoji} {short_name} | 💰 {settings.format_price(user.balance_kopeks)}'
|
||||
)
|
||||
|
||||
keyboard.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=button_text,
|
||||
callback_data=f'admin_user_manage_{user.id}',
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
if users_data['total_pages'] > 1:
|
||||
pagination_row = get_admin_pagination_keyboard(
|
||||
users_data['current_page'],
|
||||
users_data['total_pages'],
|
||||
'admin_users_potential_customers_list',
|
||||
'admin_users_potential_customers_filter',
|
||||
db_user.language,
|
||||
).inline_keyboard[0]
|
||||
keyboard.append(pagination_row)
|
||||
|
||||
keyboard.extend(
|
||||
[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text='🔍 Поиск',
|
||||
callback_data='admin_users_search',
|
||||
),
|
||||
types.InlineKeyboardButton(
|
||||
text='📊 Статистика',
|
||||
callback_data='admin_users_stats',
|
||||
),
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text='⬅️ Назад',
|
||||
callback_data='admin_users',
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@@ -647,62 +663,6 @@ async def handle_users_balance_list_pagination(
|
||||
await show_users_list_by_balance(callback, db_user, db, state, 1)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def handle_users_traffic_list_pagination(
|
||||
callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext
|
||||
):
|
||||
try:
|
||||
callback_parts = callback.data.split('_')
|
||||
page = int(callback_parts[-1])
|
||||
await show_users_list_by_traffic(callback, db_user, db, state, page)
|
||||
except (ValueError, IndexError) as e:
|
||||
logger.error(f'Ошибка парсинга номера страницы: {e}')
|
||||
await show_users_list_by_traffic(callback, db_user, db, state, 1)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def handle_users_activity_list_pagination(
|
||||
callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext
|
||||
):
|
||||
try:
|
||||
callback_parts = callback.data.split('_')
|
||||
page = int(callback_parts[-1])
|
||||
await show_users_list_by_last_activity(callback, db_user, db, state, page)
|
||||
except (ValueError, IndexError) as e:
|
||||
logger.error(f'Ошибка парсинга номера страницы: {e}')
|
||||
await show_users_list_by_last_activity(callback, db_user, db, state, 1)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def handle_users_spending_list_pagination(
|
||||
callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext
|
||||
):
|
||||
try:
|
||||
callback_parts = callback.data.split('_')
|
||||
page = int(callback_parts[-1])
|
||||
await show_users_list_by_spending(callback, db_user, db, state, page)
|
||||
except (ValueError, IndexError) as e:
|
||||
logger.error(f'Ошибка парсинга номера страницы: {e}')
|
||||
await show_users_list_by_spending(callback, db_user, db, state, 1)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def handle_users_purchases_list_pagination(
|
||||
callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext
|
||||
):
|
||||
try:
|
||||
callback_parts = callback.data.split('_')
|
||||
page = int(callback_parts[-1])
|
||||
await show_users_list_by_purchases(callback, db_user, db, state, page)
|
||||
except (ValueError, IndexError) as e:
|
||||
logger.error(f'Ошибка парсинга номера страницы: {e}')
|
||||
await show_users_list_by_purchases(callback, db_user, db, state, 1)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def handle_users_ready_to_renew_pagination(
|
||||
@@ -716,6 +676,19 @@ async def handle_users_ready_to_renew_pagination(
|
||||
await show_users_ready_to_renew(callback, db_user, db, state, 1)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def handle_potential_customers_pagination(
|
||||
callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext
|
||||
):
|
||||
try:
|
||||
page = int(callback.data.split('_')[-1])
|
||||
await show_potential_customers(callback, db_user, db, state, page)
|
||||
except (ValueError, IndexError) as e:
|
||||
logger.error(f'Ошибка парсинга номера страницы: {e}')
|
||||
await show_potential_customers(callback, db_user, db, state, 1)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def handle_users_campaign_list_pagination(
|
||||
@@ -1310,18 +1283,12 @@ async def show_user_management(callback: types.CallbackQuery, db_user: User, db:
|
||||
current_state = await state.get_state()
|
||||
if current_state == AdminStates.viewing_user_from_balance_list:
|
||||
back_callback = 'admin_users_balance_filter'
|
||||
elif current_state == AdminStates.viewing_user_from_traffic_list:
|
||||
back_callback = 'admin_users_traffic_filter'
|
||||
elif current_state == AdminStates.viewing_user_from_last_activity_list:
|
||||
back_callback = 'admin_users_activity_filter'
|
||||
elif current_state == AdminStates.viewing_user_from_spending_list:
|
||||
back_callback = 'admin_users_spending_filter'
|
||||
elif current_state == AdminStates.viewing_user_from_purchases_list:
|
||||
back_callback = 'admin_users_purchases_filter'
|
||||
elif current_state == AdminStates.viewing_user_from_campaign_list:
|
||||
back_callback = 'admin_users_campaign_filter'
|
||||
elif current_state == AdminStates.viewing_user_from_ready_to_renew_list:
|
||||
back_callback = 'admin_users_ready_to_renew_filter'
|
||||
elif current_state == AdminStates.viewing_user_from_potential_customers_list:
|
||||
back_callback = 'admin_users_potential_customers_filter'
|
||||
|
||||
# Базовая клавиатура профиля
|
||||
kb = get_user_management_keyboard(user.id, user.status, db_user.language, back_callback)
|
||||
@@ -2635,8 +2602,15 @@ async def show_inactive_users(callback: types.CallbackQuery, db_user: User, db:
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
with_active_sub = sum(1 for u in inactive_users if u.subscription and u.subscription.is_active)
|
||||
will_delete = len(inactive_users) - with_active_sub
|
||||
|
||||
text = '🗑️ <b>Неактивные пользователи</b>\n'
|
||||
text += f'Без активности более {settings.INACTIVE_USER_DELETE_MONTHS} месяцев: {len(inactive_users)}\n\n'
|
||||
text += f'Без активности более {settings.INACTIVE_USER_DELETE_MONTHS} месяцев: {len(inactive_users)}\n'
|
||||
if with_active_sub > 0:
|
||||
text += f'🛡️ С активной подпиской (не будут удалены): {with_active_sub}\n'
|
||||
text += f'🗑️ Будет удалено: {will_delete}\n'
|
||||
text += '\n'
|
||||
|
||||
for user in inactive_users[:10]:
|
||||
if user.telegram_id:
|
||||
@@ -2645,7 +2619,9 @@ async def show_inactive_users(callback: types.CallbackQuery, db_user: User, db:
|
||||
else:
|
||||
user_link = f'<b>{user.full_name}</b>'
|
||||
user_id_display = user.email or f'#{user.id}'
|
||||
text += f'👤 {user_link}\n'
|
||||
has_active = user.subscription and user.subscription.is_active
|
||||
sub_badge = ' 🛡️' if has_active else ''
|
||||
text += f'👤 {user_link}{sub_badge}\n'
|
||||
text += f'🆔 <code>{user_id_display}</code>\n'
|
||||
last_activity_display = (
|
||||
format_time_ago(user.last_activity, db_user.language) if user.last_activity else 'Никогда'
|
||||
@@ -4288,10 +4264,14 @@ async def _calculate_subscription_period_price(
|
||||
@error_handler
|
||||
async def cleanup_inactive_users(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||||
user_service = UserService()
|
||||
deleted_count = await user_service.cleanup_inactive_users(db)
|
||||
deleted_count, skipped_count = await user_service.cleanup_inactive_users(db)
|
||||
|
||||
text = f'✅ Очистка завершена\n\nУдалено неактивных пользователей: {deleted_count}'
|
||||
if skipped_count > 0:
|
||||
text += f'\n⏭️ Пропущено (активная подписка): {skipped_count}'
|
||||
|
||||
await callback.message.edit_text(
|
||||
f'✅ Очистка завершена\n\nУдалено неактивных пользователей: {deleted_count}',
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[[types.InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_users')]]
|
||||
),
|
||||
@@ -4654,6 +4634,8 @@ async def admin_buy_subscription_execute(callback: types.CallbackQuery, db_user:
|
||||
full_name=target_user.full_name,
|
||||
username=target_user.username,
|
||||
telegram_id=target_user.telegram_id,
|
||||
email=target_user.email,
|
||||
user_id=target_user.id,
|
||||
),
|
||||
active_internal_squads=subscription.connected_squads,
|
||||
)
|
||||
@@ -4667,6 +4649,8 @@ async def admin_buy_subscription_execute(callback: types.CallbackQuery, db_user:
|
||||
full_name=target_user.full_name,
|
||||
username=target_user.username,
|
||||
telegram_id=target_user.telegram_id,
|
||||
email=target_user.email,
|
||||
user_id=target_user.id,
|
||||
)
|
||||
async with remnawave_service.get_api_client() as api:
|
||||
create_kwargs = dict(
|
||||
@@ -4678,10 +4662,12 @@ async def admin_buy_subscription_execute(callback: types.CallbackQuery, db_user:
|
||||
else 0,
|
||||
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
|
||||
telegram_id=target_user.telegram_id,
|
||||
email=target_user.email,
|
||||
description=settings.format_remnawave_user_description(
|
||||
full_name=target_user.full_name,
|
||||
username=target_user.username,
|
||||
telegram_id=target_user.telegram_id,
|
||||
email=target_user.email,
|
||||
),
|
||||
active_internal_squads=subscription.connected_squads,
|
||||
)
|
||||
@@ -5483,26 +5469,14 @@ def register_handlers(dp: Dispatcher):
|
||||
handle_users_balance_list_pagination, F.data.startswith('admin_users_balance_list_page_')
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
handle_users_traffic_list_pagination, F.data.startswith('admin_users_traffic_list_page_')
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
handle_users_activity_list_pagination, F.data.startswith('admin_users_activity_list_page_')
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
handle_users_spending_list_pagination, F.data.startswith('admin_users_spending_list_page_')
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
handle_users_purchases_list_pagination, F.data.startswith('admin_users_purchases_list_page_')
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
handle_users_ready_to_renew_pagination, F.data.startswith('admin_users_ready_to_renew_list_page_')
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
handle_potential_customers_pagination, F.data.startswith('admin_users_potential_customers_list_page_')
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
handle_users_campaign_list_pagination, F.data.startswith('admin_users_campaign_list_page_')
|
||||
)
|
||||
@@ -5659,14 +5633,8 @@ def register_handlers(dp: Dispatcher):
|
||||
|
||||
dp.callback_query.register(show_users_list_by_balance, F.data == 'admin_users_balance_filter')
|
||||
|
||||
dp.callback_query.register(show_users_list_by_traffic, F.data == 'admin_users_traffic_filter')
|
||||
|
||||
dp.callback_query.register(show_users_list_by_last_activity, F.data == 'admin_users_activity_filter')
|
||||
|
||||
dp.callback_query.register(show_users_list_by_spending, F.data == 'admin_users_spending_filter')
|
||||
|
||||
dp.callback_query.register(show_users_list_by_purchases, F.data == 'admin_users_purchases_filter')
|
||||
|
||||
dp.callback_query.register(show_users_ready_to_renew, F.data == 'admin_users_ready_to_renew_filter')
|
||||
|
||||
dp.callback_query.register(show_potential_customers, F.data == 'admin_users_potential_customers_filter')
|
||||
|
||||
dp.callback_query.register(show_users_list_by_campaign, F.data == 'admin_users_campaign_filter')
|
||||
|
||||
@@ -8,7 +8,6 @@ from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.keyboards.inline import get_back_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.blacklist_service import blacklist_service
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.states import BalanceStates
|
||||
from app.utils.decorators import error_handler
|
||||
@@ -126,23 +125,6 @@ async def process_cryptobot_payment_amount(
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
# Проверяем, находится ли пользователь в черном списке
|
||||
is_blacklisted, blacklist_reason = await blacklist_service.is_user_blacklisted(
|
||||
message.from_user.id, message.from_user.username
|
||||
)
|
||||
|
||||
if is_blacklisted:
|
||||
logger.warning(f'🚫 Пользователь {message.from_user.id} находится в черном списке: {blacklist_reason}')
|
||||
try:
|
||||
await message.answer(
|
||||
f'🚫 Оплата невозможна\n\n'
|
||||
f'Причина: {blacklist_reason}\n\n'
|
||||
f'Если вы считаете, что это ошибка, обратитесь в поддержку.'
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка при отправке сообщения о блокировке: {e}')
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_cryptobot_enabled():
|
||||
|
||||
@@ -8,7 +8,6 @@ from app.database.models import User
|
||||
from app.external.telegram_stars import TelegramStarsService
|
||||
from app.keyboards.inline import get_back_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.blacklist_service import blacklist_service
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.states import BalanceStates
|
||||
from app.utils.decorators import error_handler
|
||||
@@ -94,23 +93,6 @@ async def process_stars_payment_amount(message: types.Message, db_user: User, am
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
# Проверяем, находится ли пользователь в черном списке
|
||||
is_blacklisted, blacklist_reason = await blacklist_service.is_user_blacklisted(
|
||||
message.from_user.id, message.from_user.username
|
||||
)
|
||||
|
||||
if is_blacklisted:
|
||||
logger.warning(f'🚫 Пользователь {message.from_user.id} находится в черном списке: {blacklist_reason}')
|
||||
try:
|
||||
await message.answer(
|
||||
f'🚫 Оплата невозможна\n\n'
|
||||
f'Причина: {blacklist_reason}\n\n'
|
||||
f'Если вы считаете, что это ошибка, обратитесь в поддержку.'
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка при отправке сообщения о блокировке: {e}')
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.TELEGRAM_STARS_ENABLED:
|
||||
|
||||
@@ -10,7 +10,6 @@ from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.keyboards.inline import get_back_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.blacklist_service import blacklist_service
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.states import BalanceStates
|
||||
from app.utils.decorators import error_handler
|
||||
@@ -171,23 +170,6 @@ async def process_yookassa_payment_amount(
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
# Проверяем, находится ли пользователь в черном списке
|
||||
is_blacklisted, blacklist_reason = await blacklist_service.is_user_blacklisted(
|
||||
message.from_user.id, message.from_user.username
|
||||
)
|
||||
|
||||
if is_blacklisted:
|
||||
logger.warning(f'🚫 Пользователь {message.from_user.id} находится в черном списке: {blacklist_reason}')
|
||||
try:
|
||||
await message.answer(
|
||||
f'🚫 Оплата невозможна\n\n'
|
||||
f'Причина: {blacklist_reason}\n\n'
|
||||
f'Если вы считаете, что это ошибка, обратитесь в поддержку.'
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка при отправке сообщения о блокировке: {e}')
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_yookassa_enabled():
|
||||
@@ -338,23 +320,6 @@ async def process_yookassa_sbp_payment_amount(
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
# Проверяем, находится ли пользователь в черном списке
|
||||
is_blacklisted, blacklist_reason = await blacklist_service.is_user_blacklisted(
|
||||
message.from_user.id, message.from_user.username
|
||||
)
|
||||
|
||||
if is_blacklisted:
|
||||
logger.warning(f'🚫 Пользователь {message.from_user.id} находится в черном списке: {blacklist_reason}')
|
||||
try:
|
||||
await message.answer(
|
||||
f'🚫 Оплата невозможна\n\n'
|
||||
f'Причина: {blacklist_reason}\n\n'
|
||||
f'Если вы считаете, что это ошибка, обратитесь в поддержку.'
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка при отправке сообщения о блокировке: {e}')
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_yookassa_enabled() or not settings.YOOKASSA_SBP_ENABLED:
|
||||
|
||||
+11
-11
@@ -153,7 +153,7 @@ async def show_main_menu(
|
||||
):
|
||||
if db_user is None:
|
||||
# Пользователь не найден, используем язык по умолчанию
|
||||
texts = get_texts(settings.DEFAULT_LANGUAGE_CODE)
|
||||
texts = get_texts(settings.DEFAULT_LANGUAGE)
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
'USER_NOT_FOUND_ERROR',
|
||||
@@ -243,7 +243,7 @@ async def handle_profile_unavailable(callback: types.CallbackQuery) -> None:
|
||||
async def show_service_rules(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||||
if db_user is None:
|
||||
# Пользователь не найден, используем язык по умолчанию
|
||||
texts = get_texts(settings.DEFAULT_LANGUAGE_CODE)
|
||||
texts = get_texts(settings.DEFAULT_LANGUAGE)
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
'USER_NOT_FOUND_ERROR',
|
||||
@@ -277,7 +277,7 @@ async def show_info_menu(
|
||||
):
|
||||
if db_user is None:
|
||||
# Пользователь не найден, используем язык по умолчанию
|
||||
texts = get_texts(settings.DEFAULT_LANGUAGE_CODE)
|
||||
texts = get_texts(settings.DEFAULT_LANGUAGE)
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
'USER_NOT_FOUND_ERROR',
|
||||
@@ -320,7 +320,7 @@ async def show_promo_groups_info(
|
||||
):
|
||||
if db_user is None:
|
||||
# Пользователь не найден, используем язык по умолчанию
|
||||
texts = get_texts(settings.DEFAULT_LANGUAGE_CODE)
|
||||
texts = get_texts(settings.DEFAULT_LANGUAGE)
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
'USER_NOT_FOUND_ERROR',
|
||||
@@ -468,7 +468,7 @@ async def show_faq_pages(
|
||||
):
|
||||
if db_user is None:
|
||||
# Пользователь не найден, используем язык по умолчанию
|
||||
texts = get_texts(settings.DEFAULT_LANGUAGE_CODE)
|
||||
texts = get_texts(settings.DEFAULT_LANGUAGE)
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
'USER_NOT_FOUND_ERROR',
|
||||
@@ -525,7 +525,7 @@ async def show_faq_page(
|
||||
):
|
||||
if db_user is None:
|
||||
# Пользователь не найден, используем язык по умолчанию
|
||||
texts = get_texts(settings.DEFAULT_LANGUAGE_CODE)
|
||||
texts = get_texts(settings.DEFAULT_LANGUAGE)
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
'USER_NOT_FOUND_ERROR',
|
||||
@@ -662,7 +662,7 @@ async def show_privacy_policy(
|
||||
):
|
||||
if db_user is None:
|
||||
# Пользователь не найден, используем язык по умолчанию
|
||||
texts = get_texts(settings.DEFAULT_LANGUAGE_CODE)
|
||||
texts = get_texts(settings.DEFAULT_LANGUAGE)
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
'USER_NOT_FOUND_ERROR',
|
||||
@@ -779,7 +779,7 @@ async def show_public_offer(
|
||||
):
|
||||
if db_user is None:
|
||||
# Пользователь не найден, используем язык по умолчанию
|
||||
texts = get_texts(settings.DEFAULT_LANGUAGE_CODE)
|
||||
texts = get_texts(settings.DEFAULT_LANGUAGE)
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
'USER_NOT_FOUND_ERROR',
|
||||
@@ -896,7 +896,7 @@ async def show_language_menu(
|
||||
):
|
||||
if db_user is None:
|
||||
# Пользователь не найден, используем язык по умолчанию
|
||||
texts = get_texts(settings.DEFAULT_LANGUAGE_CODE)
|
||||
texts = get_texts(settings.DEFAULT_LANGUAGE)
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
'USER_NOT_FOUND_ERROR',
|
||||
@@ -938,7 +938,7 @@ async def process_language_change(
|
||||
):
|
||||
if db_user is None:
|
||||
# Пользователь не найден, используем язык по умолчанию
|
||||
texts = get_texts(settings.DEFAULT_LANGUAGE_CODE)
|
||||
texts = get_texts(settings.DEFAULT_LANGUAGE)
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
'USER_NOT_FOUND_ERROR',
|
||||
@@ -1000,7 +1000,7 @@ async def process_language_change(
|
||||
async def handle_back_to_menu(callback: types.CallbackQuery, state: FSMContext, db_user: User, db: AsyncSession):
|
||||
if db_user is None:
|
||||
# Пользователь не найден, используем язык по умолчанию
|
||||
texts = get_texts(settings.DEFAULT_LANGUAGE_CODE)
|
||||
texts = get_texts(settings.DEFAULT_LANGUAGE)
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
'USER_NOT_FOUND_ERROR',
|
||||
|
||||
@@ -9,7 +9,6 @@ from app.database.models import User
|
||||
from app.keyboards.inline import get_back_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
from app.services.blacklist_service import blacklist_service
|
||||
from app.services.promocode_service import PromoCodeService
|
||||
from app.states import PromoCodeStates
|
||||
from app.utils.decorators import error_handler
|
||||
@@ -71,23 +70,6 @@ async def activate_promocode_for_registration(db: AsyncSession, user_id: int, co
|
||||
|
||||
@error_handler
|
||||
async def process_promocode(message: types.Message, db_user: User, state: FSMContext, db: AsyncSession):
|
||||
# Проверяем, находится ли пользователь в черном списке
|
||||
is_blacklisted, blacklist_reason = await blacklist_service.is_user_blacklisted(
|
||||
message.from_user.id, message.from_user.username
|
||||
)
|
||||
|
||||
if is_blacklisted:
|
||||
logger.warning(f'🚫 Пользователь {message.from_user.id} находится в черном списке: {blacklist_reason}')
|
||||
try:
|
||||
await message.answer(
|
||||
f'🚫 Активация промокода невозможна\n\n'
|
||||
f'Причина: {blacklist_reason}\n\n'
|
||||
f'Если вы считаете, что это ошибка, обратитесь в поддержку.'
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка при отправке сообщения о блокировке: {e}')
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
code = message.text.strip()
|
||||
|
||||
+170
-101
@@ -3,7 +3,7 @@ from datetime import datetime
|
||||
|
||||
from aiogram import Bot, Dispatcher, F, types
|
||||
from aiogram.enums import ChatMemberStatus
|
||||
from aiogram.exceptions import TelegramForbiddenError
|
||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
|
||||
from aiogram.filters import Command, StateFilter
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -22,6 +22,7 @@ from app.database.crud.user import (
|
||||
from app.database.crud.user_message import get_random_active_message
|
||||
from app.database.models import PinnedMessage, SubscriptionStatus, UserStatus
|
||||
from app.keyboards.inline import (
|
||||
get_back_keyboard,
|
||||
get_language_selection_keyboard,
|
||||
get_main_menu_keyboard_async,
|
||||
get_post_registration_keyboard,
|
||||
@@ -35,7 +36,6 @@ from app.middlewares.channel_checker import (
|
||||
get_pending_payload_from_redis,
|
||||
)
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
from app.services.blacklist_service import blacklist_service
|
||||
from app.services.campaign_service import AdvertisingCampaignService
|
||||
from app.services.main_menu_button_service import MainMenuButtonService
|
||||
from app.services.pinned_message_service import (
|
||||
@@ -309,24 +309,27 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
|
||||
logger.info(f'🚀 START: Обработка /start от {message.from_user.id}')
|
||||
|
||||
data = await state.get_data() or {}
|
||||
had_pending_payload = 'pending_start_payload' in data
|
||||
pending_start_payload = data.pop('pending_start_payload', None)
|
||||
had_campaign_notification_flag = 'campaign_notification_sent' in data
|
||||
campaign_notification_sent = data.pop('campaign_notification_sent', False)
|
||||
state_needs_update = had_pending_payload or had_campaign_notification_flag
|
||||
|
||||
# ИСПРАВЛЕНИЕ БАГА: используем .get() вместо .pop() для campaign_notification_sent
|
||||
# pending_start_payload обрабатывается отдельно ниже
|
||||
campaign_notification_sent = data.get('campaign_notification_sent', False)
|
||||
state_needs_update = False
|
||||
|
||||
# Получаем payload из state или Redis
|
||||
pending_start_payload = data.get('pending_start_payload', None)
|
||||
|
||||
# Если в FSM state нет payload, пробуем получить из Redis (резервный механизм)
|
||||
if not pending_start_payload:
|
||||
redis_payload = await get_pending_payload_from_redis(message.from_user.id)
|
||||
if redis_payload:
|
||||
pending_start_payload = redis_payload
|
||||
data['pending_start_payload'] = redis_payload
|
||||
state_needs_update = True
|
||||
logger.info(
|
||||
"📦 START: Payload '%s' восстановлен из Redis (fallback)",
|
||||
pending_start_payload,
|
||||
)
|
||||
# Очищаем Redis после получения
|
||||
await delete_pending_payload_from_redis(message.from_user.id)
|
||||
# НЕ удаляем Redis payload здесь - удаление только после успешной регистрации
|
||||
|
||||
referral_code = None
|
||||
campaign = None
|
||||
@@ -483,9 +486,24 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
|
||||
logger.info(f'🔄 Удаленный пользователь {user.telegram_id} начинает повторную регистрацию')
|
||||
|
||||
try:
|
||||
from sqlalchemy import delete
|
||||
from sqlalchemy import delete, update as sa_update
|
||||
|
||||
from app.database.models import PromoCodeUse, ReferralEarning, SubscriptionServer, Transaction
|
||||
from app.database.models import (
|
||||
CloudPaymentsPayment,
|
||||
CryptoBotPayment,
|
||||
FreekassaPayment,
|
||||
HeleketPayment,
|
||||
KassaAiPayment,
|
||||
MulenPayPayment,
|
||||
Pal24Payment,
|
||||
PlategaPayment,
|
||||
PromoCodeUse,
|
||||
ReferralEarning,
|
||||
SubscriptionServer,
|
||||
Transaction,
|
||||
WataPayment,
|
||||
YooKassaPayment,
|
||||
)
|
||||
|
||||
if user.subscription:
|
||||
await decrement_subscription_server_counts(db, user.subscription)
|
||||
@@ -500,9 +518,37 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
|
||||
|
||||
await db.execute(delete(PromoCodeUse).where(PromoCodeUse.user_id == user.id))
|
||||
|
||||
await db.execute(
|
||||
sa_update(ReferralEarning)
|
||||
.where(ReferralEarning.user_id == user.id)
|
||||
.values(referral_transaction_id=None)
|
||||
)
|
||||
await db.execute(
|
||||
sa_update(ReferralEarning)
|
||||
.where(ReferralEarning.referral_id == user.id)
|
||||
.values(referral_transaction_id=None)
|
||||
)
|
||||
await db.execute(delete(ReferralEarning).where(ReferralEarning.user_id == user.id))
|
||||
await db.execute(delete(ReferralEarning).where(ReferralEarning.referral_id == user.id))
|
||||
|
||||
# Обнуляем transaction_id во всех таблицах платежей перед удалением транзакций
|
||||
payment_models = [
|
||||
YooKassaPayment,
|
||||
CryptoBotPayment,
|
||||
HeleketPayment,
|
||||
MulenPayPayment,
|
||||
Pal24Payment,
|
||||
WataPayment,
|
||||
PlategaPayment,
|
||||
CloudPaymentsPayment,
|
||||
FreekassaPayment,
|
||||
KassaAiPayment,
|
||||
]
|
||||
for payment_model in payment_models:
|
||||
await db.execute(
|
||||
sa_update(payment_model).where(payment_model.user_id == user.id).values(transaction_id=None)
|
||||
)
|
||||
|
||||
await db.execute(delete(Transaction).where(Transaction.user_id == user.id))
|
||||
|
||||
user.status = UserStatus.ACTIVE.value
|
||||
@@ -774,12 +820,11 @@ async def process_rules_accept(callback: types.CallbackQuery, state: FSMContext,
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(rules_required_text, reply_markup=get_rules_keyboard(language))
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка при показе сообщения об отклонении правил: {e}')
|
||||
try:
|
||||
await callback.message.edit_text(rules_required_text, reply_markup=get_rules_keyboard(language))
|
||||
except:
|
||||
pass
|
||||
except TelegramBadRequest as e:
|
||||
if 'message is not modified' in str(e):
|
||||
pass # Сообщение уже содержит нужный текст
|
||||
else:
|
||||
logger.error(f'Ошибка при показе сообщения об отклонении правил: {e}')
|
||||
|
||||
logger.info(f'✅ Правила обработаны для пользователя {callback.from_user.id}')
|
||||
|
||||
@@ -990,25 +1035,6 @@ async def process_referral_code_skip(callback: types.CallbackQuery, state: FSMCo
|
||||
async def complete_registration_from_callback(callback: types.CallbackQuery, state: FSMContext, db: AsyncSession):
|
||||
logger.info(f'🎯 COMPLETE: Завершение регистрации для пользователя {callback.from_user.id}')
|
||||
|
||||
# Проверяем, находится ли пользователь в черном списке
|
||||
is_blacklisted, blacklist_reason = await blacklist_service.is_user_blacklisted(
|
||||
callback.from_user.id, callback.from_user.username
|
||||
)
|
||||
|
||||
if is_blacklisted:
|
||||
logger.warning(f'🚫 Пользователь {callback.from_user.id} находится в черном списке: {blacklist_reason}')
|
||||
try:
|
||||
await callback.message.answer(
|
||||
f'🚫 Регистрация невозможна\n\n'
|
||||
f'Причина: {blacklist_reason}\n\n'
|
||||
f'Если вы считаете, что это ошибка, обратитесь в поддержку.'
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка при отправке сообщения о блокировке: {e}')
|
||||
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
existing_user = await get_user_by_telegram_id(db, callback.from_user.id)
|
||||
|
||||
if existing_user and existing_user.status == UserStatus.ACTIVE.value:
|
||||
@@ -1167,6 +1193,12 @@ async def complete_registration_from_callback(callback: types.CallbackQuery, sta
|
||||
refresh_subscription_error,
|
||||
)
|
||||
|
||||
# ИСПРАВЛЕНИЕ БАГА: Очищаем Redis payload после успешной регистрации
|
||||
await delete_pending_payload_from_redis(callback.from_user.id)
|
||||
logger.info(
|
||||
'🗑️ COMPLETE_FROM_CALLBACK: Redis payload удален после успешной регистрации пользователя %s', user.telegram_id
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
|
||||
if campaign_message:
|
||||
@@ -1253,25 +1285,6 @@ async def complete_registration_from_callback(callback: types.CallbackQuery, sta
|
||||
async def complete_registration(message: types.Message, state: FSMContext, db: AsyncSession):
|
||||
logger.info(f'🎯 COMPLETE: Завершение регистрации для пользователя {message.from_user.id}')
|
||||
|
||||
# Проверяем, находится ли пользователь в черном списке
|
||||
is_blacklisted, blacklist_reason = await blacklist_service.is_user_blacklisted(
|
||||
message.from_user.id, message.from_user.username
|
||||
)
|
||||
|
||||
if is_blacklisted:
|
||||
logger.warning(f'🚫 Пользователь {message.from_user.id} находится в черном списке: {blacklist_reason}')
|
||||
try:
|
||||
await message.answer(
|
||||
f'🚫 Регистрация невозможна\n\n'
|
||||
f'Причина: {blacklist_reason}\n\n'
|
||||
f'Если вы считаете, что это ошибка, обратитесь в поддержку.'
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка при отправке сообщения о блокировке: {e}')
|
||||
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
existing_user = await get_user_by_telegram_id(db, message.from_user.id)
|
||||
|
||||
if existing_user and existing_user.status == UserStatus.ACTIVE.value:
|
||||
@@ -1454,6 +1467,10 @@ async def complete_registration(message: types.Message, state: FSMContext, db: A
|
||||
refresh_subscription_error,
|
||||
)
|
||||
|
||||
# ИСПРАВЛЕНИЕ БАГА: Очищаем Redis payload после успешной регистрации
|
||||
await delete_pending_payload_from_redis(message.from_user.id)
|
||||
logger.info('🗑️ COMPLETE: Redis payload удален после успешной регистрации пользователя %s', user.telegram_id)
|
||||
|
||||
await state.clear()
|
||||
|
||||
if campaign_message:
|
||||
@@ -1477,9 +1494,16 @@ async def complete_registration(message: types.Message, state: FSMContext, db: A
|
||||
|
||||
if offer_text and not skip_welcome_offer:
|
||||
try:
|
||||
# Если у пользователя уже есть подписка (например, от промокода), не предлагаем триал
|
||||
user_has_subscription = user.subscription and getattr(user.subscription, 'is_active', False)
|
||||
if user_has_subscription:
|
||||
keyboard = get_back_keyboard(user.language, callback_data='back_to_menu')
|
||||
else:
|
||||
keyboard = get_post_registration_keyboard(user.language)
|
||||
|
||||
await message.answer(
|
||||
offer_text,
|
||||
reply_markup=get_post_registration_keyboard(user.language),
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
logger.info(f'✅ Приветственное сообщение отправлено пользователю {user.telegram_id}')
|
||||
await _send_pinned_message(message.bot, db, user)
|
||||
@@ -1717,52 +1741,26 @@ async def required_sub_channel_check(
|
||||
try:
|
||||
state_data = await state.get_data() or {}
|
||||
|
||||
pending_start_payload = state_data.pop('pending_start_payload', None)
|
||||
# Получаем payload БЕЗ удаления - удалим только после успешной проверки подписки
|
||||
pending_start_payload = state_data.get('pending_start_payload')
|
||||
|
||||
# Если в FSM state нет payload, пробуем получить из Redis (резервный механизм)
|
||||
if not pending_start_payload:
|
||||
redis_payload = await get_pending_payload_from_redis(query.from_user.id)
|
||||
if redis_payload:
|
||||
pending_start_payload = redis_payload
|
||||
state_data['pending_start_payload'] = redis_payload
|
||||
logger.info(
|
||||
"📦 CHANNEL CHECK: Payload '%s' восстановлен из Redis (fallback)",
|
||||
pending_start_payload,
|
||||
)
|
||||
|
||||
state_updated = pending_start_payload is not None
|
||||
|
||||
if pending_start_payload:
|
||||
logger.info(
|
||||
"📦 CHANNEL CHECK: Найден сохраненный payload '%s'",
|
||||
pending_start_payload,
|
||||
)
|
||||
|
||||
# Очищаем Redis после получения payload
|
||||
await delete_pending_payload_from_redis(query.from_user.id)
|
||||
|
||||
# Всегда обновляем referral_code если есть новый payload
|
||||
# (исправление бага с устаревшими данными в state)
|
||||
campaign = await get_campaign_by_start_parameter(
|
||||
db,
|
||||
pending_start_payload,
|
||||
only_active=True,
|
||||
)
|
||||
|
||||
if campaign:
|
||||
state_data['campaign_id'] = campaign.id
|
||||
logger.info(
|
||||
'📣 CHANNEL CHECK: Кампания %s восстановлена из payload',
|
||||
campaign.id,
|
||||
)
|
||||
else:
|
||||
state_data['referral_code'] = pending_start_payload
|
||||
logger.info(
|
||||
'🎯 CHANNEL CHECK: Payload интерпретирован как реферальный код',
|
||||
)
|
||||
|
||||
if state_updated:
|
||||
await state.set_data(state_data)
|
||||
|
||||
user = db_user
|
||||
if not user:
|
||||
user = await get_user_by_telegram_id(db, query.from_user.id)
|
||||
@@ -1781,11 +1779,61 @@ async def required_sub_channel_check(
|
||||
ChatMemberStatus.ADMINISTRATOR,
|
||||
ChatMemberStatus.CREATOR,
|
||||
]:
|
||||
# НЕ удаляем payload - пользователь может попробовать снова после подписки
|
||||
logger.info(
|
||||
"📦 CHANNEL CHECK: Подписка не подтверждена, payload '%s' сохранён для следующей попытки",
|
||||
pending_start_payload,
|
||||
)
|
||||
return await query.answer(
|
||||
texts.t('CHANNEL_SUBSCRIBE_REQUIRED_ALERT', '❌ Вы не подписались на канал!'),
|
||||
show_alert=True,
|
||||
)
|
||||
|
||||
# Подписка подтверждена - теперь удаляем payload и обрабатываем его
|
||||
if pending_start_payload:
|
||||
# Удаляем из FSM state
|
||||
state_data.pop('pending_start_payload', None)
|
||||
|
||||
# Очищаем Redis после успешной проверки подписки
|
||||
await delete_pending_payload_from_redis(query.from_user.id)
|
||||
|
||||
# Всегда обновляем referral_code если есть новый payload
|
||||
# (исправление бага с устаревшими данными в state)
|
||||
campaign = await get_campaign_by_start_parameter(
|
||||
db,
|
||||
pending_start_payload,
|
||||
only_active=True,
|
||||
)
|
||||
|
||||
# Обрабатываем payload только если ещё не обработан
|
||||
# (проверяем по наличию referral_code или campaign_id в state)
|
||||
if not state_data.get('referral_code') and not state_data.get('campaign_id'):
|
||||
campaign = await get_campaign_by_start_parameter(
|
||||
db,
|
||||
pending_start_payload,
|
||||
only_active=True,
|
||||
)
|
||||
|
||||
if campaign:
|
||||
state_data['campaign_id'] = campaign.id
|
||||
logger.info(
|
||||
'📣 CHANNEL CHECK: Кампания %s восстановлена из payload',
|
||||
campaign.id,
|
||||
)
|
||||
else:
|
||||
state_data['referral_code'] = pending_start_payload
|
||||
logger.info(
|
||||
'🎯 CHANNEL CHECK: Payload интерпретирован как реферальный код: %s',
|
||||
pending_start_payload,
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
'✅ CHANNEL CHECK: Реферальный код уже сохранен в state: %s',
|
||||
state_data.get('referral_code') or f'campaign_id={state_data.get("campaign_id")}',
|
||||
)
|
||||
|
||||
await state.set_data(state_data)
|
||||
|
||||
if user and user.subscription:
|
||||
subscription = user.subscription
|
||||
if subscription.is_trial and subscription.status == SubscriptionStatus.DISABLED.value:
|
||||
@@ -1821,14 +1869,18 @@ async def required_sub_channel_check(
|
||||
except Exception as e:
|
||||
logger.warning(f'Не удалось удалить сообщение: {e}')
|
||||
|
||||
# ИСПРАВЛЕНИЕ БАГА: Очищаем Redis payload ТОЛЬКО после успешной проверки подписки
|
||||
# и перед показом главного меню или завершением регистрации
|
||||
if pending_start_payload:
|
||||
await delete_pending_payload_from_redis(query.from_user.id)
|
||||
logger.info('🗑️ CHANNEL CHECK: Redis payload удален после успешной проверки подписки')
|
||||
|
||||
if user and user.status != UserStatus.DELETED.value:
|
||||
has_active_subscription, subscription_is_active = _calculate_subscription_flags(user.subscription)
|
||||
|
||||
menu_text = await get_main_menu_text(user, texts, db)
|
||||
|
||||
from aiogram.types import FSInputFile
|
||||
|
||||
from app.utils.message_patch import LOGO_PATH
|
||||
from app.utils.message_patch import _cache_logo_file_id, get_logo_media
|
||||
|
||||
is_admin = settings.is_admin(user.telegram_id)
|
||||
is_moderator = (not is_admin) and SupportSettingsService.is_moderator(user.telegram_id)
|
||||
@@ -1855,13 +1907,14 @@ async def required_sub_channel_check(
|
||||
)
|
||||
|
||||
if settings.ENABLE_LOGO_MODE:
|
||||
await bot.send_photo(
|
||||
_result = await bot.send_photo(
|
||||
chat_id=query.from_user.id,
|
||||
photo=FSInputFile(LOGO_PATH),
|
||||
photo=get_logo_media(),
|
||||
caption=menu_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode='HTML',
|
||||
)
|
||||
_cache_logo_file_id(_result)
|
||||
else:
|
||||
await bot.send_message(
|
||||
chat_id=query.from_user.id,
|
||||
@@ -1903,6 +1956,11 @@ async def required_sub_channel_check(
|
||||
)
|
||||
await db.refresh(user, ['subscription'])
|
||||
|
||||
# ИСПРАВЛЕНИЕ БАГА: Очищаем pending_start_payload из state после создания пользователя
|
||||
state_data.pop('pending_start_payload', None)
|
||||
await state.set_data(state_data)
|
||||
logger.info('✅ CHANNEL CHECK: pending_start_payload удален из state после создания пользователя')
|
||||
|
||||
# Обрабатываем реферальную регистрацию
|
||||
if referrer_id:
|
||||
try:
|
||||
@@ -1916,9 +1974,7 @@ async def required_sub_channel_check(
|
||||
|
||||
menu_text = await get_main_menu_text(user, texts, db)
|
||||
|
||||
from aiogram.types import FSInputFile
|
||||
|
||||
from app.utils.message_patch import LOGO_PATH
|
||||
from app.utils.message_patch import _cache_logo_file_id, get_logo_media
|
||||
|
||||
is_admin = settings.is_admin(user.telegram_id)
|
||||
is_moderator = (not is_admin) and SupportSettingsService.is_moderator(user.telegram_id)
|
||||
@@ -1945,13 +2001,14 @@ async def required_sub_channel_check(
|
||||
)
|
||||
|
||||
if settings.ENABLE_LOGO_MODE:
|
||||
await bot.send_photo(
|
||||
_result = await bot.send_photo(
|
||||
chat_id=query.from_user.id,
|
||||
photo=FSInputFile(LOGO_PATH),
|
||||
photo=get_logo_media(),
|
||||
caption=menu_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode='HTML',
|
||||
)
|
||||
_cache_logo_file_id(_result)
|
||||
else:
|
||||
await bot.send_message(
|
||||
chat_id=query.from_user.id,
|
||||
@@ -1971,19 +2028,18 @@ async def required_sub_channel_check(
|
||||
)
|
||||
await state.set_state(RegistrationStates.waiting_for_referral_code)
|
||||
else:
|
||||
from aiogram.types import FSInputFile
|
||||
|
||||
from app.utils.message_patch import LOGO_PATH
|
||||
from app.utils.message_patch import _cache_logo_file_id, get_logo_media
|
||||
|
||||
rules_text = await get_rules(language)
|
||||
|
||||
if settings.ENABLE_LOGO_MODE:
|
||||
await bot.send_photo(
|
||||
_result = await bot.send_photo(
|
||||
chat_id=query.from_user.id,
|
||||
photo=FSInputFile(LOGO_PATH),
|
||||
photo=get_logo_media(),
|
||||
caption=rules_text,
|
||||
reply_markup=get_rules_keyboard(language),
|
||||
)
|
||||
_cache_logo_file_id(_result)
|
||||
else:
|
||||
await bot.send_message(
|
||||
chat_id=query.from_user.id,
|
||||
@@ -1992,9 +2048,22 @@ async def required_sub_channel_check(
|
||||
)
|
||||
await state.set_state(RegistrationStates.waiting_for_rules_accept)
|
||||
|
||||
except TelegramBadRequest as e:
|
||||
error_msg = str(e).lower()
|
||||
if 'query is too old' in error_msg or 'query id is invalid' in error_msg:
|
||||
logger.debug('Устаревший callback в required_sub_channel_check, игнорируем')
|
||||
else:
|
||||
logger.error(f'Ошибка Telegram API в required_sub_channel_check: {e}')
|
||||
try:
|
||||
await query.answer(f'{texts.ERROR}!', show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка в required_sub_channel_check: {e}')
|
||||
await query.answer(f'{texts.ERROR}!', show_alert=True)
|
||||
try:
|
||||
await query.answer(f'{texts.ERROR}!', show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
|
||||
@@ -39,6 +39,21 @@ async def handle_autopay_menu(callback: types.CallbackQuery, db_user: User, db:
|
||||
)
|
||||
return
|
||||
|
||||
# Суточные подписки имеют свой механизм продления, глобальный autopay не применяется
|
||||
try:
|
||||
await db.refresh(subscription, ['tariff'])
|
||||
except Exception:
|
||||
pass
|
||||
if subscription.tariff and getattr(subscription.tariff, 'is_daily', False):
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
'AUTOPAY_NOT_AVAILABLE_FOR_DAILY',
|
||||
'Автоплатеж недоступен для суточных тарифов. Списание происходит автоматически раз в сутки.',
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
status = (
|
||||
texts.t('AUTOPAY_STATUS_ENABLED', 'включен')
|
||||
if subscription.autopay_enabled
|
||||
@@ -68,6 +83,24 @@ async def toggle_autopay(callback: types.CallbackQuery, db_user: User, db: Async
|
||||
subscription = db_user.subscription
|
||||
enable = callback.data == 'autopay_enable'
|
||||
|
||||
# Суточные подписки имеют свой механизм продления (DailySubscriptionService),
|
||||
# глобальный autopay для них запрещён
|
||||
if enable:
|
||||
try:
|
||||
await db.refresh(subscription, ['tariff'])
|
||||
except Exception:
|
||||
pass
|
||||
if subscription.tariff and getattr(subscription.tariff, 'is_daily', False):
|
||||
texts = get_texts(db_user.language)
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
'AUTOPAY_NOT_AVAILABLE_FOR_DAILY',
|
||||
'Автоплатеж недоступен для суточных тарифов. Списание происходит автоматически раз в сутки.',
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
await update_subscription_autopay(db, subscription, enable)
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
@@ -416,8 +416,14 @@ def get_traffic_switch_keyboard(
|
||||
|
||||
buttons.append([InlineKeyboardButton(text=button_text, callback_data=f'switch_traffic_{gb}')])
|
||||
|
||||
language_code = (language or 'ru').split('-')[0].lower()
|
||||
buttons.append(
|
||||
[InlineKeyboardButton(text='⬅️ Назад' if language == 'ru' else '⬅️ Back', callback_data='subscription_settings')]
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text='⬅️ Назад' if language_code in {'ru', 'fa'} else '⬅️ Back',
|
||||
callback_data='subscription_settings',
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
@@ -468,6 +468,10 @@ async def select_country(callback: types.CallbackQuery, state: FSMContext, db_us
|
||||
country_uuid = callback.data.split('_')[1]
|
||||
data = await state.get_data()
|
||||
|
||||
if 'period_days' not in data:
|
||||
await callback.answer('❌ Данные подписки устарели. Начните оформление заново.', show_alert=True)
|
||||
return
|
||||
|
||||
selected_countries = data.get('countries', [])
|
||||
if country_uuid in selected_countries:
|
||||
selected_countries.remove(country_uuid)
|
||||
|
||||
@@ -22,6 +22,7 @@ from app.keyboards.inline import (
|
||||
from app.localization.texts import get_texts
|
||||
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.pagination import paginate_list
|
||||
from app.utils.pricing_utils import (
|
||||
apply_percentage_discount,
|
||||
@@ -185,18 +186,29 @@ async def handle_change_devices(callback: types.CallbackQuery, db_user: User, db
|
||||
if tariff:
|
||||
price_per_device = tariff_device_price
|
||||
price_text = texts.format_price(price_per_device)
|
||||
tariff_min_devices = getattr(tariff, 'device_limit', 1) or 1
|
||||
|
||||
# Добавляем информацию о минимальном лимите если он больше 1
|
||||
min_devices_info = ''
|
||||
if tariff_min_devices > 1:
|
||||
min_devices_info = texts.t(
|
||||
'CHANGE_DEVICES_MIN_LIMIT_INFO',
|
||||
'\nМинимум для тарифа: {min_devices} устройств\n',
|
||||
).format(min_devices=tariff_min_devices)
|
||||
|
||||
prompt_text = texts.t(
|
||||
'CHANGE_DEVICES_PROMPT_TARIFF',
|
||||
(
|
||||
'📱 <b>Изменение количества устройств</b>\n\n'
|
||||
'Текущий лимит: {current_devices} устройств\n'
|
||||
'Цена за доп. устройство: {price}/мес\n'
|
||||
'{min_devices_info}'
|
||||
'Выберите новое количество устройств:\n\n'
|
||||
'💡 <b>Важно:</b>\n'
|
||||
'• При увеличении - доплата пропорционально оставшемуся времени\n'
|
||||
'• При уменьшении - возврат средств не производится'
|
||||
),
|
||||
).format(current_devices=current_devices, price=price_text)
|
||||
).format(current_devices=current_devices, price=price_text, min_devices_info=min_devices_info)
|
||||
else:
|
||||
prompt_text = texts.t(
|
||||
'CHANGE_DEVICES_PROMPT',
|
||||
@@ -275,6 +287,18 @@ async def confirm_change_devices(callback: types.CallbackQuery, db_user: User, d
|
||||
)
|
||||
return
|
||||
|
||||
# Проверяем минимальное количество устройств на тарифе
|
||||
tariff_min_devices = (getattr(tariff, 'device_limit', 1) or 1) if tariff else 1
|
||||
if new_devices_count < tariff_min_devices:
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
'DEVICES_MIN_LIMIT_REACHED',
|
||||
'⚠️ Минимальное количество устройств для вашего тарифа: {limit}',
|
||||
).format(limit=tariff_min_devices),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
devices_difference = new_devices_count - current_devices
|
||||
|
||||
if devices_difference > 0:
|
||||
@@ -290,26 +314,54 @@ async def confirm_change_devices(callback: types.CallbackQuery, db_user: User, d
|
||||
chargeable_devices = additional_devices
|
||||
|
||||
devices_price_per_month = chargeable_devices * price_per_device
|
||||
months_hint = get_remaining_months(subscription.end_date)
|
||||
period_hint_days = months_hint * 30 if months_hint > 0 else None
|
||||
devices_discount_percent = _get_addon_discount_percent_for_user(
|
||||
db_user,
|
||||
'devices',
|
||||
period_hint_days,
|
||||
)
|
||||
discounted_per_month, discount_per_month = apply_percentage_discount(
|
||||
devices_price_per_month,
|
||||
devices_discount_percent,
|
||||
)
|
||||
price, charged_months = calculate_prorated_price(
|
||||
discounted_per_month,
|
||||
subscription.end_date,
|
||||
)
|
||||
total_discount = discount_per_month * charged_months
|
||||
|
||||
# Проверяем является ли тариф суточным
|
||||
is_daily_tariff = tariff and getattr(tariff, 'is_daily', False)
|
||||
|
||||
if is_daily_tariff:
|
||||
# Для суточных тарифов считаем по дням (как в кабинете)
|
||||
now = datetime.utcnow()
|
||||
days_left = max(1, (subscription.end_date - now).days)
|
||||
period_hint_days = days_left
|
||||
|
||||
devices_discount_percent = _get_addon_discount_percent_for_user(
|
||||
db_user,
|
||||
'devices',
|
||||
period_hint_days,
|
||||
)
|
||||
discounted_per_month, discount_per_month = apply_percentage_discount(
|
||||
devices_price_per_month,
|
||||
devices_discount_percent,
|
||||
)
|
||||
# Цена = месячная_цена * days_left / 30
|
||||
price = int(discounted_per_month * days_left / 30)
|
||||
price = max(100, price) # Минимум 1 рубль
|
||||
total_discount = int(discount_per_month * days_left / 30)
|
||||
period_label = f'{days_left} дн.' if days_left > 1 else '1 день'
|
||||
else:
|
||||
# Для обычных тарифов - по месяцам
|
||||
months_hint = get_remaining_months(subscription.end_date)
|
||||
period_hint_days = months_hint * 30 if months_hint > 0 else None
|
||||
|
||||
devices_discount_percent = _get_addon_discount_percent_for_user(
|
||||
db_user,
|
||||
'devices',
|
||||
period_hint_days,
|
||||
)
|
||||
discounted_per_month, discount_per_month = apply_percentage_discount(
|
||||
devices_price_per_month,
|
||||
devices_discount_percent,
|
||||
)
|
||||
price, charged_months = calculate_prorated_price(
|
||||
discounted_per_month,
|
||||
subscription.end_date,
|
||||
)
|
||||
total_discount = discount_per_month * charged_months
|
||||
period_label = f'{charged_months} мес'
|
||||
|
||||
if price > 0 and db_user.balance_kopeks < price:
|
||||
missing_kopeks = price - db_user.balance_kopeks
|
||||
required_text = f'{texts.format_price(price)} (за {charged_months} мес)'
|
||||
required_text = f'{texts.format_price(price)} (за {period_label})'
|
||||
message_text = texts.t(
|
||||
'ADDON_INSUFFICIENT_FUNDS_MESSAGE',
|
||||
(
|
||||
@@ -325,11 +377,28 @@ async def confirm_change_devices(callback: types.CallbackQuery, db_user: User, d
|
||||
missing=texts.format_price(missing_kopeks),
|
||||
)
|
||||
|
||||
# Сохраняем корзину для автопокупки после пополнения баланса
|
||||
await user_cart_service.save_user_cart(
|
||||
user_id=db_user.id,
|
||||
cart_data={
|
||||
'cart_mode': 'add_devices',
|
||||
'devices_to_add': devices_difference,
|
||||
'price_kopeks': price,
|
||||
},
|
||||
)
|
||||
logger.info(
|
||||
'Сохранена корзина add_devices для пользователя %s: +%s устройств, цена %s коп.',
|
||||
db_user.telegram_id,
|
||||
devices_difference,
|
||||
price,
|
||||
)
|
||||
|
||||
await callback.message.answer(
|
||||
message_text,
|
||||
reply_markup=get_insufficient_balance_keyboard(
|
||||
db_user.language,
|
||||
amount_kopeks=missing_kopeks,
|
||||
has_saved_cart=True,
|
||||
),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
@@ -343,10 +412,11 @@ async def confirm_change_devices(callback: types.CallbackQuery, db_user: User, d
|
||||
if price > 0:
|
||||
cost_text = texts.t(
|
||||
'DEVICE_CHANGE_EXTRA_COST',
|
||||
'Доплата: {amount} (за {months} мес)',
|
||||
'Доплата: {amount} (за {period})',
|
||||
).format(
|
||||
amount=texts.format_price(price),
|
||||
months=charged_months,
|
||||
period=period_label,
|
||||
months=period_label,
|
||||
)
|
||||
if total_discount > 0:
|
||||
cost_text += texts.t(
|
||||
@@ -427,13 +497,41 @@ async def execute_change_devices(callback: types.CallbackQuery, db_user: User, d
|
||||
subscription = db_user.subscription
|
||||
current_devices = subscription.device_limit
|
||||
|
||||
if not settings.is_devices_selection_enabled():
|
||||
# Проверяем тариф подписки
|
||||
tariff = None
|
||||
if subscription.tariff_id:
|
||||
from app.database.crud.tariff import get_tariff_by_id
|
||||
|
||||
tariff = await get_tariff_by_id(db, subscription.tariff_id)
|
||||
|
||||
# Для тарифов - проверяем разрешено ли изменение устройств
|
||||
if tariff:
|
||||
tariff_device_price = getattr(tariff, 'device_price_kopeks', None)
|
||||
if tariff_device_price is None or tariff_device_price <= 0:
|
||||
await callback.answer(
|
||||
texts.t('TARIFF_DEVICES_DISABLED', '⚠️ Изменение устройств недоступно для вашего тарифа'),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
elif not settings.is_devices_selection_enabled():
|
||||
await callback.answer(
|
||||
texts.t('DEVICES_SELECTION_DISABLED', '⚠️ Изменение количества устройств недоступно'),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
# Проверяем минимальное количество устройств на тарифе
|
||||
tariff_min_devices = (getattr(tariff, 'device_limit', 1) or 1) if tariff else 1
|
||||
if new_devices_count < tariff_min_devices:
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
'DEVICES_MIN_LIMIT_REACHED',
|
||||
'⚠️ Минимальное количество устройств для вашего тарифа: {limit}',
|
||||
).format(limit=tariff_min_devices),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
if price > 0:
|
||||
success = await subtract_user_balance(
|
||||
@@ -464,7 +562,7 @@ async def execute_change_devices(callback: types.CallbackQuery, db_user: User, d
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.update_remnawave_user(db, subscription)
|
||||
|
||||
# При уменьшении лимита - сбросить лишние устройства
|
||||
# При уменьшении лимита - удалить лишние устройства (последние подключённые)
|
||||
devices_reset_count = 0
|
||||
if new_devices_count < current_devices and db_user.remnawave_uuid:
|
||||
try:
|
||||
@@ -475,16 +573,34 @@ async def execute_change_devices(callback: types.CallbackQuery, db_user: User, d
|
||||
devices_list = response['response'].get('devices', [])
|
||||
connected_count = len(devices_list)
|
||||
|
||||
# Если подключённых устройств больше чем новый лимит - сбросить все
|
||||
# Если подключённых устройств больше чем новый лимит - удалить лишние
|
||||
if connected_count > new_devices_count:
|
||||
devices_to_remove = connected_count - new_devices_count
|
||||
logger.info(
|
||||
f'🔧 Сброс устройств при уменьшении лимита: '
|
||||
f'подключено {connected_count}, новый лимит {new_devices_count}'
|
||||
f'🔧 Удаление лишних устройств при уменьшении лимита: '
|
||||
f'подключено {connected_count}, новый лимит {new_devices_count}, '
|
||||
f'удаляем {devices_to_remove}'
|
||||
)
|
||||
await api.reset_user_devices(db_user.remnawave_uuid)
|
||||
devices_reset_count = connected_count
|
||||
|
||||
# Сортируем по дате (последние в конце) и удаляем последние
|
||||
sorted_devices = sorted(
|
||||
devices_list,
|
||||
key=lambda d: d.get('updatedAt') or d.get('createdAt') or '',
|
||||
)
|
||||
devices_to_delete = sorted_devices[-devices_to_remove:]
|
||||
|
||||
for device in devices_to_delete:
|
||||
device_hwid = device.get('hwid')
|
||||
if device_hwid:
|
||||
try:
|
||||
delete_data = {'userUuid': db_user.remnawave_uuid, 'hwid': device_hwid}
|
||||
await api._make_request('POST', '/api/hwid/devices/delete', data=delete_data)
|
||||
devices_reset_count += 1
|
||||
logger.info(f'✅ Удалено устройство {device_hwid}')
|
||||
except Exception as del_error:
|
||||
logger.error(f'Ошибка удаления устройства {device_hwid}: {del_error}')
|
||||
except Exception as reset_error:
|
||||
logger.error(f'Ошибка сброса устройств при уменьшении лимита: {reset_error}')
|
||||
logger.error(f'Ошибка удаления устройств при уменьшении лимита: {reset_error}')
|
||||
|
||||
await db.refresh(db_user)
|
||||
await db.refresh(subscription)
|
||||
@@ -524,9 +640,9 @@ async def execute_change_devices(callback: types.CallbackQuery, db_user: User, d
|
||||
).format(old=current_devices, new=new_devices_count)
|
||||
if devices_reset_count > 0:
|
||||
success_text += texts.t(
|
||||
'DEVICE_CHANGE_DEVICES_RESET',
|
||||
'\n🔄 Сброшено устройств: {count}\n💡 Подключите заново нужные устройства (до {limit} шт.)\n\n',
|
||||
).format(count=devices_reset_count, limit=new_devices_count)
|
||||
'DEVICE_CHANGE_DEVICES_REMOVED',
|
||||
'\n🗑 Удалено устройств: {count}\n',
|
||||
).format(count=devices_reset_count)
|
||||
success_text += texts.t(
|
||||
'DEVICE_CHANGE_NO_REFUND_INFO',
|
||||
'ℹ️ Возврат средств не производится',
|
||||
@@ -949,35 +1065,63 @@ async def confirm_add_devices(callback: types.CallbackQuery, db_user: User, db:
|
||||
return
|
||||
|
||||
devices_price_per_month = devices_count * price_per_device
|
||||
months_hint = get_remaining_months(subscription.end_date)
|
||||
period_hint_days = months_hint * 30 if months_hint > 0 else None
|
||||
devices_discount_percent = _get_addon_discount_percent_for_user(
|
||||
db_user,
|
||||
'devices',
|
||||
period_hint_days,
|
||||
)
|
||||
discounted_per_month, discount_per_month = apply_percentage_discount(
|
||||
devices_price_per_month,
|
||||
devices_discount_percent,
|
||||
)
|
||||
price, charged_months = calculate_prorated_price(
|
||||
discounted_per_month,
|
||||
subscription.end_date,
|
||||
)
|
||||
total_discount = discount_per_month * charged_months
|
||||
|
||||
# Проверяем является ли тариф суточным
|
||||
is_daily_tariff = tariff and getattr(tariff, 'is_daily', False)
|
||||
|
||||
if is_daily_tariff:
|
||||
# Для суточных тарифов считаем по дням (как в кабинете)
|
||||
now = datetime.utcnow()
|
||||
days_left = max(1, (subscription.end_date - now).days)
|
||||
period_hint_days = days_left
|
||||
|
||||
devices_discount_percent = _get_addon_discount_percent_for_user(
|
||||
db_user,
|
||||
'devices',
|
||||
period_hint_days,
|
||||
)
|
||||
discounted_per_month, discount_per_month = apply_percentage_discount(
|
||||
devices_price_per_month,
|
||||
devices_discount_percent,
|
||||
)
|
||||
# Цена = месячная_цена * days_left / 30
|
||||
price = int(discounted_per_month * days_left / 30)
|
||||
price = max(100, price) # Минимум 1 рубль
|
||||
total_discount = int(discount_per_month * days_left / 30)
|
||||
period_label = f'{days_left} дн.' if days_left > 1 else '1 день'
|
||||
else:
|
||||
# Для обычных тарифов - по месяцам
|
||||
months_hint = get_remaining_months(subscription.end_date)
|
||||
period_hint_days = months_hint * 30 if months_hint > 0 else None
|
||||
|
||||
devices_discount_percent = _get_addon_discount_percent_for_user(
|
||||
db_user,
|
||||
'devices',
|
||||
period_hint_days,
|
||||
)
|
||||
discounted_per_month, discount_per_month = apply_percentage_discount(
|
||||
devices_price_per_month,
|
||||
devices_discount_percent,
|
||||
)
|
||||
price, charged_months = calculate_prorated_price(
|
||||
discounted_per_month,
|
||||
subscription.end_date,
|
||||
)
|
||||
total_discount = discount_per_month * charged_months
|
||||
period_label = f'{charged_months} мес'
|
||||
|
||||
logger.info(
|
||||
'Добавление %s устройств: %.2f₽/мес × %s мес = %.2f₽ (скидка %.2f₽)',
|
||||
'Добавление %s устройств: %.2f₽/мес × %s = %.2f₽ (скидка %.2f₽)',
|
||||
devices_count,
|
||||
discounted_per_month / 100,
|
||||
charged_months,
|
||||
period_label,
|
||||
price / 100,
|
||||
total_discount / 100,
|
||||
)
|
||||
|
||||
if db_user.balance_kopeks < price:
|
||||
missing_kopeks = price - db_user.balance_kopeks
|
||||
required_text = f'{texts.format_price(price)} (за {charged_months} мес)'
|
||||
required_text = f'{texts.format_price(price)} (за {period_label})'
|
||||
message_text = texts.t(
|
||||
'ADDON_INSUFFICIENT_FUNDS_MESSAGE',
|
||||
(
|
||||
@@ -993,12 +1137,29 @@ async def confirm_add_devices(callback: types.CallbackQuery, db_user: User, db:
|
||||
missing=texts.format_price(missing_kopeks),
|
||||
)
|
||||
|
||||
# Сохраняем корзину для автопокупки после пополнения баланса
|
||||
await user_cart_service.save_user_cart(
|
||||
user_id=db_user.id,
|
||||
cart_data={
|
||||
'cart_mode': 'add_devices',
|
||||
'devices_to_add': devices_count,
|
||||
'price_kopeks': price,
|
||||
},
|
||||
)
|
||||
logger.info(
|
||||
'Сохранена корзина add_devices для пользователя %s: +%s устройств, цена %s коп.',
|
||||
db_user.telegram_id,
|
||||
devices_count,
|
||||
price,
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
message_text,
|
||||
reply_markup=get_insufficient_balance_keyboard(
|
||||
db_user.language,
|
||||
resume_callback=resume_callback,
|
||||
amount_kopeks=missing_kopeks,
|
||||
has_saved_cart=True,
|
||||
),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
@@ -1007,7 +1168,7 @@ async def confirm_add_devices(callback: types.CallbackQuery, db_user: User, db:
|
||||
|
||||
try:
|
||||
success = await subtract_user_balance(
|
||||
db, db_user, price, f'Добавление {devices_count} устройств на {charged_months} мес'
|
||||
db, db_user, price, f'Добавление {devices_count} устройств на {period_label}'
|
||||
)
|
||||
|
||||
if not success:
|
||||
@@ -1024,18 +1185,30 @@ async def confirm_add_devices(callback: types.CallbackQuery, db_user: User, db:
|
||||
user_id=db_user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
amount_kopeks=price,
|
||||
description=f'Добавление {devices_count} устройств на {charged_months} мес',
|
||||
description=f'Добавление {devices_count} устройств на {period_label}',
|
||||
)
|
||||
|
||||
await db.refresh(db_user)
|
||||
await db.refresh(subscription)
|
||||
|
||||
# Отправляем уведомление админам о докупке устройств
|
||||
try:
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
notification_service = AdminNotificationService(callback.bot)
|
||||
old_device_limit = subscription.device_limit - devices_count
|
||||
await notification_service.send_subscription_update_notification(
|
||||
db, db_user, subscription, 'devices', old_device_limit, subscription.device_limit, price
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка отправки уведомления о докупке устройств: {e}')
|
||||
|
||||
success_text = (
|
||||
'✅ Устройства успешно добавлены!\n\n'
|
||||
f'📱 Добавлено: {devices_count} устройств\n'
|
||||
f'Новый лимит: {subscription.device_limit} устройств\n'
|
||||
)
|
||||
success_text += f'💰 Списано: {texts.format_price(price)} (за {charged_months} мес)'
|
||||
success_text += f'💰 Списано: {texts.format_price(price)} (за {period_label})'
|
||||
if total_discount > 0:
|
||||
success_text += f' (скидка {devices_discount_percent}%: -{texts.format_price(total_discount)})'
|
||||
|
||||
|
||||
@@ -24,6 +24,10 @@ async def _prepare_subscription_summary(
|
||||
texts,
|
||||
) -> tuple[str, dict[str, Any]]:
|
||||
summary_data = dict(data)
|
||||
|
||||
if 'period_days' not in summary_data:
|
||||
raise KeyError('period_days missing from subscription data — FSM state likely expired')
|
||||
|
||||
countries = await _get_available_countries(db_user.promo_group_id)
|
||||
|
||||
months_in_period = calculate_months_from_days(summary_data['period_days'])
|
||||
@@ -428,7 +432,7 @@ async def get_subscription_info_text(subscription, texts, db_user, db: AsyncSess
|
||||
type=type_text,
|
||||
end_date=format_local_datetime(subscription.end_date, '%d.%m.%Y %H:%M'),
|
||||
days_left=max(0, subscription.days_left),
|
||||
traffic_used=texts.format_traffic(subscription.traffic_used_gb),
|
||||
traffic_used=texts.format_traffic(subscription.traffic_used_gb, is_limit=False),
|
||||
traffic_limit=traffic_text,
|
||||
countries_count=len(subscription.connected_squads),
|
||||
devices_used=devices_used,
|
||||
|
||||
@@ -39,7 +39,6 @@ from app.keyboards.inline import (
|
||||
)
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
from app.services.blacklist_service import blacklist_service
|
||||
from app.services.remnawave_service import RemnaWaveConfigurationError
|
||||
from app.services.subscription_checkout_service import (
|
||||
clear_subscription_checkout_draft,
|
||||
@@ -561,6 +560,15 @@ async def show_trial_offer(callback: types.CallbackQuery, db_user: User, db: Asy
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
# Проверяем, отключён ли триал для этого типа пользователя
|
||||
if settings.is_trial_disabled_for_user(getattr(db_user, 'auth_type', 'telegram')):
|
||||
await callback.message.edit_text(
|
||||
texts.t('TRIAL_DISABLED_FOR_USER_TYPE', 'Пробный период недоступен'),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
# Проверяем, использовал ли пользователь триал
|
||||
# PENDING триальные подписки не считаются - пользователь может повторить оплату
|
||||
trial_blocked = False
|
||||
@@ -753,6 +761,15 @@ async def activate_trial(callback: types.CallbackQuery, db_user: User, db: Async
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
# Проверяем, отключён ли триал для этого типа пользователя
|
||||
if settings.is_trial_disabled_for_user(getattr(db_user, 'auth_type', 'telegram')):
|
||||
await callback.message.edit_text(
|
||||
texts.t('TRIAL_DISABLED_FOR_USER_TYPE', 'Пробный период недоступен'),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
# Проверяем, использовал ли пользователь триал
|
||||
# PENDING триальные подписки не считаются - пользователь может повторить оплату
|
||||
trial_blocked = False
|
||||
@@ -1385,6 +1402,11 @@ async def return_to_saved_cart(callback: types.CallbackQuery, state: FSMContext,
|
||||
|
||||
prepared_cart_data = dict(cart_data)
|
||||
|
||||
if 'period_days' not in prepared_cart_data:
|
||||
await callback.answer('❌ Корзина повреждена. Оформите подписку заново.', show_alert=True)
|
||||
await user_cart_service.delete_user_cart(db_user.id)
|
||||
return
|
||||
|
||||
if not settings.is_devices_selection_enabled():
|
||||
try:
|
||||
from .pricing import _prepare_subscription_summary
|
||||
@@ -1724,24 +1746,6 @@ async def handle_extend_subscription(callback: types.CallbackQuery, db_user: Use
|
||||
|
||||
|
||||
async def confirm_extend_subscription(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||||
# Проверяем, находится ли пользователь в черном списке
|
||||
is_blacklisted, blacklist_reason = await blacklist_service.is_user_blacklisted(
|
||||
callback.from_user.id, callback.from_user.username
|
||||
)
|
||||
|
||||
if is_blacklisted:
|
||||
logger.warning(f'🚫 Пользователь {callback.from_user.id} находится в черном списке: {blacklist_reason}')
|
||||
try:
|
||||
await callback.answer(
|
||||
f'🚫 Продление подписки невозможно\n\n'
|
||||
f'Причина: {blacklist_reason}\n\n'
|
||||
f'Если вы считаете, что это ошибка, обратитесь в поддержку.',
|
||||
show_alert=True,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка при отправке сообщения о блокировке: {e}')
|
||||
return
|
||||
|
||||
days = int(callback.data.split('_')[2])
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
@@ -2228,24 +2232,6 @@ async def devices_continue(callback: types.CallbackQuery, state: FSMContext, db_
|
||||
|
||||
|
||||
async def confirm_purchase(callback: types.CallbackQuery, state: FSMContext, db_user: User, db: AsyncSession):
|
||||
# Проверяем, находится ли пользователь в черном списке
|
||||
is_blacklisted, blacklist_reason = await blacklist_service.is_user_blacklisted(
|
||||
callback.from_user.id, callback.from_user.username
|
||||
)
|
||||
|
||||
if is_blacklisted:
|
||||
logger.warning(f'🚫 Пользователь {callback.from_user.id} находится в черном списке: {blacklist_reason}')
|
||||
try:
|
||||
await callback.answer(
|
||||
f'🚫 Покупка подписки невозможна\n\n'
|
||||
f'Причина: {blacklist_reason}\n\n'
|
||||
f'Если вы считаете, что это ошибка, обратитесь в поддержку.',
|
||||
show_alert=True,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка при отправке сообщения о блокировке: {e}')
|
||||
return
|
||||
|
||||
# Проверка ограничения на покупку/продление подписки
|
||||
if getattr(db_user, 'restriction_subscription', False):
|
||||
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
|
||||
@@ -3084,8 +3070,8 @@ async def handle_subscription_settings(callback: types.CallbackQuery, db_user: U
|
||||
|
||||
settings_text = settings_template.format(
|
||||
countries_count=len(subscription.connected_squads),
|
||||
traffic_used=texts.format_traffic(subscription.traffic_used_gb),
|
||||
traffic_limit=texts.format_traffic(subscription.traffic_limit_gb),
|
||||
traffic_used=texts.format_traffic(subscription.traffic_used_gb, is_limit=False),
|
||||
traffic_limit=texts.format_traffic(subscription.traffic_limit_gb, is_limit=True),
|
||||
devices_used=devices_used,
|
||||
devices_limit=devices_limit_display,
|
||||
)
|
||||
@@ -3241,6 +3227,9 @@ async def handle_trial_pay_with_balance(callback: types.CallbackQuery, db_user:
|
||||
|
||||
await db.refresh(db_user)
|
||||
|
||||
# Сохраняем ID до начала транзакции (на случай detached session)
|
||||
user_id_snapshot = db_user.id
|
||||
|
||||
# Создаем триальную подписку
|
||||
subscription: Subscription | None = None
|
||||
remnawave_user = None
|
||||
@@ -3402,22 +3391,33 @@ async def handle_trial_pay_with_balance(callback: types.CallbackQuery, db_user:
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
'Unexpected error during paid trial activation for user %s: %s',
|
||||
db_user.id,
|
||||
user_id_snapshot,
|
||||
error,
|
||||
)
|
||||
# Пытаемся откатить и вернуть деньги
|
||||
if subscription:
|
||||
await rollback_trial_subscription_activation(db, subscription)
|
||||
from app.database.crud.user import add_user_balance
|
||||
# Откатываем сессию чтобы очистить PendingRollbackError
|
||||
try:
|
||||
await db.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
await add_user_balance(
|
||||
db,
|
||||
db_user,
|
||||
trial_price_kopeks,
|
||||
texts.t('TRIAL_REFUND_DESCRIPTION', 'Возврат за неудачную активацию триала'),
|
||||
transaction_type=TransactionType.REFUND,
|
||||
)
|
||||
await db.refresh(db_user)
|
||||
# Пытаемся вернуть деньги
|
||||
try:
|
||||
from app.database.crud.user import add_user_balance
|
||||
|
||||
await add_user_balance(
|
||||
db,
|
||||
db_user,
|
||||
trial_price_kopeks,
|
||||
texts.t('TRIAL_REFUND_DESCRIPTION', 'Возврат за неудачную активацию триала'),
|
||||
transaction_type=TransactionType.REFUND,
|
||||
)
|
||||
await db.refresh(db_user)
|
||||
except Exception as refund_error:
|
||||
logger.error(
|
||||
'Failed to refund trial payment for user %s: %s',
|
||||
user_id_snapshot,
|
||||
refund_error,
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.t(
|
||||
@@ -4134,24 +4134,6 @@ async def handle_simple_subscription_purchase(
|
||||
db: AsyncSession,
|
||||
):
|
||||
"""Обрабатывает простую покупку подписки."""
|
||||
# Проверяем, находится ли пользователь в черном списке
|
||||
is_blacklisted, blacklist_reason = await blacklist_service.is_user_blacklisted(
|
||||
callback.from_user.id, callback.from_user.username
|
||||
)
|
||||
|
||||
if is_blacklisted:
|
||||
logger.warning(f'🚫 Пользователь {callback.from_user.id} находится в черном списке: {blacklist_reason}')
|
||||
try:
|
||||
await callback.answer(
|
||||
f'🚫 Простая покупка подписки невозможна\n\n'
|
||||
f'Причина: {blacklist_reason}\n\n'
|
||||
f'Если вы считаете, что это ошибка, обратитесь в поддержку.',
|
||||
show_alert=True,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка при отправке сообщения о блокировке: {e}')
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.SIMPLE_SUBSCRIPTION_ENABLED:
|
||||
|
||||
@@ -1424,10 +1424,23 @@ async def confirm_daily_tariff_purchase(
|
||||
# ==================== Продление по тарифу ====================
|
||||
|
||||
|
||||
def _calc_extra_devices_cost(tariff: Tariff, subscription_device_limit: int, period_days: int) -> int:
|
||||
"""Рассчитывает стоимость дополнительных устройств сверх тарифа для периода."""
|
||||
additional = max(0, subscription_device_limit - (tariff.device_limit or 1))
|
||||
if additional <= 0:
|
||||
return 0
|
||||
device_price = getattr(tariff, 'device_price_kopeks', None) or 0
|
||||
if device_price <= 0:
|
||||
return 0
|
||||
months = max(1, round(period_days / 30))
|
||||
return additional * device_price * months
|
||||
|
||||
|
||||
def get_tariff_extend_keyboard(
|
||||
tariff: Tariff,
|
||||
language: str,
|
||||
db_user: User | None = None,
|
||||
subscription_device_limit: int | None = None,
|
||||
) -> InlineKeyboardMarkup:
|
||||
"""Создает клавиатуру выбора периода для продления по тарифу с учетом скидок по периодам."""
|
||||
texts = get_texts(language)
|
||||
@@ -1438,6 +1451,10 @@ def get_tariff_extend_keyboard(
|
||||
period = int(period_str)
|
||||
price = prices[period_str]
|
||||
|
||||
# Добавляем стоимость дополнительных устройств
|
||||
if subscription_device_limit is not None:
|
||||
price += _calc_extra_devices_cost(tariff, subscription_device_limit, period)
|
||||
|
||||
# Получаем скидку для конкретного периода
|
||||
discount_percent = 0
|
||||
if db_user:
|
||||
@@ -1508,13 +1525,17 @@ async def show_tariff_extend(
|
||||
if has_period_discounts:
|
||||
discount_hint = '\n🎁 <i>Скидки зависят от выбранного периода</i>'
|
||||
|
||||
actual_device_limit = subscription.device_limit or tariff.device_limit
|
||||
|
||||
await callback.message.edit_text(
|
||||
f'🔄 <b>Продление подписки</b>{discount_hint}\n\n'
|
||||
f'📦 Тариф: <b>{tariff.name}</b>\n'
|
||||
f'📊 Трафик: {traffic}\n'
|
||||
f'📱 Устройств: {tariff.device_limit}\n\n'
|
||||
f'📱 Устройств: {actual_device_limit}\n\n'
|
||||
'Выберите период продления:',
|
||||
reply_markup=get_tariff_extend_keyboard(tariff, db_user.language, db_user=db_user),
|
||||
reply_markup=get_tariff_extend_keyboard(
|
||||
tariff, db_user.language, db_user=db_user, subscription_device_limit=actual_device_limit
|
||||
),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
await callback.answer()
|
||||
@@ -1538,12 +1559,16 @@ async def select_tariff_extend_period(
|
||||
await callback.answer('Тариф недоступен', show_alert=True)
|
||||
return
|
||||
|
||||
subscription = await get_subscription_by_user_id(db, db_user.id)
|
||||
actual_device_limit = (subscription.device_limit if subscription else None) or tariff.device_limit
|
||||
|
||||
# Получаем скидку для выбранного периода
|
||||
discount_percent = _get_user_period_discount(db_user, period)
|
||||
|
||||
# Получаем цену
|
||||
# Получаем цену (тариф + дополнительные устройства)
|
||||
prices = tariff.period_prices or {}
|
||||
base_price = prices.get(str(period), 0)
|
||||
base_price += _calc_extra_devices_cost(tariff, actual_device_limit, period)
|
||||
final_price = _apply_promo_discount(base_price, discount_percent)
|
||||
|
||||
# Проверяем баланс
|
||||
@@ -1560,7 +1585,7 @@ async def select_tariff_extend_period(
|
||||
f'✅ <b>Подтверждение продления</b>\n\n'
|
||||
f'📦 Тариф: <b>{tariff.name}</b>\n'
|
||||
f'📊 Трафик: {traffic}\n'
|
||||
f'📱 Устройств: {tariff.device_limit}\n'
|
||||
f'📱 Устройств: {actual_device_limit}\n'
|
||||
f'📅 Период: {_format_period(period)}\n'
|
||||
f'{discount_text}\n'
|
||||
f'💰 <b>К оплате: {_format_price_kopeks(final_price)}</b>\n\n'
|
||||
@@ -1572,9 +1597,6 @@ async def select_tariff_extend_period(
|
||||
else:
|
||||
missing = final_price - user_balance
|
||||
|
||||
# Получаем текущую подписку для сохранения в корзину
|
||||
subscription = await get_subscription_by_user_id(db, db_user.id)
|
||||
|
||||
# Сохраняем данные корзины для автопокупки после пополнения
|
||||
cart_data = {
|
||||
'cart_mode': 'extend',
|
||||
@@ -1588,7 +1610,7 @@ async def select_tariff_extend_period(
|
||||
'return_to_cart': True,
|
||||
'description': f'Продление тарифа {tariff.name} на {period} дней',
|
||||
'traffic_limit_gb': tariff.traffic_limit_gb,
|
||||
'device_limit': tariff.device_limit,
|
||||
'device_limit': actual_device_limit,
|
||||
'allowed_squads': tariff.allowed_squads or [],
|
||||
'discount_percent': discount_percent,
|
||||
}
|
||||
@@ -1641,12 +1663,15 @@ async def confirm_tariff_extend(
|
||||
await callback.answer('Подписка не найдена', show_alert=True)
|
||||
return
|
||||
|
||||
actual_device_limit = subscription.device_limit or tariff.device_limit
|
||||
|
||||
data = await state.get_data()
|
||||
discount_percent = data.get('extend_discount_percent', 0)
|
||||
|
||||
# Получаем цену
|
||||
# Получаем цену (тариф + дополнительные устройства)
|
||||
prices = tariff.period_prices or {}
|
||||
base_price = prices.get(str(period), 0)
|
||||
base_price += _calc_extra_devices_cost(tariff, actual_device_limit, period)
|
||||
final_price = _apply_promo_discount(base_price, discount_percent)
|
||||
|
||||
# Проверяем баланс
|
||||
@@ -1724,7 +1749,7 @@ async def confirm_tariff_extend(
|
||||
f'🎉 <b>Подписка успешно продлена!</b>\n\n'
|
||||
f'📦 Тариф: <b>{tariff.name}</b>\n'
|
||||
f'📊 Трафик: {traffic}\n'
|
||||
f'📱 Устройств: {tariff.device_limit}\n'
|
||||
f'📱 Устройств: {actual_device_limit}\n'
|
||||
f'📅 Добавлено: {_format_period(period)}\n'
|
||||
f'💰 Списано: {_format_price_kopeks(final_price)}',
|
||||
reply_markup=InlineKeyboardMarkup(
|
||||
@@ -2242,6 +2267,19 @@ async def confirm_tariff_switch(
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка обновления Remnawave при переключении тарифа: {e}')
|
||||
|
||||
# Гарантированный сброс устройств при смене тарифа
|
||||
await db.refresh(db_user)
|
||||
if db_user.remnawave_uuid:
|
||||
try:
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
|
||||
service = RemnaWaveService()
|
||||
async with service.get_api_client() as api:
|
||||
await api.reset_user_devices(db_user.remnawave_uuid)
|
||||
logger.info(f'🔧 Сброшены устройства при смене тарифа для user_id={db_user.id}')
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка сброса устройств при смене тарифа: {e}')
|
||||
|
||||
# Создаем транзакцию
|
||||
await create_transaction(
|
||||
db,
|
||||
@@ -2262,6 +2300,7 @@ async def confirm_tariff_switch(
|
||||
days_for_new_tariff, # Итоговый срок подписки
|
||||
was_trial_conversion=False,
|
||||
amount_kopeks=final_price,
|
||||
purchase_type='tariff_switch',
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка отправки уведомления админу: {e}')
|
||||
@@ -2395,7 +2434,7 @@ async def confirm_daily_tariff_switch(
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
# Обновляем пользователя в Remnawave
|
||||
# Обновляем пользователя в Remnawave (create_remnawave_user также сбрасывает устройства)
|
||||
try:
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.create_remnawave_user(
|
||||
@@ -2407,6 +2446,19 @@ async def confirm_daily_tariff_switch(
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка обновления Remnawave: {e}')
|
||||
|
||||
# Гарантированный сброс устройств при смене тарифа
|
||||
await db.refresh(db_user)
|
||||
if db_user.remnawave_uuid:
|
||||
try:
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
|
||||
service = RemnaWaveService()
|
||||
async with service.get_api_client() as api:
|
||||
await api.reset_user_devices(db_user.remnawave_uuid)
|
||||
logger.info(f'🔧 Сброшены устройства при смене на суточный тариф для user_id={db_user.id}')
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка сброса устройств при смене тарифа: {e}')
|
||||
|
||||
# Создаем транзакцию
|
||||
await create_transaction(
|
||||
db,
|
||||
@@ -2427,6 +2479,7 @@ async def confirm_daily_tariff_switch(
|
||||
1, # 1 день
|
||||
was_trial_conversion=False,
|
||||
amount_kopeks=daily_price,
|
||||
purchase_type='tariff_switch',
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка отправки уведомления админу: {e}')
|
||||
@@ -2972,6 +3025,19 @@ async def confirm_instant_switch(
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка обновления Remnawave при мгновенном переключении: {e}')
|
||||
|
||||
# Гарантированный сброс устройств при смене тарифа
|
||||
await db.refresh(db_user)
|
||||
if db_user.remnawave_uuid:
|
||||
try:
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
|
||||
service = RemnaWaveService()
|
||||
async with service.get_api_client() as api:
|
||||
await api.reset_user_devices(db_user.remnawave_uuid)
|
||||
logger.info(f'🔧 Сброшены устройства при мгновенном переключении тарифа для user_id={db_user.id}')
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка сброса устройств при переключении тарифа: {e}')
|
||||
|
||||
# Создаем транзакцию если была оплата
|
||||
if is_upgrade and upgrade_cost > 0:
|
||||
await create_transaction(
|
||||
@@ -2993,6 +3059,7 @@ async def confirm_instant_switch(
|
||||
remaining_days,
|
||||
was_trial_conversion=False,
|
||||
amount_kopeks=upgrade_cost,
|
||||
purchase_type='tariff_switch',
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка отправки уведомления админу: {e}')
|
||||
|
||||
@@ -21,6 +21,7 @@ from app.keyboards.inline import (
|
||||
from app.localization.texts import get_texts
|
||||
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.states import SubscriptionStates
|
||||
from app.utils.pricing_utils import (
|
||||
apply_percentage_discount,
|
||||
@@ -239,7 +240,7 @@ async def handle_reset_traffic(callback: types.CallbackQuery, db_user: User, db:
|
||||
|
||||
await callback.message.edit_text(
|
||||
f'🔄 <b>Сброс трафика</b>\n\n'
|
||||
f'Использовано: {texts.format_traffic(subscription.traffic_used_gb)}\n'
|
||||
f'Использовано: {texts.format_traffic(subscription.traffic_used_gb, is_limit=False)}\n'
|
||||
f'Лимит: {texts.format_traffic(subscription.traffic_limit_gb)}\n\n'
|
||||
f'Стоимость сброса: {texts.format_price(reset_price)}{price_info}{balance_info}\n\n'
|
||||
'После сброса счетчик использованного трафика станет равным 0.',
|
||||
@@ -483,7 +484,14 @@ async def add_traffic(callback: types.CallbackQuery, db_user: User, db: AsyncSes
|
||||
discount_per_month = discount_result['discount']
|
||||
charged_months = 1
|
||||
|
||||
if subscription:
|
||||
# На тарифах пакеты трафика покупаются на 1 месяц (30 дней),
|
||||
# цена в тарифе уже месячная — не умножаем на оставшиеся месяцы подписки.
|
||||
# Пропорциональный расчёт применяем только в классическом режиме.
|
||||
is_tariff_mode = settings.is_tariffs_mode() and subscription and subscription.tariff_id
|
||||
|
||||
if is_tariff_mode:
|
||||
price = discounted_per_month
|
||||
elif subscription:
|
||||
price, charged_months = calculate_prorated_price(
|
||||
discounted_per_month,
|
||||
subscription.end_date,
|
||||
@@ -495,6 +503,24 @@ async def add_traffic(callback: types.CallbackQuery, db_user: User, db: AsyncSes
|
||||
|
||||
if db_user.balance_kopeks < price:
|
||||
missing_kopeks = price - db_user.balance_kopeks
|
||||
|
||||
# Save cart for auto-purchase after balance top-up
|
||||
cart_data = {
|
||||
'cart_mode': 'add_traffic',
|
||||
'subscription_id': subscription.id,
|
||||
'traffic_gb': traffic_gb,
|
||||
'price_kopeks': price,
|
||||
'base_price_kopeks': discounted_per_month,
|
||||
'discount_percent': discount_result['percent'],
|
||||
'source': 'bot',
|
||||
'description': f'Докупка {traffic_gb} ГБ трафика',
|
||||
}
|
||||
try:
|
||||
await user_cart_service.save_user_cart(db_user.id, cart_data)
|
||||
logger.info(f'Cart saved for traffic purchase (bot) user {db_user.telegram_id}: +{traffic_gb} GB')
|
||||
except Exception as e:
|
||||
logger.error(f'Error saving cart for traffic purchase (bot): {e}')
|
||||
|
||||
message_text = texts.t(
|
||||
'ADDON_INSUFFICIENT_FUNDS_MESSAGE',
|
||||
(
|
||||
@@ -521,6 +547,9 @@ async def add_traffic(callback: types.CallbackQuery, db_user: User, db: AsyncSes
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
# Сохраняем старое значение трафика для уведомления
|
||||
old_traffic_limit = subscription.traffic_limit_gb
|
||||
|
||||
try:
|
||||
success = await subtract_user_balance(
|
||||
db,
|
||||
@@ -561,6 +590,17 @@ async def add_traffic(callback: types.CallbackQuery, db_user: User, db: AsyncSes
|
||||
await db.refresh(db_user)
|
||||
await db.refresh(subscription)
|
||||
|
||||
# Отправляем уведомление админам о докупке трафика
|
||||
try:
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
notification_service = AdminNotificationService(callback.bot)
|
||||
await notification_service.send_subscription_update_notification(
|
||||
db, db_user, subscription, 'traffic', old_traffic_limit, subscription.traffic_limit_gb, price
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка отправки уведомления о докупке трафика: {e}')
|
||||
|
||||
success_text = '✅ Трафик успешно добавлен!\n\n'
|
||||
if traffic_gb == 0:
|
||||
success_text += '🎉 Теперь у вас безлимитный трафик!'
|
||||
|
||||
@@ -80,6 +80,9 @@ async def handle_ticket_title_input(message: types.Message, state: FSMContext, d
|
||||
return
|
||||
|
||||
"""Обработать ввод заголовка тикета"""
|
||||
if not message.text:
|
||||
asyncio.create_task(_try_delete_message_later(message.bot, message.chat.id, message.message_id, 2.0))
|
||||
return
|
||||
title = message.text.strip()
|
||||
|
||||
data_prompt = await state.get_data()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import logging
|
||||
|
||||
from aiogram import types
|
||||
from aiogram import Bot, types
|
||||
from aiohttp import web
|
||||
|
||||
from app.config import settings
|
||||
@@ -13,6 +13,15 @@ from app.external.tribute import TributeService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Глобальная ссылка на бота для отправки уведомлений
|
||||
_bot_instance: Bot | None = None
|
||||
|
||||
|
||||
def set_webhook_bot(bot: Bot) -> None:
|
||||
"""Устанавливает экземпляр бота для отправки уведомлений об ошибках в webhook."""
|
||||
global _bot_instance
|
||||
_bot_instance = bot
|
||||
|
||||
|
||||
async def tribute_webhook(request):
|
||||
try:
|
||||
|
||||
+18
-24
@@ -326,6 +326,12 @@ def get_admin_users_keyboard(language: str = 'ru') -> InlineKeyboardMarkup:
|
||||
text=_t(texts, 'ADMIN_USERS_BULK_BAN', '🛑 Массовый бан'), callback_data='admin_bulk_ban_start'
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, 'ADMIN_USERS_BLOCKED_CHECK', '🔒 Заблокировавшие бота'),
|
||||
callback_data='admin_blocked_users',
|
||||
)
|
||||
],
|
||||
[InlineKeyboardButton(text=texts.BACK, callback_data='admin_submenu_users')],
|
||||
]
|
||||
)
|
||||
@@ -342,36 +348,18 @@ def get_admin_users_filters_keyboard(language: str = 'ru') -> InlineKeyboardMark
|
||||
callback_data='admin_users_balance_filter',
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, 'ADMIN_USERS_FILTER_TRAFFIC', '📶 По трафику'),
|
||||
callback_data='admin_users_traffic_filter',
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, 'ADMIN_USERS_FILTER_ACTIVITY', '🕒 По активности'),
|
||||
callback_data='admin_users_activity_filter',
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, 'ADMIN_USERS_FILTER_SPENDING', '💳 По сумме трат'),
|
||||
callback_data='admin_users_spending_filter',
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, 'ADMIN_USERS_FILTER_PURCHASES', '🛒 По количеству покупок'),
|
||||
callback_data='admin_users_purchases_filter',
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, 'ADMIN_USERS_FILTER_RENEW_READY', '♻️ Готовы к продлению'),
|
||||
callback_data='admin_users_ready_to_renew_filter',
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, 'ADMIN_USERS_FILTER_POTENTIAL_CUSTOMERS', '💰 Потенциальные клиенты'),
|
||||
callback_data='admin_users_potential_customers_filter',
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, 'ADMIN_USERS_FILTER_CAMPAIGN', '📢 По кампании'),
|
||||
@@ -649,6 +637,12 @@ def get_referral_contest_manage_keyboard(
|
||||
callback_data=f'admin_contest_edit_times_{contest_id}',
|
||||
),
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text='👻 Виртуальные',
|
||||
callback_data=f'admin_contest_vp_{contest_id}',
|
||||
),
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text='🔄 Синхронизация',
|
||||
|
||||
+64
-32
@@ -247,6 +247,8 @@ _LANGUAGE_DISPLAY_NAMES = {
|
||||
'zh-hant': '🇹🇼 中文 (繁體)',
|
||||
'vi': '🇻🇳 Tiếng Việt',
|
||||
'vi-vn': '🇻🇳 Tiếng Việt',
|
||||
'fa': '🇮🇷 فارسی',
|
||||
'fa-ir': '🇮🇷 فارسی',
|
||||
}
|
||||
|
||||
|
||||
@@ -1789,6 +1791,8 @@ def get_add_traffic_keyboard(
|
||||
from app.utils.pricing_utils import get_remaining_months
|
||||
|
||||
texts = get_texts(language)
|
||||
language_code = (language or DEFAULT_LANGUAGE).split('-')[0].lower()
|
||||
use_russian_fallback = language_code in {'ru', 'fa'}
|
||||
|
||||
months_multiplier = 1
|
||||
period_text = ''
|
||||
@@ -1826,17 +1830,20 @@ def get_add_traffic_keyboard(
|
||||
total_discount = discount_per_month * months_multiplier
|
||||
|
||||
if gb == 0:
|
||||
if language == 'ru':
|
||||
if use_russian_fallback:
|
||||
text = f'♾️ Безлимитный трафик - {total_price // 100} ₽{period_text}'
|
||||
else:
|
||||
text = f'♾️ Unlimited traffic - {total_price // 100} ₽{period_text}'
|
||||
elif language == 'ru':
|
||||
elif use_russian_fallback:
|
||||
text = f'📊 +{gb} ГБ трафика - {total_price // 100} ₽{period_text}'
|
||||
else:
|
||||
text = f'📊 +{gb} GB traffic - {total_price // 100} ₽{period_text}'
|
||||
|
||||
if discount_percent > 0 and total_discount > 0:
|
||||
text += f' (скидка {discount_percent}%: -{total_discount // 100}₽)'
|
||||
if use_russian_fallback:
|
||||
text += f' (скидка {discount_percent}%: -{total_discount // 100}₽)'
|
||||
else:
|
||||
text += f' (discount {discount_percent}%: -{total_discount // 100}₽)'
|
||||
|
||||
buttons.append([InlineKeyboardButton(text=text, callback_data=f'add_traffic_{gb}')])
|
||||
|
||||
@@ -1860,16 +1867,9 @@ def get_add_traffic_keyboard_from_tariff(
|
||||
subscription_end_date: Дата окончания подписки для расчета цены
|
||||
discount_percent: Процент скидки
|
||||
"""
|
||||
from app.utils.pricing_utils import get_remaining_months
|
||||
|
||||
texts = get_texts(language)
|
||||
|
||||
months_multiplier = 1
|
||||
period_text = ''
|
||||
if subscription_end_date:
|
||||
months_multiplier = get_remaining_months(subscription_end_date)
|
||||
if months_multiplier > 1:
|
||||
period_text = f' (за {months_multiplier} мес)'
|
||||
language_code = (language or DEFAULT_LANGUAGE).split('-')[0].lower()
|
||||
use_russian_fallback = language_code in {'ru', 'fa'}
|
||||
|
||||
if not packages:
|
||||
return InlineKeyboardMarkup(
|
||||
@@ -1889,21 +1889,26 @@ def get_add_traffic_keyboard_from_tariff(
|
||||
# Сортируем пакеты по размеру
|
||||
sorted_packages = sorted(packages.items(), key=lambda x: x[0])
|
||||
|
||||
# Пакеты трафика на тарифах покупаются на 1 месяц (30 дней),
|
||||
# цена в тарифе уже месячная — не умножаем на оставшиеся месяцы подписки
|
||||
for gb, price_per_month in sorted_packages:
|
||||
discounted_per_month, discount_per_month = apply_percentage_discount(
|
||||
discounted_price, discount_value = apply_percentage_discount(
|
||||
price_per_month,
|
||||
discount_percent,
|
||||
)
|
||||
total_price = discounted_per_month * months_multiplier
|
||||
total_discount = discount_per_month * months_multiplier
|
||||
|
||||
if language == 'ru':
|
||||
text = f'📊 +{gb} ГБ трафика - {total_price // 100} ₽{period_text}'
|
||||
period_text = ' /мес' if use_russian_fallback else ' /mo'
|
||||
|
||||
if use_russian_fallback:
|
||||
text = f'📊 +{gb} ГБ трафика - {discounted_price // 100} ₽{period_text}'
|
||||
else:
|
||||
text = f'📊 +{gb} GB traffic - {total_price // 100} ₽{period_text}'
|
||||
text = f'📊 +{gb} GB traffic - {discounted_price // 100} ₽{period_text}'
|
||||
|
||||
if discount_percent > 0 and total_discount > 0:
|
||||
text += f' (скидка {discount_percent}%: -{total_discount // 100}₽)'
|
||||
if discount_percent > 0 and discount_value > 0:
|
||||
if use_russian_fallback:
|
||||
text += f' (скидка {discount_percent}%: -{discount_value // 100}₽)'
|
||||
else:
|
||||
text += f' (discount {discount_percent}%: -{discount_value // 100}₽)'
|
||||
|
||||
buttons.append([InlineKeyboardButton(text=text, callback_data=f'add_traffic_{gb}')])
|
||||
|
||||
@@ -1924,12 +1929,28 @@ def get_change_devices_keyboard(
|
||||
|
||||
texts = get_texts(language)
|
||||
|
||||
months_multiplier = 1
|
||||
period_text = ''
|
||||
if subscription_end_date:
|
||||
months_multiplier = get_remaining_months(subscription_end_date)
|
||||
if months_multiplier > 1:
|
||||
period_text = f' (за {months_multiplier} мес)'
|
||||
# Проверяем является ли тариф суточным
|
||||
is_daily_tariff = tariff and getattr(tariff, 'is_daily', False)
|
||||
|
||||
# Для суточных тарифов считаем по дням, для обычных - по месяцам
|
||||
if is_daily_tariff and subscription_end_date:
|
||||
# Суточный тариф: цена за оставшиеся дни (обычно 1 день)
|
||||
from datetime import datetime
|
||||
|
||||
now = datetime.utcnow()
|
||||
days_left = max(1, (subscription_end_date - now).days)
|
||||
# Множитель = days_left / 30 (как в кабинете)
|
||||
price_multiplier = days_left / 30
|
||||
period_text = f' (за {days_left} дн.)' if days_left > 1 else ' (за 1 день)'
|
||||
else:
|
||||
# Обычный тариф: цена за оставшиеся месяцы
|
||||
months_multiplier = 1
|
||||
period_text = ''
|
||||
if subscription_end_date:
|
||||
months_multiplier = get_remaining_months(subscription_end_date)
|
||||
if months_multiplier > 1:
|
||||
period_text = f' (за {months_multiplier} мес)'
|
||||
price_multiplier = months_multiplier
|
||||
|
||||
# Используем цену из тарифа если есть, иначе глобальную настройку
|
||||
tariff_device_price = getattr(tariff, 'device_price_kopeks', None) if tariff else None
|
||||
@@ -1943,9 +1964,18 @@ def get_change_devices_keyboard(
|
||||
|
||||
buttons = []
|
||||
|
||||
max_devices = settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else 20
|
||||
# Используем max_device_limit из тарифа если есть, иначе глобальную настройку
|
||||
tariff_max_devices = getattr(tariff, 'max_device_limit', None) if tariff else None
|
||||
if tariff_max_devices and tariff_max_devices > 0:
|
||||
max_devices = tariff_max_devices
|
||||
else:
|
||||
max_devices = settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else 20
|
||||
|
||||
start_range = max(1, min(current_devices - 3, max_devices - 6))
|
||||
# Минимальное количество устройств: device_limit тарифа или 1
|
||||
tariff_min_devices = (getattr(tariff, 'device_limit', 1) or 1) if tariff else 1
|
||||
min_devices = max(1, tariff_min_devices)
|
||||
|
||||
start_range = max(min_devices, min(current_devices - 3, max_devices - 6))
|
||||
end_range = min(max_devices + 1, max(current_devices + 4, 7))
|
||||
|
||||
for devices_count in range(start_range, end_range):
|
||||
@@ -1967,10 +1997,12 @@ def get_change_devices_keyboard(
|
||||
price_per_month,
|
||||
discount_percent,
|
||||
)
|
||||
total_price = discounted_per_month * months_multiplier
|
||||
total_price = int(discounted_per_month * price_multiplier)
|
||||
total_price = max(100, total_price) # Минимум 1 рубль
|
||||
price_text = f' (+{total_price // 100}₽{period_text})'
|
||||
if discount_percent > 0 and discount_per_month * months_multiplier > 0:
|
||||
price_text += f' (скидка {discount_percent}%: -{(discount_per_month * months_multiplier) // 100}₽)'
|
||||
total_discount = int(discount_per_month * price_multiplier)
|
||||
if discount_percent > 0 and total_discount > 0:
|
||||
price_text += f' (скидка {discount_percent}%: -{total_discount // 100}₽)'
|
||||
action_text = ''
|
||||
else:
|
||||
price_text = ' (бесплатно)'
|
||||
@@ -2042,7 +2074,7 @@ def get_reset_traffic_confirm_keyboard(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('TOPUP_BALANCE_BUTTON', '💳 Пополнить баланс'),
|
||||
callback_data=f'topup_amount_{missing_kopeks}',
|
||||
callback_data='balance_topup',
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
@@ -231,7 +231,7 @@ def ensure_locale_templates() -> None:
|
||||
_copy_locale(template, destination / template.name)
|
||||
return
|
||||
|
||||
for locale_code in ('ru', 'en'):
|
||||
for locale_code in ('ru', 'en', 'fa'):
|
||||
source_path = _DEFAULT_LOCALES_DIR / f'{locale_code}.json'
|
||||
target_path = destination / f'{locale_code}.json'
|
||||
|
||||
|
||||
@@ -925,6 +925,7 @@
|
||||
"CHANGE_DEVICES_CONFIRM": "\n📱 <b>Confirm change</b>\n\nCurrent amount: {current_devices} devices\nNew amount: {new_devices} devices\n\nAction: {action}\n💰 {cost}\n\nApply this change?\n",
|
||||
"CHANGE_DEVICES_INFO": "\n📱 <b>Adjust device limit</b>\n\nCurrent limit: {current_devices} devices\n\nChoose the new number of devices:\n\n💡 <b>Important:</b>\n• Increasing — extra charge proportional to the remaining time\n• Decreasing — funds are not refunded\n",
|
||||
"CHANGE_DEVICES_PROMPT": "📱 <b>Adjust device limit</b>\n\nCurrent limit: {current_devices} devices\nChoose the new number of devices:\n\n💡 <b>Important:</b>\n• Increasing — extra cost prorated by remaining time\n• Decreasing — payments are not refunded",
|
||||
"CHANGE_DEVICES_PROMPT_TARIFF": "📱 <b>Adjust device limit</b>\n\nCurrent limit: {current_devices} devices\nPrice per extra device: {price}/mo\nChoose the new number of devices:\n\n💡 <b>Important:</b>\n• Increasing — extra cost prorated by remaining time\n• Decreasing — payments are not refunded",
|
||||
"CHANGE_DEVICES_SUCCESS_DECREASE": "\n✅ Device limit decreased!\n\n📱 Was: {old_count} → Now: {new_count}\nℹ️ Payments are not refunded\n",
|
||||
"CHANGE_DEVICES_SUCCESS_INCREASE": "\n✅ Device limit increased!\n\n📱 Was: {old_count} → Now: {new_count}\n💰 Charged: {amount}\n",
|
||||
"CHANGE_DEVICES_TITLE": "📱 Change device limit",
|
||||
@@ -977,7 +978,7 @@
|
||||
"DEVICE_CHANGE_CONFIRMATION": "📱 <b>Confirm change</b>\n\nCurrent amount: {current} devices\nNew amount: {new} devices\n\nAction: {action}\n💰 {cost}\n\nApply this change?",
|
||||
"DEVICE_CHANGE_DECREASE_SUCCESS": "✅ Device limit decreased!\n\n",
|
||||
"DEVICE_CHANGE_DISCOUNT_INFO": " (discount {percent}%: -{amount})",
|
||||
"DEVICE_CHANGE_EXTRA_COST": "Extra payment: {amount} (for {months} mo)",
|
||||
"DEVICE_CHANGE_EXTRA_COST": "Extra payment: {amount} (for {period})",
|
||||
"DEVICE_CHANGE_FREE": "Free",
|
||||
"DEVICE_CHANGE_INCREASE_SUCCESS": "✅ Device limit increased!\n\n",
|
||||
"DEVICE_CHANGE_NO_REFUND": "Payments are not refunded",
|
||||
@@ -1547,6 +1548,9 @@
|
||||
"TRIAL_PROVISIONING_FAILED": "We couldn't finish setting up the trial. Any charge has been refunded. Please try again later.",
|
||||
"TRIAL_ROLLBACK_FAILED": "We couldn't cancel the trial activation after a payment error. Please contact support and try again later.",
|
||||
"TRIAL_REFUND_FAILED": "We couldn't refund the trial activation charge. Please contact support immediately.",
|
||||
"TRIAL_PAYMENT_DESCRIPTION": "Trial subscription payment",
|
||||
"TRIAL_REFUND_DESCRIPTION": "Refund for failed trial activation",
|
||||
"TRIAL_ACTIVATION_ERROR": "❌ An error occurred during trial activation. Funds have been returned to your balance.",
|
||||
"TRIAL_PAYMENT_CHARGED_NOTE": "💳 {amount} has been deducted from your balance.",
|
||||
"TRIAL_CHANNEL_UNSUBSCRIBED": "\n🚫 <b>Access paused</b>\n\nWe couldn't find your subscription to our channel, so the trial plan has been disabled.\n\nJoin the channel and tap “{check_button}” to restore access.",
|
||||
"TRIAL_ENDING_SOON": "\n🎁 <b>The trial subscription is ending soon!</b>\n\nYour trial expires in a few hours.\n\n💎 <b>Don't want to lose VPN access?</b>\nSwitch to the full subscription!\n\n🔥 <b>Special offer:</b>\n• 30 days for {price}\n• Unlimited traffic\n• All servers available\n• Speeds up to 1 Gbit/s\n\n⚡️ Activate before the trial ends!\n",
|
||||
@@ -1652,6 +1656,7 @@
|
||||
"POLL_REWARD_GRANTED": "Reward {amount} has been credited to your balance.",
|
||||
"ADMIN_USERS_BULK_BAN": "🛑 Bulk Ban",
|
||||
"ADMIN_USERS_BLACKLIST": "🔐 Blacklist",
|
||||
"ADMIN_USERS_BLOCKED_CHECK": "🔒 Blocked bot users",
|
||||
"CONTEST_START_ANNOUNCEMENT": "Game starts",
|
||||
"CONTEST_PRIZE": "Prize",
|
||||
"DAYS": "days subscription",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -807,6 +807,10 @@
|
||||
"ADMIN_USERS_FILTER_RENEW_READY_TITLE": "♻️ Пользователи готовы к продлению",
|
||||
"ADMIN_USERS_FILTER_RENEW_READY_DESC": "Подписка истекла, а на балансе осталось {amount} или больше.",
|
||||
"ADMIN_USERS_FILTER_RENEW_READY_EMPTY": "Сейчас нет пользователей, которые подходят под этот фильтр.",
|
||||
"ADMIN_USERS_FILTER_POTENTIAL_CUSTOMERS": "💰 Потенциальные клиенты",
|
||||
"ADMIN_USERS_FILTER_POTENTIAL_CUSTOMERS_TITLE": "💰 Потенциальные клиенты",
|
||||
"ADMIN_USERS_FILTER_POTENTIAL_CUSTOMERS_DESC": "Нет подписки, но баланс достаточен для покупки.",
|
||||
"ADMIN_USERS_FILTER_POTENTIAL_CUSTOMERS_EMPTY": "Нет пользователей без подписки с достаточным балансом.",
|
||||
"ADMIN_USERS_FILTER_CAMPAIGN": "📢 По кампании",
|
||||
"ADMIN_USERS_FILTER_PURCHASES": "🛒 По количеству покупок",
|
||||
"ADMIN_USERS_FILTER_SPENDING": "💳 По сумме трат",
|
||||
@@ -941,6 +945,7 @@
|
||||
"CHANGE_DEVICES_CONFIRM": "\n 📱 <b>Подтверждение изменения</b>\n\n Текущее количество: {current_devices} устройств\n Новое количество: {new_devices} устройств\n\n Действие: {action}\n 💰 {cost}\n\n Подтвердить изменение?\n ",
|
||||
"CHANGE_DEVICES_INFO": "\n 📱 <b>Изменение количества устройств</b>\n\n Текущий лимит: {current_devices} устройств\n\n Выберите новое количество устройств:\n\n 💡 <b>Важно:</b>\n • При увеличении - доплата пропорционально оставшемуся времени\n • При уменьшении - возврат средств не производится\n ",
|
||||
"CHANGE_DEVICES_PROMPT": "📱 <b>Изменение количества устройств</b>\n\nТекущий лимит: {current_devices} устройств\nВыберите новое количество устройств:\n\n💡 <b>Важно:</b>\n• При увеличении - доплата пропорционально оставшемуся времени\n• При уменьшении - возврат средств не производится",
|
||||
"CHANGE_DEVICES_PROMPT_TARIFF": "📱 <b>Изменение количества устройств</b>\n\nТекущий лимит: {current_devices} устройств\nЦена за доп. устройство: {price}/мес\nВыберите новое количество устройств:\n\n💡 <b>Важно:</b>\n• При увеличении - доплата пропорционально оставшемуся времени\n• При уменьшении - возврат средств не производится",
|
||||
"CHANGE_DEVICES_SUCCESS_DECREASE": "\n ✅ Количество устройств уменьшено!\n\n 📱 Было: {old_count} → Стало: {new_count}\n ℹ️ Возврат средств не производится\n ",
|
||||
"CHANGE_DEVICES_SUCCESS_INCREASE": "\n ✅ Количество устройств увеличено!\n\n 📱 Было: {old_count} → Стало: {new_count}\n 💰 Списано: {amount}\n ",
|
||||
"CHANGE_DEVICES_TITLE": "📱 Изменение количества устройств",
|
||||
@@ -994,7 +999,7 @@
|
||||
"DEVICE_CHANGE_CONFIRMATION": "📱 <b>Подтверждение изменения</b>\n\nТекущее количество: {current} устройств\nНовое количество: {new} устройств\n\nДействие: {action}\n💰 {cost}\n\nПодтвердить изменение?",
|
||||
"DEVICE_CHANGE_DECREASE_SUCCESS": "✅ Количество устройств уменьшено!\n\n",
|
||||
"DEVICE_CHANGE_DISCOUNT_INFO": " (скидка {percent}%: -{amount})",
|
||||
"DEVICE_CHANGE_EXTRA_COST": "Доплата: {amount} (за {months} мес)",
|
||||
"DEVICE_CHANGE_EXTRA_COST": "Доплата: {amount} (за {period})",
|
||||
"DEVICE_CHANGE_FREE": "Бесплатно",
|
||||
"DEVICE_CHANGE_INCREASE_SUCCESS": "✅ Количество устройств увеличено!\n\n",
|
||||
"DEVICE_CHANGE_NO_REFUND": "Возврат средств не производится",
|
||||
@@ -1534,7 +1539,7 @@
|
||||
"TICKET_TITLE_TOO_LONG": "Заголовок слишком длинный. Максимум 255 символов. Попробуйте еще раз:",
|
||||
"TICKET_TITLE_TOO_SHORT": "Заголовок должен содержать минимум 5 символов. Попробуйте еще раз:",
|
||||
"TICKET_UPDATE_ERROR": "❌ Ошибка при обновлении тикета.",
|
||||
"TOPUP_BALANCE_BUTTON": "💳 Попол\\у043Dить баланс",
|
||||
"TOPUP_BALANCE_BUTTON": "💳 Пополнить баланс",
|
||||
"TOP_UP_AMOUNT": "💳 Введите сумму для пополнения (в рублях):",
|
||||
"TOP_UP_METHODS": "\n💳 <b>Выберите способ оплаты</b>\n\nСумма: {amount}\n",
|
||||
"TOP_UP_STARS": "⭐ Telegram Stars",
|
||||
@@ -1564,6 +1569,9 @@
|
||||
"TRIAL_PROVISIONING_FAILED": "Не удалось завершить активацию триала. Средства возвращены на баланс. Попробуйте позже.",
|
||||
"TRIAL_ROLLBACK_FAILED": "Не удалось отменить активацию триала после ошибки списания. Свяжитесь с поддержкой и попробуйте позже.",
|
||||
"TRIAL_REFUND_FAILED": "Не удалось вернуть оплату за активацию триала. Немедленно свяжитесь с поддержкой.",
|
||||
"TRIAL_PAYMENT_DESCRIPTION": "Оплата пробной подписки",
|
||||
"TRIAL_REFUND_DESCRIPTION": "Возврат за неудачную активацию триала",
|
||||
"TRIAL_ACTIVATION_ERROR": "❌ Произошла ошибка при активации триала. Средства возвращены на баланс.",
|
||||
"TRIAL_PAYMENT_CHARGED_NOTE": "💳 С вашего баланса списано {amount}.",
|
||||
"TRIAL_CHANNEL_UNSUBSCRIBED": "\n🚫 <b>Доступ приостановлен</b>\n\nМы не нашли вашу подписку на наш канал, поэтому тестовая подписка отключена.\n\nПодпишитесь на канал и нажмите «{check_button}», чтобы вернуть доступ.",
|
||||
"TRIAL_ENDING_SOON": "\n🎁 <b>Тестовая подписка скоро закончится!</b>\n\nВаша тестовая подписка истекает через несколько часов.\n\n💎 <b>Не хотите остаться без VPN?</b>\nПереходите на полную подписку!\n\n🔥 <b>Специальное предложение:</b>\n• 30 дней всего за {price}\n• Безлимитный трафик \n• Все серверы доступны\n• Скорость до 1ГБит/сек\n\n⚡️ Успейте оформить до окончания тестового периода!\n",
|
||||
@@ -1669,6 +1677,7 @@
|
||||
"POLL_REWARD_GRANTED": "Награда {amount} зачислена на ваш баланс.",
|
||||
"ADMIN_USERS_BULK_BAN": "🛑 Массовый бан",
|
||||
"ADMIN_USERS_BLACKLIST": "🔐 Черный список",
|
||||
"ADMIN_USERS_BLOCKED_CHECK": "🔒 Заблокировавшие бота",
|
||||
"CONTEST_START_ANNOUNCEMENT": "Стартует игра",
|
||||
"CONTEST_PRIZE": "Приз",
|
||||
"DAYS": "дн. подписки",
|
||||
|
||||
@@ -866,6 +866,7 @@
|
||||
"CHANGE_DEVICES_CONFIRM": "\n 📱 <b>Підтвердження зміни</b>\n\n Поточна кількість: {current_devices} пристроїв\n Нова кількість: {new_devices} пристроїв\n\n Дія: {action}\n 💰 {cost}\n\n Підтвердити зміну?\n ",
|
||||
"CHANGE_DEVICES_INFO": "\n 📱 <b>Зміна кількості пристроїв</b>\n\n Поточний ліміт: {current_devices} пристроїв\n\n Оберіть нову кількість пристроїв:\n\n 💡 <b>Важливо:</b>\n • При збільшенні - доплата пропорційно часу, що залишився\n • При зменшенні - повернення коштів не здійснюється\n ",
|
||||
"CHANGE_DEVICES_PROMPT": "📱 <b>Зміна кількості пристроїв</b>\n\nПоточний ліміт: {current_devices} пристроїв\nОберіть нову кількість пристроїв:\n\n💡 <b>Важливо:</b>\n• При збільшенні - доплата пропорційно часу, що залишився\n• При зменшенні - повернення коштів не здійснюється",
|
||||
"CHANGE_DEVICES_PROMPT_TARIFF": "📱 <b>Зміна кількості пристроїв</b>\n\nПоточний ліміт: {current_devices} пристроїв\nЦіна за дод. пристрій: {price}/міс\nОберіть нову кількість пристроїв:\n\n💡 <b>Важливо:</b>\n• При збільшенні - доплата пропорційно часу, що залишився\n• При зменшенні - повернення коштів не здійснюється",
|
||||
"CHANGE_DEVICES_SUCCESS_DECREASE": "\n ✅ Кількість пристроїв зменшено!\n\n 📱 Було: {old_count} → Стало: {new_count}\n ℹ️ Повернення коштів не здійснюється\n ",
|
||||
"CHANGE_DEVICES_SUCCESS_INCREASE": "\n ✅ Кількість пристроїв збільшено!\n\n 📱 Було: {old_count} → Стало: {new_count}\n 💰 Списано: {amount}\n ",
|
||||
"CHANGE_DEVICES_TITLE": "📱 Зміна кількості пристроїв",
|
||||
@@ -917,7 +918,7 @@
|
||||
"DEVICE_CHANGE_CONFIRMATION": "📱 <b>Підтвердження зміни</b>\n\nПоточна кількість: {current} пристроїв\nНова кількість: {new} пристроїв\n\nДія: {action}\n💰 {cost}\n\nПідтвердити зміну?",
|
||||
"DEVICE_CHANGE_DECREASE_SUCCESS": "✅ Кількість пристроїв зменшено!\n\n",
|
||||
"DEVICE_CHANGE_DISCOUNT_INFO": " (знижка {percent}%: -{amount})",
|
||||
"DEVICE_CHANGE_EXTRA_COST": "Доплата: {amount} (за {months} міс)",
|
||||
"DEVICE_CHANGE_EXTRA_COST": "Доплата: {amount} (за {period})",
|
||||
"DEVICE_CHANGE_FREE": "Безкоштовно",
|
||||
"DEVICE_CHANGE_INCREASE_SUCCESS": "✅ Кількість пристроїв збільшено!\n\n",
|
||||
"DEVICE_CHANGE_NO_REFUND": "Повернення коштів не здійснюється",
|
||||
@@ -1478,6 +1479,9 @@
|
||||
"TRIAL_PROVISIONING_FAILED": "Не вдалося завершити активацію тріалу. Кошти повернуто на баланс. Спробуйте пізніше.",
|
||||
"TRIAL_ROLLBACK_FAILED": "Не вдалося скасувати активацію тріалу після помилки списання. Зв'яжіться з підтримкою і спробуйте пізніше.",
|
||||
"TRIAL_REFUND_FAILED": "Не вдалося повернути оплату за активацію тріалу. Негайно зв'яжіться з підтримкою.",
|
||||
"TRIAL_PAYMENT_DESCRIPTION": "Оплата пробної підписки",
|
||||
"TRIAL_REFUND_DESCRIPTION": "Повернення за невдалу активацію тріалу",
|
||||
"TRIAL_ACTIVATION_ERROR": "❌ Виникла помилка при активації тріалу. Кошти повернуто на баланс.",
|
||||
"TRIAL_PAYMENT_CHARGED_NOTE": "💳 З вашого балансу списано {amount}.",
|
||||
"TRIAL_CHANNEL_UNSUBSCRIBED": "\n🚫 <b>Доступ призупинено</b>\n\nМи не знайшли вашу підписку на наш канал, тому тестову підписку вимкнено.\n\nПідпишіться на канал і натисніть «{check_button}», щоб повернути доступ.",
|
||||
"TRIAL_ENDING_SOON": "\n🎁 <b>Тестова підписка скоро закінчиться!</b>\n\nВаша тестова підписка закінчується через декілька годин.\n\n💎 <b>Не хочете залишитися без VPN?</b>\nПереходьте на повну підписку!\n\n🔥 <b>Спеціальна пропозиція:</b>\n• 30 днів усього за {price}\n• Безлімітний трафік \n• Всі сервери доступні\n• Швидкість до 1ГБіт/сек\n\n⚡️ Встигніть оформити до закінчення тестового періоду!\n",
|
||||
|
||||
@@ -864,6 +864,7 @@
|
||||
"CHANGE_DEVICES_CONFIRM":"\n 📱<b>确认更改</b>\n\n 当前数量:{current_devices}台设备\n 新数量:{new_devices}台设备\n\n 操作:{action}\n 💰{cost}\n\n 确认更改吗?\n ",
|
||||
"CHANGE_DEVICES_INFO":"\n 📱<b>更改设备数量</b>\n\n 当前限制:{current_devices}台设备\n\n 请选择新的设备数量:\n\n 💡<b>重要提示:</b>\n •增加-按剩余时间比例补差价\n •减少-不退款\n ",
|
||||
"CHANGE_DEVICES_PROMPT":"📱<b>更改设备数量</b>\n\n当前限制:{current_devices}台设备\n请选择新的设备数量:\n\n💡<b>重要提示:</b>\n•增加-按剩余时间比例补差价\n•减少-不退款",
|
||||
"CHANGE_DEVICES_PROMPT_TARIFF":"📱<b>更改设备数量</b>\n\n当前限制:{current_devices}台设备\n每台额外设备价格:{price}/月\n请选择新的设备数量:\n\n💡<b>重要提示:</b>\n•增加-按剩余时间比例补差价\n•减少-不退款",
|
||||
"CHANGE_DEVICES_SUCCESS_DECREASE":"\n ✅设备数量已减少!\n\n 📱之前:{old_count}→现在:{new_count}\n ℹ️不退款\n ",
|
||||
"CHANGE_DEVICES_SUCCESS_INCREASE":"\n ✅设备数量已增加!\n\n 📱之前:{old_count}→现在:{new_count}\n 💰已扣除:{amount}\n ",
|
||||
"CHANGE_DEVICES_TITLE":"📱更改设备数量",
|
||||
@@ -915,7 +916,7 @@
|
||||
"DEVICE_CHANGE_CONFIRMATION":"📱<b>确认更改</b>\n\n当前数量:{current}台设备\n新数量:{new}台设备\n\n操作:{action}\n💰{cost}\n\n确认更改吗?",
|
||||
"DEVICE_CHANGE_DECREASE_SUCCESS":"✅设备数量已减少!\n\n",
|
||||
"DEVICE_CHANGE_DISCOUNT_INFO":"(折扣{percent}%:-{amount})",
|
||||
"DEVICE_CHANGE_EXTRA_COST":"补差价:{amount}(共{months}个月)",
|
||||
"DEVICE_CHANGE_EXTRA_COST":"补差价:{amount}({period})",
|
||||
"DEVICE_CHANGE_FREE":"免费",
|
||||
"DEVICE_CHANGE_INCREASE_SUCCESS":"✅设备数量已增加!\n\n",
|
||||
"DEVICE_CHANGE_NO_REFUND":"不退款",
|
||||
@@ -1476,6 +1477,9 @@
|
||||
"TRIAL_PROVISIONING_FAILED":"未能完成试用激活。资金已退回余额。请稍后再试。",
|
||||
"TRIAL_ROLLBACK_FAILED":"扣款失败后未能取消试用激活。请联系支持并稍后再试。",
|
||||
"TRIAL_REFUND_FAILED":"未能退还试用激活付款。请立即联系支持。",
|
||||
"TRIAL_PAYMENT_DESCRIPTION":"试用订阅付款",
|
||||
"TRIAL_REFUND_DESCRIPTION":"试用激活失败退款",
|
||||
"TRIAL_ACTIVATION_ERROR":"❌ 试用激活出错。资金已退回余额。",
|
||||
"TRIAL_PAYMENT_CHARGED_NOTE":"💳已从您的余额中扣除{amount}。",
|
||||
"TRIAL_CHANNEL_UNSUBSCRIBED":"\n🚫<b>访问已暂停</b>\n\n我们未找到您对我们频道的订阅,因此试用订阅已禁用。\n\n请订阅频道并点击“{check_button}”以恢复访问。",
|
||||
"TRIAL_ENDING_SOON":"\n🎁<b>试用订阅即将结束!</b>\n\n您的试用订阅将在几小时后过期。\n\n💎<b>不想没有VPN吗?</b>\n升级到完整订阅!\n\n🔥<b>特别优惠:</b>\n•30天仅需{price}\n•无限流量 \n•所有服务器可用\n•速度高达1Gbit/s\n\n⚡️赶在试用期结束前订购吧!\n",
|
||||
@@ -1806,6 +1810,9 @@
|
||||
"TRIAL_PROVISIONING_FAILED":"未能完成试用激活。资金已退回余额。请稍后再试。",
|
||||
"TRIAL_ROLLBACK_FAILED":"扣款失败后未能取消试用激活。请联系支持并稍后再试。",
|
||||
"TRIAL_REFUND_FAILED":"未能退还试用激活付款。请立即联系支持。",
|
||||
"TRIAL_PAYMENT_DESCRIPTION":"试用订阅付款",
|
||||
"TRIAL_REFUND_DESCRIPTION":"试用激活失败退款",
|
||||
"TRIAL_ACTIVATION_ERROR":"❌ 试用激活出错。资金已退回余额。",
|
||||
"TRIAL_PAYMENT_CHARGED_NOTE":"💳已从您的余额中扣除{amount}。",
|
||||
"TRIAL_CHANNEL_UNSUBSCRIBED":"\n🚫<b>访问已暂停</b>\n\n我们未找到您对我们频道的订阅,因此试用订阅已禁用。\n\n请订阅频道并点击“{check_button}”以恢复访问。",
|
||||
"TRIAL_ENDING_SOON":"\n🎁<b>试用订阅即将结束!</b>\n\n您的试用订阅将在几小时后过期。\n\n💎<b>不想没有VPN吗?</b>\n升级到完整订阅!\n\n🔥<b>特别优惠:</b>\n•30天仅需{price}\n•无限流量 \n•所有服务器可用\n•速度高达1Gbit/s\n\n⚡️赶在试用期结束前订购吧!\n",
|
||||
|
||||
@@ -35,6 +35,18 @@ _DYNAMIC_LANGUAGE_CONFIGS = {
|
||||
'Старайтесь использовать тикеты — так мы быстрее поможем и ничего не потеряется.\n'
|
||||
),
|
||||
},
|
||||
'fa': {
|
||||
'traffic_pattern': '📊 {size} گیگابایت - {price}',
|
||||
'unlimited_pattern': '📊 نامحدود - {price}',
|
||||
'support_info': (
|
||||
'\n🛟 <b>پشتیبانی</b>\n\n'
|
||||
'برای هرگونه سؤال به پشتیبانی پیام دهید:\n\n'
|
||||
'👤 {support_username}\n\n'
|
||||
'• 🎫 ایجاد تیکت\n'
|
||||
'• 📋 تیکتهای من\n'
|
||||
'• 💬 تماس مستقیم\n'
|
||||
),
|
||||
},
|
||||
'en': {
|
||||
'traffic_pattern': '📊 {size} GB - {price}',
|
||||
'unlimited_pattern': '📊 Unlimited - {price}',
|
||||
@@ -190,9 +202,15 @@ class Texts:
|
||||
return settings.format_price(kopeks)
|
||||
|
||||
@staticmethod
|
||||
def format_traffic(gb: float) -> str:
|
||||
def format_traffic(gb: float, is_limit: bool = True) -> str:
|
||||
"""Format traffic value.
|
||||
|
||||
Args:
|
||||
gb: Traffic in gigabytes
|
||||
is_limit: If True, 0 means unlimited. If False, 0 means zero used.
|
||||
"""
|
||||
if gb == 0:
|
||||
return '∞ (безлимит)'
|
||||
return '∞ (безлимит)' if is_limit else '0 ГБ'
|
||||
if gb >= 1024:
|
||||
return f'{gb / 1024:.1f} ТБ'
|
||||
return f'{gb:.0f} ГБ'
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
"""Кастомный logging handler для отправки ERROR/CRITICAL в админский чат Telegram.
|
||||
|
||||
Перехватывает все log records уровня ERROR и CRITICAL и отправляет их
|
||||
в админский чат через существующий механизм send_error_to_admin_chat()
|
||||
из app.middlewares.global_error.
|
||||
|
||||
Дедупликация:
|
||||
- Записи, уже обработанные GlobalErrorMiddleware или @error_handler,
|
||||
помечаются атрибутом _admin_notified = True и пропускаются.
|
||||
- Хеши недавних сообщений хранятся в LRU-кеше для предотвращения
|
||||
дублирования одинаковых ошибок за короткий период.
|
||||
|
||||
Async bridge:
|
||||
- logging.Handler.emit() -- синхронный. Мы используем
|
||||
asyncio.get_running_loop().call_soon_threadsafe() для планирования
|
||||
asyncio.Task из любого потока (sync или async).
|
||||
|
||||
Deferred init:
|
||||
- Bot instance создаётся позже в main.py. Метод set_bot() позволяет
|
||||
передать его после создания. До этого записи молча пропускаются.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import logging
|
||||
import time
|
||||
from typing import Final
|
||||
|
||||
from aiogram import Bot
|
||||
|
||||
|
||||
# Константы
|
||||
RECENT_HASHES_MAX_SIZE: Final[int] = 256
|
||||
RECENT_HASH_TTL_SECONDS: Final[float] = 300.0 # 5 минут -- совпадает с cooldown в global_error
|
||||
|
||||
# Логгеры, от которых мы гарантированно не хотим получать уведомления,
|
||||
# даже если они вдруг выдадут ERROR (шум от транспортного уровня).
|
||||
IGNORED_LOGGER_PREFIXES: Final[tuple[str, ...]] = (
|
||||
'aiohttp.access',
|
||||
'aiohttp.client',
|
||||
'aiohttp.internal',
|
||||
'uvicorn.access',
|
||||
'uvicorn.error',
|
||||
'uvicorn.protocols',
|
||||
'websockets',
|
||||
'asyncio',
|
||||
)
|
||||
|
||||
|
||||
class TelegramErrorHandler(logging.Handler):
|
||||
"""Logging handler, отправляющий ERROR/CRITICAL записи в админский Telegram-чат.
|
||||
|
||||
Использует существующий механизм троттлинга и буферизации из
|
||||
``app.middlewares.global_error.send_error_to_admin_chat``.
|
||||
|
||||
Usage::
|
||||
|
||||
handler = TelegramErrorHandler()
|
||||
handler.setLevel(logging.ERROR)
|
||||
logging.getLogger().addHandler(handler)
|
||||
|
||||
# Позже, когда Bot создан:
|
||||
handler.set_bot(bot)
|
||||
"""
|
||||
|
||||
def __init__(self, level: int = logging.ERROR) -> None:
|
||||
super().__init__(level=level)
|
||||
self._bot: Bot | None = None
|
||||
# LRU-подобный кеш хешей недавних сообщений: hash -> timestamp
|
||||
self._recent_hashes: dict[str, float] = {}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def set_bot(self, bot: Bot) -> None:
|
||||
"""Устанавливает Bot instance для отправки сообщений.
|
||||
|
||||
Вызывается из main.py после создания бота.
|
||||
"""
|
||||
self._bot = bot
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# logging.Handler interface
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def emit(self, record: logging.LogRecord) -> None:
|
||||
"""Обрабатывает log record.
|
||||
|
||||
Синхронный метод (требование logging). Планирует async-отправку
|
||||
через event loop.
|
||||
"""
|
||||
# 1. Фильтр по уровню (на случай если кто-то обойдёт setLevel)
|
||||
if record.levelno < logging.ERROR:
|
||||
return
|
||||
|
||||
# 2. Уже отправлено через GlobalErrorMiddleware / @error_handler
|
||||
if getattr(record, '_admin_notified', False):
|
||||
return
|
||||
|
||||
# 3. Фильтруем шумные логгеры
|
||||
if any(record.name.startswith(prefix) for prefix in IGNORED_LOGGER_PREFIXES):
|
||||
return
|
||||
|
||||
# 4. Бот ещё не инициализирован -- пропускаем
|
||||
bot = self._bot
|
||||
if bot is None:
|
||||
return
|
||||
|
||||
# 5. Дедупликация по хешу (logger_name + message)
|
||||
msg_hash = self._compute_hash(record)
|
||||
now = time.monotonic()
|
||||
|
||||
# Чистим просроченные записи (ленивая очистка)
|
||||
self._evict_stale(now)
|
||||
|
||||
if msg_hash in self._recent_hashes:
|
||||
return
|
||||
self._recent_hashes[msg_hash] = now
|
||||
|
||||
# 6. Планируем отправку через event loop
|
||||
self._schedule_send(bot, record)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _compute_hash(record: logging.LogRecord) -> str:
|
||||
"""Вычисляет короткий хеш для дедупликации.
|
||||
|
||||
Хешируем имя логгера + сообщение (без timestamp).
|
||||
"""
|
||||
raw = f'{record.name}:{record.getMessage()}'
|
||||
return hashlib.md5(raw.encode('utf-8', errors='replace')).hexdigest()
|
||||
|
||||
def _evict_stale(self, now: float) -> None:
|
||||
"""Удаляет устаревшие записи из кеша хешей."""
|
||||
if not self._recent_hashes:
|
||||
return
|
||||
stale_keys = [k for k, ts in self._recent_hashes.items() if (now - ts) > RECENT_HASH_TTL_SECONDS]
|
||||
for k in stale_keys:
|
||||
self._recent_hashes.pop(k, None)
|
||||
# Принудительная очистка при переполнении — удаляем самые старые
|
||||
if len(self._recent_hashes) > RECENT_HASHES_MAX_SIZE:
|
||||
sorted_keys = sorted(self._recent_hashes, key=self._recent_hashes.get)
|
||||
for k in sorted_keys[: len(self._recent_hashes) - RECENT_HASHES_MAX_SIZE]:
|
||||
self._recent_hashes.pop(k, None)
|
||||
|
||||
def _schedule_send(self, bot: Bot, record: logging.LogRecord) -> None:
|
||||
"""Планирует асинхронную отправку в event loop.
|
||||
|
||||
Работает из любого потока:
|
||||
- Если вызов из async-контекста -- создаём Task напрямую.
|
||||
- Если из другого потока -- используем call_soon_threadsafe.
|
||||
"""
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
# Нет running loop -- мы в стороннем потоке без loop.
|
||||
# Пытаемся получить loop, привязанный к основному потоку.
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
if loop.is_closed():
|
||||
return
|
||||
loop.call_soon_threadsafe(self._create_send_task, bot, record, loop)
|
||||
except RuntimeError:
|
||||
return
|
||||
else:
|
||||
# Мы в async-контексте -- создаём task напрямую
|
||||
self._create_send_task(bot, record, loop)
|
||||
|
||||
def _create_send_task(self, bot: Bot, record: logging.LogRecord, loop: asyncio.AbstractEventLoop) -> None:
|
||||
"""Создаёт asyncio.Task для отправки уведомления."""
|
||||
loop.create_task(self._send(bot, record))
|
||||
|
||||
@staticmethod
|
||||
async def _send(bot: Bot, record: logging.LogRecord) -> None:
|
||||
"""Отправляет log record в админский чат через существующую инфраструктуру."""
|
||||
try:
|
||||
# Ленивый импорт -- избегаем циклических зависимостей при старте
|
||||
from app.middlewares.global_error import send_error_to_admin_chat
|
||||
|
||||
# Формируем pseudo-Exception из log record
|
||||
error = _make_log_record_error(record)
|
||||
|
||||
context_parts: list[str] = [f'Logger: {record.name}']
|
||||
if record.funcName:
|
||||
context_parts.append(f'Function: {record.funcName}')
|
||||
if record.pathname and record.lineno:
|
||||
context_parts.append(f'Location: {record.pathname}:{record.lineno}')
|
||||
|
||||
context = '\n'.join(context_parts)
|
||||
|
||||
# Извлекаем traceback из log record (если есть exc_info)
|
||||
tb_override: str | None = None
|
||||
if record.exc_info and record.exc_info[2] is not None:
|
||||
import traceback
|
||||
|
||||
tb_override = ''.join(traceback.format_exception(*record.exc_info))
|
||||
elif record.exc_text:
|
||||
tb_override = record.exc_text
|
||||
|
||||
await send_error_to_admin_chat(bot, error, context, tb_override=tb_override)
|
||||
|
||||
except Exception:
|
||||
# Ни в коем случае не даём исключению утечь -- это logging handler,
|
||||
# рекурсия убьёт приложение.
|
||||
pass
|
||||
|
||||
|
||||
def _make_log_record_error(record: logging.LogRecord) -> Exception:
|
||||
"""Создаёт Exception-обёртку для LogRecord.
|
||||
|
||||
send_error_to_admin_chat использует type(error).__name__ как error_type.
|
||||
Мы динамически создаём класс с правильным именем, чтобы не мутировать
|
||||
общий класс между вызовами.
|
||||
"""
|
||||
class_name = f'Log{record.levelname.capitalize()}'
|
||||
error_cls = type(
|
||||
class_name,
|
||||
(Exception,),
|
||||
{
|
||||
'__str__': lambda self: self.args[0] if self.args else '',
|
||||
},
|
||||
)
|
||||
error = error_cls(record.getMessage())
|
||||
error.record = record # type: ignore[attr-defined]
|
||||
return error
|
||||
+17
-2
@@ -7,6 +7,7 @@ from typing import Any
|
||||
from aiogram import BaseMiddleware
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import CallbackQuery, Message, TelegramObject, User as TgUser
|
||||
from sqlalchemy.exc import InterfaceError, OperationalError
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.user import get_user_by_telegram_id
|
||||
@@ -204,13 +205,27 @@ class AuthMiddleware(BaseMiddleware):
|
||||
data['is_admin'] = settings.is_admin(user.id)
|
||||
|
||||
result = await handler(event, data)
|
||||
await db.commit()
|
||||
try:
|
||||
await db.commit()
|
||||
except (InterfaceError, OperationalError) as conn_err:
|
||||
# Соединение закрылось (таймаут после долгой операции) - просто логируем
|
||||
logger.warning(f'⚠️ Соединение с БД закрыто после обработки, пропускаем commit: {conn_err}')
|
||||
return result
|
||||
|
||||
except (InterfaceError, OperationalError) as conn_err:
|
||||
# Соединение с БД закрылось - не пытаемся rollback
|
||||
logger.error(f'Ошибка соединения с БД в AuthMiddleware: {conn_err}')
|
||||
logger.error(f'Event type: {type(event)}')
|
||||
if hasattr(event, 'data'):
|
||||
logger.error(f'Callback data: {event.data}')
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка в AuthMiddleware: {e}')
|
||||
logger.error(f'Event type: {type(event)}')
|
||||
if hasattr(event, 'data'):
|
||||
logger.error(f'Callback data: {event.data}')
|
||||
await db.rollback()
|
||||
try:
|
||||
await db.rollback()
|
||||
except (InterfaceError, OperationalError):
|
||||
pass # Соединение уже закрыто
|
||||
raise
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import logging
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
|
||||
from aiogram import BaseMiddleware
|
||||
from aiogram.types import CallbackQuery, Message, PreCheckoutQuery, TelegramObject, User as TgUser
|
||||
|
||||
from app.services.blacklist_service import blacklist_service
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BlacklistMiddleware(BaseMiddleware):
|
||||
async def __call__(
|
||||
self,
|
||||
handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]],
|
||||
event: TelegramObject,
|
||||
data: dict[str, Any],
|
||||
) -> Any:
|
||||
user: TgUser | None = None
|
||||
if isinstance(event, (Message, CallbackQuery, PreCheckoutQuery)):
|
||||
user = event.from_user
|
||||
|
||||
if not user or user.is_bot:
|
||||
return await handler(event, data)
|
||||
|
||||
is_blacklisted, reason = await blacklist_service.is_user_blacklisted(user.id, user.username)
|
||||
|
||||
if not is_blacklisted:
|
||||
return await handler(event, data)
|
||||
|
||||
logger.warning(f'🚫 Пользователь {user.id} (@{user.username}) из черного списка: {reason}')
|
||||
|
||||
block_text = (
|
||||
f'🚫 Доступ запрещен\n\nПричина: {reason}\n\nЕсли вы считаете, что это ошибка, обратитесь в поддержку.'
|
||||
)
|
||||
|
||||
try:
|
||||
if isinstance(event, Message):
|
||||
await event.answer(block_text)
|
||||
elif isinstance(event, CallbackQuery):
|
||||
await event.answer(block_text, show_alert=True)
|
||||
elif isinstance(event, PreCheckoutQuery):
|
||||
await event.answer(ok=False, error_message='Доступ запрещен')
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка отправки сообщения о блокировке пользователю {user.id}: {e}')
|
||||
|
||||
return None
|
||||
@@ -15,7 +15,7 @@ from app.database.crud.campaign import get_campaign_by_start_parameter
|
||||
from app.database.crud.subscription import deactivate_subscription, reactivate_subscription
|
||||
from app.database.crud.user import get_user_by_telegram_id
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from app.database.models import SubscriptionStatus
|
||||
from app.database.models import SubscriptionStatus, UserStatus
|
||||
from app.keyboards.inline import get_channel_sub_keyboard
|
||||
from app.localization.loader import DEFAULT_LANGUAGE
|
||||
from app.localization.texts import get_texts
|
||||
@@ -396,6 +396,14 @@ class ChannelCheckerMiddleware(BaseMiddleware):
|
||||
if not user or not user.subscription:
|
||||
return
|
||||
|
||||
# НЕ реактивируем подписку заблокированным пользователям
|
||||
if user.status == UserStatus.BLOCKED.value:
|
||||
logger.info(
|
||||
'🚫 Пропуск реактивации для заблокированного пользователя %s',
|
||||
telegram_id,
|
||||
)
|
||||
return
|
||||
|
||||
subscription = user.subscription
|
||||
|
||||
# Реактивируем только DISABLED подписки
|
||||
|
||||
+218
-41
@@ -1,14 +1,60 @@
|
||||
import logging
|
||||
import traceback
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Final
|
||||
|
||||
from aiogram import BaseMiddleware
|
||||
from aiogram import BaseMiddleware, Bot
|
||||
from aiogram.enums import ParseMode
|
||||
from aiogram.exceptions import TelegramBadRequest
|
||||
from aiogram.types import CallbackQuery, TelegramObject
|
||||
from aiogram.types import BufferedInputFile, CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, TelegramObject
|
||||
from sqlalchemy.exc import InterfaceError, OperationalError
|
||||
|
||||
from app.config import settings
|
||||
from app.services.startup_notification_service import _get_error_recommendations
|
||||
from app.utils.timezone import format_local_datetime
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Константы
|
||||
ERROR_NOTIFICATION_COOLDOWN_MINUTES: Final[int] = 5
|
||||
ERROR_BUFFER_MAX_SIZE: Final[int] = 10
|
||||
ERROR_MESSAGE_MAX_LENGTH: Final[int] = 500
|
||||
REPORT_SEPARATOR_WIDTH: Final[int] = 50
|
||||
DATETIME_FORMAT: Final[str] = '%d.%m.%Y %H:%M:%S'
|
||||
DATETIME_FORMAT_FILENAME: Final[str] = '%Y%m%d_%H%M%S'
|
||||
DEVELOPER_CONTACT_URL: Final[str] = 'https://t.me/fringg'
|
||||
|
||||
# Фразы ошибок Telegram API
|
||||
OLD_QUERY_PHRASES: Final[tuple[str, ...]] = (
|
||||
'query is too old',
|
||||
'query id is invalid',
|
||||
'response timeout expired',
|
||||
)
|
||||
BAD_REQUEST_PHRASES: Final[tuple[str, ...]] = (
|
||||
'message not found',
|
||||
'chat not found',
|
||||
'bot was blocked by the user',
|
||||
'user is deactivated',
|
||||
)
|
||||
TOPIC_ERROR_PHRASES: Final[tuple[str, ...]] = (
|
||||
'topic must be specified',
|
||||
'topic_closed',
|
||||
'topic_deleted',
|
||||
'forum_closed',
|
||||
)
|
||||
MESSAGE_NOT_MODIFIED_PHRASE: Final[str] = 'message is not modified'
|
||||
BOT_BLOCKED_PHRASE: Final[str] = 'bot was blocked'
|
||||
USER_DEACTIVATED_PHRASE: Final[str] = 'user is deactivated'
|
||||
CHAT_NOT_FOUND_PHRASE: Final[str] = 'chat not found'
|
||||
MESSAGE_NOT_FOUND_PHRASE: Final[str] = 'message not found'
|
||||
|
||||
# Троттлинг для предотвращения спама ошибками
|
||||
_last_error_notification: datetime | None = None
|
||||
_error_notification_cooldown = timedelta(minutes=ERROR_NOTIFICATION_COOLDOWN_MINUTES)
|
||||
_error_buffer: list[tuple[str, str, str]] = [] # (error_type, error_message, traceback)
|
||||
|
||||
|
||||
class GlobalErrorMiddleware(BaseMiddleware):
|
||||
async def __call__(
|
||||
@@ -20,81 +66,83 @@ class GlobalErrorMiddleware(BaseMiddleware):
|
||||
try:
|
||||
return await handler(event, data)
|
||||
except TelegramBadRequest as e:
|
||||
return await self._handle_telegram_error(event, e)
|
||||
return await self._handle_telegram_error(event, e, data)
|
||||
except (InterfaceError, OperationalError) as e:
|
||||
# Ошибки соединения с БД (таймаут после долгих операций) - логируем, но не спамим админам
|
||||
logger.warning('⚠️ Ошибка соединения с БД в GlobalErrorMiddleware: %s', e)
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f'Неожиданная ошибка в GlobalErrorMiddleware: {e}', exc_info=True)
|
||||
user_info = self._get_user_info(event)
|
||||
logger.error('Неожиданная ошибка в GlobalErrorMiddleware (user=%s): %s', user_info, e, exc_info=True)
|
||||
raise
|
||||
|
||||
async def _handle_telegram_error(self, event: TelegramObject, error: TelegramBadRequest):
|
||||
async def _handle_telegram_error(self, event: TelegramObject, error: TelegramBadRequest, data: dict[str, Any]):
|
||||
error_message = str(error).lower()
|
||||
|
||||
if self._is_old_query_error(error_message):
|
||||
return await self._handle_old_query(event, error)
|
||||
if self._is_message_not_modified_error(error_message):
|
||||
return await self._handle_message_not_modified(event, error)
|
||||
return await self._handle_message_not_modified(event, error, data)
|
||||
if self._is_topic_required_error(error_message):
|
||||
# Канал с топиками — просто игнорируем
|
||||
logger.debug(f'📋 [GlobalErrorMiddleware] Игнорируем ошибку топика: {error}')
|
||||
logger.debug('[GlobalErrorMiddleware] Игнорируем ошибку топика: %s', error)
|
||||
return None
|
||||
if self._is_bad_request_error(error_message):
|
||||
return await self._handle_bad_request(event, error)
|
||||
logger.error(f'Неизвестная Telegram API ошибка: {error}')
|
||||
return await self._handle_bad_request(event, error, data)
|
||||
|
||||
# Неизвестная ошибка — логируем
|
||||
user_info = self._get_user_info(event)
|
||||
logger.error('Неизвестная Telegram API ошибка (user=%s): %s', user_info, error)
|
||||
raise error
|
||||
|
||||
def _is_old_query_error(self, error_message: str) -> bool:
|
||||
return any(
|
||||
phrase in error_message
|
||||
for phrase in ['query is too old', 'query id is invalid', 'response timeout expired']
|
||||
)
|
||||
return any(phrase in error_message for phrase in OLD_QUERY_PHRASES)
|
||||
|
||||
def _is_message_not_modified_error(self, error_message: str) -> bool:
|
||||
return 'message is not modified' in error_message
|
||||
return MESSAGE_NOT_MODIFIED_PHRASE in error_message
|
||||
|
||||
def _is_bad_request_error(self, error_message: str) -> bool:
|
||||
return any(
|
||||
phrase in error_message
|
||||
for phrase in ['message not found', 'chat not found', 'bot was blocked by the user', 'user is deactivated']
|
||||
)
|
||||
return any(phrase in error_message for phrase in BAD_REQUEST_PHRASES)
|
||||
|
||||
def _is_topic_required_error(self, error_message: str) -> bool:
|
||||
return any(
|
||||
phrase in error_message
|
||||
for phrase in ['topic must be specified', 'topic_closed', 'topic_deleted', 'forum_closed']
|
||||
)
|
||||
return any(phrase in error_message for phrase in TOPIC_ERROR_PHRASES)
|
||||
|
||||
async def _handle_old_query(self, event: TelegramObject, error: TelegramBadRequest):
|
||||
if isinstance(event, CallbackQuery):
|
||||
user_info = self._get_user_info(event)
|
||||
logger.warning(f"🕐 [GlobalErrorMiddleware] Игнорируем устаревший callback '{event.data}' от {user_info}")
|
||||
logger.warning("[GlobalErrorMiddleware] Игнорируем устаревший callback '%s' от %s", event.data, user_info)
|
||||
else:
|
||||
logger.warning(f'🕐 [GlobalErrorMiddleware] Игнорируем устаревший запрос: {error}')
|
||||
logger.warning('[GlobalErrorMiddleware] Игнорируем устаревший запрос: %s', error)
|
||||
|
||||
async def _handle_message_not_modified(self, event: TelegramObject, error: TelegramBadRequest):
|
||||
logger.debug(f'📝 [GlobalErrorMiddleware] Сообщение не было изменено: {error}')
|
||||
async def _handle_message_not_modified(
|
||||
self, event: TelegramObject, error: TelegramBadRequest, data: dict[str, Any]
|
||||
):
|
||||
logger.debug('[GlobalErrorMiddleware] Сообщение не было изменено: %s', error)
|
||||
|
||||
if isinstance(event, CallbackQuery):
|
||||
try:
|
||||
await event.answer()
|
||||
logger.debug("✅ Успешно ответили на callback после 'message not modified'")
|
||||
logger.debug("Успешно ответили на callback после 'message not modified'")
|
||||
except TelegramBadRequest as answer_error:
|
||||
if not self._is_old_query_error(str(answer_error).lower()):
|
||||
logger.error(f'❌ Ошибка при ответе на callback: {answer_error}')
|
||||
logger.warning('Ошибка при ответе на callback: %s', answer_error)
|
||||
|
||||
async def _handle_bad_request(self, event: TelegramObject, error: TelegramBadRequest):
|
||||
async def _handle_bad_request(self, event: TelegramObject, error: TelegramBadRequest, data: dict[str, Any]):
|
||||
error_message = str(error).lower()
|
||||
|
||||
if 'bot was blocked' in error_message:
|
||||
if BOT_BLOCKED_PHRASE in error_message:
|
||||
user_info = self._get_user_info(event) if hasattr(event, 'from_user') else 'Unknown'
|
||||
logger.info(f'🚫 [GlobalErrorMiddleware] Бот заблокирован пользователем {user_info}')
|
||||
logger.info('[GlobalErrorMiddleware] Бот заблокирован пользователем %s', user_info)
|
||||
return
|
||||
if 'user is deactivated' in error_message:
|
||||
if USER_DEACTIVATED_PHRASE in error_message:
|
||||
user_info = self._get_user_info(event) if hasattr(event, 'from_user') else 'Unknown'
|
||||
logger.info(f'👻 [GlobalErrorMiddleware] Пользователь деактивирован {user_info}')
|
||||
logger.info('[GlobalErrorMiddleware] Пользователь деактивирован %s', user_info)
|
||||
return
|
||||
if 'chat not found' in error_message or 'message not found' in error_message:
|
||||
logger.warning(f'🔍 [GlobalErrorMiddleware] Чат или сообщение не найдено: {error}')
|
||||
if CHAT_NOT_FOUND_PHRASE in error_message or MESSAGE_NOT_FOUND_PHRASE in error_message:
|
||||
logger.warning('[GlobalErrorMiddleware] Чат или сообщение не найдено: %s', error)
|
||||
return
|
||||
logger.error(f'❌ [GlobalErrorMiddleware] Неизвестная bad request ошибка: {error}')
|
||||
user_info = self._get_user_info(event)
|
||||
logger.error('[GlobalErrorMiddleware] Неизвестная bad request ошибка (user=%s): %s', user_info, error)
|
||||
raise error
|
||||
|
||||
def _get_user_info(self, event: TelegramObject) -> str:
|
||||
@@ -130,13 +178,13 @@ class ErrorStatisticsMiddleware(BaseMiddleware):
|
||||
def _count_error(self, error: TelegramBadRequest):
|
||||
error_message = str(error).lower()
|
||||
|
||||
if 'query is too old' in error_message:
|
||||
if OLD_QUERY_PHRASES[0] in error_message:
|
||||
self.error_counts['old_queries'] += 1
|
||||
elif 'message is not modified' in error_message:
|
||||
elif MESSAGE_NOT_MODIFIED_PHRASE in error_message:
|
||||
self.error_counts['message_not_modified'] += 1
|
||||
elif 'bot was blocked' in error_message:
|
||||
elif BOT_BLOCKED_PHRASE in error_message:
|
||||
self.error_counts['bot_blocked'] += 1
|
||||
elif 'user is deactivated' in error_message:
|
||||
elif USER_DEACTIVATED_PHRASE in error_message:
|
||||
self.error_counts['user_deactivated'] += 1
|
||||
else:
|
||||
self.error_counts['other_errors'] += 1
|
||||
@@ -147,3 +195,132 @@ class ErrorStatisticsMiddleware(BaseMiddleware):
|
||||
def reset_statistics(self):
|
||||
for key in self.error_counts:
|
||||
self.error_counts[key] = 0
|
||||
|
||||
|
||||
async def send_error_to_admin_chat(
|
||||
bot: Bot, error: Exception, context: str = '', tb_override: str | None = None
|
||||
) -> bool:
|
||||
"""
|
||||
Отправляет уведомление об ошибке в админский чат с троттлингом.
|
||||
|
||||
Args:
|
||||
bot: Экземпляр бота
|
||||
error: Исключение
|
||||
context: Дополнительный контекст (например, информация о пользователе)
|
||||
tb_override: Готовый traceback (если вызывается не из except-блока)
|
||||
|
||||
Returns:
|
||||
bool: True если уведомление отправлено
|
||||
"""
|
||||
global _last_error_notification
|
||||
|
||||
chat_id = getattr(settings, 'ADMIN_NOTIFICATIONS_CHAT_ID', None)
|
||||
topic_id = getattr(settings, 'ADMIN_NOTIFICATIONS_TOPIC_ID', None)
|
||||
enabled = getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False)
|
||||
|
||||
if not enabled or not chat_id:
|
||||
return False
|
||||
|
||||
error_type = type(error).__name__
|
||||
error_message = str(error)[:ERROR_MESSAGE_MAX_LENGTH]
|
||||
tb_str = tb_override or traceback.format_exc()
|
||||
if tb_str == 'NoneType: None\n' or tb_str == 'NoneType: None':
|
||||
tb_str = '(no traceback available)'
|
||||
|
||||
# Добавляем в буфер
|
||||
_error_buffer.append((error_type, error_message, tb_str))
|
||||
if len(_error_buffer) > ERROR_BUFFER_MAX_SIZE:
|
||||
_error_buffer.pop(0)
|
||||
|
||||
# Проверяем троттлинг
|
||||
now = datetime.utcnow()
|
||||
if _last_error_notification and (now - _last_error_notification) < _error_notification_cooldown:
|
||||
logger.debug('Ошибка добавлена в буфер, троттлинг активен: %s', error_type)
|
||||
return False
|
||||
|
||||
_last_error_notification = now
|
||||
|
||||
try:
|
||||
timestamp = format_local_datetime(now, DATETIME_FORMAT)
|
||||
separator = '=' * REPORT_SEPARATOR_WIDTH
|
||||
|
||||
# Формируем лог-файл со всеми ошибками из буфера
|
||||
log_lines = [
|
||||
'ERROR REPORT',
|
||||
separator,
|
||||
f'Timestamp: {timestamp}',
|
||||
f'Errors in buffer: {len(_error_buffer)}',
|
||||
'',
|
||||
]
|
||||
|
||||
for i, (err_type, err_msg, err_tb) in enumerate(_error_buffer):
|
||||
log_lines.extend(
|
||||
[
|
||||
separator,
|
||||
f'ERROR #{i}: {err_type}',
|
||||
separator,
|
||||
f'Message: {err_msg}',
|
||||
'',
|
||||
'Traceback:',
|
||||
err_tb,
|
||||
'',
|
||||
]
|
||||
)
|
||||
|
||||
log_content = '\n'.join(log_lines)
|
||||
|
||||
# Очищаем буфер после отправки
|
||||
errors_count = len(_error_buffer)
|
||||
_error_buffer.clear()
|
||||
|
||||
file_name = f'error_report_{now.strftime(DATETIME_FORMAT_FILENAME)}.txt'
|
||||
file = BufferedInputFile(
|
||||
file=log_content.encode('utf-8'),
|
||||
filename=file_name,
|
||||
)
|
||||
|
||||
message_text = (
|
||||
f'<b>Remnawave Bedolaga Bot</b>\n\n'
|
||||
f'⚠️ Ошибка во время работы\n\n'
|
||||
f'<b>Тип:</b> <code>{error_type}</code>\n'
|
||||
f'<b>Ошибок в отчёте:</b> {errors_count}\n'
|
||||
)
|
||||
if context:
|
||||
message_text += f'<b>Контекст:</b> {context}\n'
|
||||
|
||||
# Добавляем рекомендации если есть
|
||||
recommendations = _get_error_recommendations(error_message)
|
||||
if recommendations:
|
||||
message_text += f'\n{recommendations}\n'
|
||||
|
||||
message_text += f'\n<i>{timestamp}</i>'
|
||||
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text='💬 Сообщить разработчику',
|
||||
url=DEVELOPER_CONTACT_URL,
|
||||
),
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
message_kwargs: dict = {
|
||||
'chat_id': chat_id,
|
||||
'document': file,
|
||||
'caption': message_text,
|
||||
'parse_mode': ParseMode.HTML,
|
||||
'reply_markup': keyboard,
|
||||
}
|
||||
|
||||
if topic_id:
|
||||
message_kwargs['message_thread_id'] = topic_id
|
||||
|
||||
await bot.send_document(**message_kwargs)
|
||||
logger.info('Уведомление об ошибке отправлено в чат %s', chat_id)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Ошибка отправки уведомления об ошибке: %s', e, extra={'_admin_notified': True})
|
||||
return False
|
||||
|
||||
@@ -288,9 +288,7 @@ class AdminNotificationService:
|
||||
return False
|
||||
|
||||
user_status = '🆕 Новый' if not user.has_had_paid_subscription else '🔄 Существующий'
|
||||
referrer_info = await self._get_referrer_info(db, user.referred_by_id)
|
||||
promo_group = await self._get_user_promo_group(db, user)
|
||||
promo_block = self._format_promo_group_block(promo_group)
|
||||
user_display = self._get_user_display(user)
|
||||
|
||||
trial_device_limit = subscription.device_limit
|
||||
@@ -308,33 +306,79 @@ class AdminNotificationService:
|
||||
user_id_label = self._get_user_identifier_label(user)
|
||||
user_id_display = self._get_user_identifier_display(user)
|
||||
|
||||
message = f"""🎯 <b>АКТИВАЦИЯ ТРИАЛА</b>
|
||||
# Получаем название тарифа (если режим тарифов)
|
||||
tariff_name = await self._get_tariff_name(db, subscription)
|
||||
|
||||
👤 <b>Пользователь:</b> {user_display}
|
||||
🆔 <b>{user_id_label}:</b> {user_id_display}
|
||||
📱 <b>Username:</b> @{getattr(user, 'username', None) or 'отсутствует'}
|
||||
👥 <b>Статус:</b> {user_status}
|
||||
message_lines = [
|
||||
'🎯 <b>АКТИВАЦИЯ ТРИАЛА</b>',
|
||||
'',
|
||||
f'👤 <b>Пользователь:</b> {user_display}',
|
||||
f'🆔 <b>{user_id_label}:</b> {user_id_display}',
|
||||
f'📱 <b>Username:</b> @{getattr(user, "username", None) or "отсутствует"}',
|
||||
f'👥 <b>Статус:</b> {user_status}',
|
||||
'',
|
||||
]
|
||||
|
||||
{promo_block}
|
||||
# Промогруппа — только название, без скидок
|
||||
if promo_group:
|
||||
message_lines.append(f'🏷️ <b>Промогруппа:</b> {promo_group.name}')
|
||||
else:
|
||||
message_lines.append('🏷️ <b>Промогруппа:</b> —')
|
||||
|
||||
⏰ <b>Параметры триала:</b>
|
||||
📅 Период: {settings.TRIAL_DURATION_DAYS} дней
|
||||
📊 Трафик: {self._format_traffic(settings.TRIAL_TRAFFIC_LIMIT_GB)}
|
||||
📱 Устройства: {trial_device_limit}
|
||||
🌐 Сервер: {subscription.connected_squads[0] if subscription.connected_squads else 'По умолчанию'}
|
||||
{payment_block}
|
||||
# Тариф триала (если есть)
|
||||
if tariff_name:
|
||||
message_lines.append(f'📦 <b>Тариф:</b> {tariff_name}')
|
||||
|
||||
📆 <b>Действует до:</b> {format_local_datetime(subscription.end_date, '%d.%m.%Y %H:%M')}
|
||||
🔗 <b>Реферер:</b> {referrer_info}
|
||||
message_lines.append('')
|
||||
|
||||
⏰ <i>{format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}</i>"""
|
||||
message_lines.extend(
|
||||
[
|
||||
'⏰ <b>Параметры триала:</b>',
|
||||
f'📅 Период: {settings.TRIAL_DURATION_DAYS} дней',
|
||||
f'📊 Трафик: {self._format_traffic(settings.TRIAL_TRAFFIC_LIMIT_GB)}',
|
||||
f'📱 Устройства: {trial_device_limit}',
|
||||
f'🌐 Сервер: {subscription.connected_squads[0] if subscription.connected_squads else "По умолчанию"}',
|
||||
]
|
||||
)
|
||||
|
||||
return await self._send_message(message)
|
||||
if payment_block:
|
||||
message_lines.append(payment_block)
|
||||
|
||||
message_lines.append('')
|
||||
message_lines.append(
|
||||
f'📆 <b>Действует до:</b> {format_local_datetime(subscription.end_date, "%d.%m.%Y %H:%M")}'
|
||||
)
|
||||
|
||||
# Реферер — только если есть
|
||||
if user.referred_by_id:
|
||||
referrer_info = await self._get_referrer_info(db, user.referred_by_id)
|
||||
if referrer_info != 'Нет':
|
||||
message_lines.append(f'🔗 <b>Реферер:</b> {referrer_info}')
|
||||
|
||||
message_lines.append('')
|
||||
message_lines.append(f'⏰ <i>{format_local_datetime(datetime.utcnow(), "%d.%m.%Y %H:%M:%S")}</i>')
|
||||
|
||||
return await self._send_message('\n'.join(message_lines))
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка отправки уведомления о триале: {e}')
|
||||
return False
|
||||
|
||||
async def _get_tariff_name(self, db: AsyncSession, subscription: Subscription) -> str | None:
|
||||
"""Получает название тарифа подписки, если он есть."""
|
||||
if not subscription.tariff_id:
|
||||
return None
|
||||
|
||||
try:
|
||||
from app.database.crud.tariff import get_tariff_by_id
|
||||
|
||||
tariff = await get_tariff_by_id(db, subscription.tariff_id)
|
||||
if tariff:
|
||||
return tariff.name
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
async def send_subscription_purchase_notification(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
@@ -344,6 +388,7 @@ class AdminNotificationService:
|
||||
period_days: int,
|
||||
was_trial_conversion: bool = False,
|
||||
amount_kopeks: int | None = None,
|
||||
purchase_type: str | None = None, # 'tariff_switch', 'renewal', None (auto)
|
||||
) -> bool:
|
||||
try:
|
||||
total_amount = (
|
||||
@@ -371,93 +416,138 @@ class AdminNotificationService:
|
||||
if not self._is_enabled():
|
||||
return False
|
||||
|
||||
event_type = '🔄 КОНВЕРСИЯ ИЗ ТРИАЛА' if was_trial_conversion else '💎 ПОКУПКА ПОДПИСКИ'
|
||||
|
||||
if was_trial_conversion:
|
||||
user_status = '🎯 Конверсия из триала'
|
||||
elif user.has_had_paid_subscription:
|
||||
user_status = '🔄 Продление/Обновление'
|
||||
# Определяем тип операции и заголовок
|
||||
if purchase_type == 'tariff_switch':
|
||||
event_title = '🔄 СМЕНА ТАРИФА'
|
||||
user_status = 'Смена тарифа'
|
||||
elif was_trial_conversion:
|
||||
event_title = '🔄 КОНВЕРСИЯ ИЗ ТРИАЛА'
|
||||
user_status = 'Конверсия'
|
||||
elif purchase_type == 'renewal' or user.has_had_paid_subscription:
|
||||
event_title = '💎 ПРОДЛЕНИЕ ПОДПИСКИ'
|
||||
user_status = 'Продление'
|
||||
else:
|
||||
user_status = '🆕 Первая покупка'
|
||||
event_title = '💎 ПОКУПКА ПОДПИСКИ'
|
||||
user_status = 'Первая покупка'
|
||||
|
||||
# Получаем название тарифа
|
||||
tariff_name = await self._get_tariff_name(db, subscription)
|
||||
|
||||
servers_info = await self._get_servers_info(subscription.connected_squads)
|
||||
payment_method = self._get_payment_method_display(transaction.payment_method) if transaction else 'Баланс'
|
||||
referrer_info = await self._get_referrer_info(db, user.referred_by_id)
|
||||
promo_group = await self._get_user_promo_group(db, user)
|
||||
promo_block = self._format_promo_group_block(promo_group)
|
||||
user_display = self._get_user_display(user)
|
||||
|
||||
transaction_id = transaction.id if transaction else '—'
|
||||
user_id_label = self._get_user_identifier_label(user)
|
||||
user_id_display = self._get_user_identifier_display(user)
|
||||
|
||||
message = f"""💎 <b>{event_type}</b>
|
||||
# Формируем компактное сообщение
|
||||
message_lines = [
|
||||
f'<b>{event_title}</b>',
|
||||
'',
|
||||
f'👤 {user_display} ({user_id_display})',
|
||||
]
|
||||
|
||||
👤 <b>Пользователь:</b> {user_display}
|
||||
🆔 <b>{user_id_label}:</b> {user_id_display}
|
||||
📱 <b>Username:</b> @{getattr(user, 'username', None) or 'отсутствует'}
|
||||
👥 <b>Статус:</b> {user_status}
|
||||
# Добавляем username только если есть
|
||||
username = getattr(user, 'username', None)
|
||||
if username:
|
||||
message_lines.append(f'📱 @{username}')
|
||||
|
||||
{promo_block}
|
||||
message_lines.append(f'📋 {user_status}')
|
||||
|
||||
💰 <b>Платеж:</b>
|
||||
💵 Сумма: {settings.format_price(total_amount)}
|
||||
💳 Способ: {payment_method}
|
||||
🆔 ID транзакции: {transaction_id}
|
||||
# Тариф (если есть)
|
||||
if tariff_name:
|
||||
message_lines.append(f'🏷️ Тариф: <b>{tariff_name}</b>')
|
||||
|
||||
📱 <b>Параметры подписки:</b>
|
||||
📅 Период: {period_days} дней
|
||||
📊 Трафик: {self._format_traffic(subscription.traffic_limit_gb)}
|
||||
📱 Устройства: {subscription.device_limit}
|
||||
🌐 Серверы: {servers_info}
|
||||
message_lines.extend(
|
||||
[
|
||||
'',
|
||||
f'💵 <b>{settings.format_price(total_amount)}</b> • {payment_method}',
|
||||
f'📅 {period_days} дн. • до {format_local_datetime(subscription.end_date, "%d.%m.%Y")}',
|
||||
f'📊 {self._format_traffic(subscription.traffic_limit_gb)} • 📱 {subscription.device_limit} устр.',
|
||||
f'🌐 {servers_info}',
|
||||
]
|
||||
)
|
||||
|
||||
📆 <b>Действует до:</b> {format_local_datetime(subscription.end_date, '%d.%m.%Y %H:%M')}
|
||||
💰 <b>Баланс после покупки:</b> {settings.format_price(user.balance_kopeks)}
|
||||
🔗 <b>Реферер:</b> {referrer_info}
|
||||
# Баланс после покупки
|
||||
message_lines.append(f'💰 Баланс: {settings.format_price(user.balance_kopeks)}')
|
||||
|
||||
⏰ <i>{format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}</i>"""
|
||||
# Реферер (только если есть)
|
||||
if user.referred_by_id:
|
||||
referrer_info = await self._get_referrer_info(db, user.referred_by_id)
|
||||
if referrer_info != 'Нет':
|
||||
message_lines.append(f'🔗 Реф: {referrer_info}')
|
||||
|
||||
return await self._send_message(message)
|
||||
# ID транзакции (только если есть)
|
||||
if transaction:
|
||||
message_lines.append(f'🆔 #{transaction.id}')
|
||||
|
||||
message_lines.extend(
|
||||
[
|
||||
'',
|
||||
f'<i>{format_local_datetime(datetime.utcnow(), "%d.%m.%Y %H:%M")}</i>',
|
||||
]
|
||||
)
|
||||
|
||||
return await self._send_message('\n'.join(message_lines))
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка отправки уведомления о покупке: {e}')
|
||||
return False
|
||||
|
||||
async def send_version_update_notification(self, current_version: str, latest_version, total_updates: int) -> bool:
|
||||
"""Отправляет уведомление о новых обновлениях"""
|
||||
"""Отправляет уведомление о новых обновлениях."""
|
||||
if not self._is_enabled():
|
||||
return False
|
||||
|
||||
try:
|
||||
from app.utils.markdown_to_telegram import github_markdown_to_telegram_html, truncate_for_blockquote
|
||||
|
||||
repo = getattr(settings, 'VERSION_CHECK_REPO', 'fr1ngg/remnawave-bedolaga-telegram-bot')
|
||||
release_url = f'https://github.com/{repo}/releases/tag/{latest_version.tag_name}'
|
||||
repo_url = f'https://github.com/{repo}'
|
||||
timestamp = format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')
|
||||
|
||||
if latest_version.prerelease:
|
||||
update_type = '🧪 ПРЕДВАРИТЕЛЬНАЯ ВЕРСИЯ'
|
||||
type_icon = '🧪'
|
||||
header = '🧪 <b>Pre-release</b>'
|
||||
elif latest_version.is_dev:
|
||||
update_type = '🔧 DEV ВЕРСИЯ'
|
||||
type_icon = '🔧'
|
||||
header = '🔧 <b>Dev build</b>'
|
||||
else:
|
||||
update_type = '📦 НОВАЯ ВЕРСИЯ'
|
||||
type_icon = '📦'
|
||||
header = '🆕 <b>Доступно обновление</b>'
|
||||
|
||||
description = latest_version.short_description
|
||||
if len(description) > 200:
|
||||
description = description[:197] + '...'
|
||||
# -- message prefix (everything before blockquote) --
|
||||
prefix_lines = [
|
||||
header,
|
||||
'',
|
||||
f'<code>{current_version}</code> → <b><a href="{release_url}">{latest_version.tag_name}</a></b>',
|
||||
f'📅 {latest_version.formatted_date}',
|
||||
'',
|
||||
]
|
||||
message_prefix = '\n'.join(prefix_lines)
|
||||
|
||||
message = f"""{type_icon} <b>{update_type} ДОСТУПНА</b>
|
||||
# -- message suffix (everything after blockquote) --
|
||||
suffix_lines = ['']
|
||||
if total_updates > 1:
|
||||
suffix_lines.append(f'Доступно обновлений: <b>{total_updates}</b>')
|
||||
suffix_lines.extend(
|
||||
[
|
||||
f'<a href="{repo_url}">Репозиторий</a>',
|
||||
'',
|
||||
f'<i>{timestamp}</i>',
|
||||
]
|
||||
)
|
||||
message_suffix = '\n'.join(suffix_lines)
|
||||
|
||||
📦 <b>Текущая версия:</b> <code>{current_version}</code>
|
||||
🆕 <b>Новая версия:</b> <code>{latest_version.tag_name}</code>
|
||||
📅 <b>Дата релиза:</b> {latest_version.formatted_date}
|
||||
# -- description in blockquote --
|
||||
raw_description = getattr(latest_version, 'full_description', '') or latest_version.short_description
|
||||
description_html = github_markdown_to_telegram_html(raw_description)
|
||||
|
||||
📝 <b>Описание:</b>
|
||||
{description}
|
||||
|
||||
🔢 <b>Всего доступно обновлений:</b> {total_updates}
|
||||
🔗 <b>Репозиторий:</b> https://github.com/{getattr(self, 'repo', 'fr1ngg/remnawave-bedolaga-telegram-bot')}
|
||||
|
||||
ℹ️ Для обновления перезапустите контейнер с новым тегом или обновите код из репозитория.
|
||||
|
||||
⚙️ <i>Автоматическая проверка обновлений • {format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}</i>"""
|
||||
if description_html:
|
||||
description_html = truncate_for_blockquote(
|
||||
description_html,
|
||||
message_prefix=message_prefix,
|
||||
message_suffix=message_suffix,
|
||||
)
|
||||
message = f'{message_prefix}<blockquote expandable>{description_html}</blockquote>{message_suffix}'
|
||||
else:
|
||||
message = f'{message_prefix}{message_suffix}'
|
||||
|
||||
return await self._send_message(message)
|
||||
|
||||
@@ -500,35 +590,81 @@ class AdminNotificationService:
|
||||
payment_method = self._get_payment_method_display(transaction.payment_method)
|
||||
balance_change = user.balance_kopeks - old_balance
|
||||
subscription_status = self._get_subscription_status(subscription)
|
||||
promo_block = self._format_promo_group_block(promo_group)
|
||||
timestamp = format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')
|
||||
user_display = self._get_user_display(user)
|
||||
user_id_label = self._get_user_identifier_label(user)
|
||||
user_id_display = self._get_user_identifier_display(user)
|
||||
|
||||
return f"""💰 <b>ПОПОЛНЕНИЕ БАЛАНСА</b>
|
||||
# --- Основной блок ---
|
||||
message_lines: list[str] = [
|
||||
'💰 <b>ПОПОЛНЕНИЕ БАЛАНСА</b>',
|
||||
'',
|
||||
f'👤 {user_display} ({user_id_display})',
|
||||
]
|
||||
|
||||
👤 <b>Пользователь:</b> {user_display}
|
||||
🆔 <b>{user_id_label}:</b> {user_id_display}
|
||||
📱 <b>Username:</b> @{getattr(user, 'username', None) or 'отсутствует'}
|
||||
💳 <b>Статус:</b> {topup_status}
|
||||
username = getattr(user, 'username', None)
|
||||
if username:
|
||||
message_lines.append(f'📱 @{username}')
|
||||
|
||||
{promo_block}
|
||||
message_lines.append(f'💳 {topup_status}')
|
||||
|
||||
💰 <b>Детали пополнения:</b>
|
||||
💵 Сумма: {settings.format_price(transaction.amount_kopeks)}
|
||||
💳 Способ: {payment_method}
|
||||
🆔 ID транзакции: {transaction.id}
|
||||
# Промогруппа -- только название
|
||||
if promo_group:
|
||||
message_lines.append(f'🏷️ Промогруппа: {promo_group.name}')
|
||||
|
||||
💰 <b>Баланс:</b>
|
||||
📉 Было: {settings.format_price(old_balance)}
|
||||
📈 Стало: {settings.format_price(user.balance_kopeks)}
|
||||
➕ Изменение: +{settings.format_price(balance_change)}
|
||||
message_lines.append('')
|
||||
|
||||
🔗 <b>Реферер:</b> {referrer_info}
|
||||
📱 <b>Подписка:</b> {subscription_status}
|
||||
# --- Детали пополнения ---
|
||||
message_lines.extend(
|
||||
[
|
||||
f'💵 <b>{settings.format_price(transaction.amount_kopeks)}</b> | {payment_method}',
|
||||
'',
|
||||
f'📉 {settings.format_price(old_balance)} → 📈 {settings.format_price(user.balance_kopeks)}'
|
||||
f' (<b>+{settings.format_price(balance_change)}</b>)',
|
||||
]
|
||||
)
|
||||
|
||||
⏰ <i>{timestamp}</i>"""
|
||||
# --- Подписка ---
|
||||
message_lines.append(f'📱 Подписка: {subscription_status}')
|
||||
|
||||
# --- Реферер (только если есть) ---
|
||||
if referrer_info and referrer_info != 'Нет':
|
||||
message_lines.append(f'🔗 Реферер: {referrer_info}')
|
||||
|
||||
# --- Expandable blockquote с техническими деталями ---
|
||||
detail_lines: list[str] = [
|
||||
f'ID транзакции: {transaction.id}',
|
||||
f'Способ оплаты: {transaction.payment_method or "balance"}',
|
||||
]
|
||||
|
||||
if transaction.external_id:
|
||||
detail_lines.append(f'Внешний ID: {transaction.external_id}')
|
||||
|
||||
if transaction.description:
|
||||
desc = transaction.description
|
||||
if len(desc) > 120:
|
||||
desc = desc[:117] + '...'
|
||||
detail_lines.append(f'Описание: {desc}')
|
||||
|
||||
if transaction.created_at:
|
||||
detail_lines.append(f'Создана: {format_local_datetime(transaction.created_at, "%d.%m.%Y %H:%M:%S")}')
|
||||
|
||||
if transaction.completed_at:
|
||||
detail_lines.append(f'Завершена: {format_local_datetime(transaction.completed_at, "%d.%m.%Y %H:%M:%S")}')
|
||||
|
||||
if transaction.receipt_uuid:
|
||||
detail_lines.append(f'Чек UUID: {transaction.receipt_uuid}')
|
||||
|
||||
blockquote_body = '\n'.join(detail_lines)
|
||||
message_lines.extend(
|
||||
[
|
||||
'',
|
||||
f'<blockquote expandable>{blockquote_body}</blockquote>',
|
||||
]
|
||||
)
|
||||
|
||||
message_lines.append(f'<i>{timestamp}</i>')
|
||||
|
||||
return '\n'.join(message_lines)
|
||||
|
||||
async def _reload_topup_notification_entities(
|
||||
self,
|
||||
@@ -1081,21 +1217,27 @@ class AdminNotificationService:
|
||||
return self.enabled and bool(self.chat_id)
|
||||
|
||||
def _get_payment_method_display(self, payment_method: str | None) -> str:
|
||||
mulenpay_name = settings.get_mulenpay_display_name()
|
||||
method_names = {
|
||||
if not payment_method:
|
||||
return '💰 С баланса'
|
||||
|
||||
method_names: dict[str, str] = {
|
||||
'telegram_stars': '⭐ Telegram Stars',
|
||||
'yookassa': '💳 YooKassa (карта)',
|
||||
'tribute': '💎 Tribute (карта)',
|
||||
'mulenpay': f'💳 {mulenpay_name} (карта)',
|
||||
'pal24': '🏦 PayPalych (СБП)',
|
||||
'mulenpay': f'💳 {settings.get_mulenpay_display_name()} (карта)',
|
||||
'pal24': f'🏦 {settings.get_pal24_display_name()} (СБП)',
|
||||
'cryptobot': f'🪙 {settings.get_cryptobot_display_name()} (крипто)',
|
||||
'heleket': f'🪙 {settings.get_heleket_display_name()} (крипто)',
|
||||
'wata': f'💳 {settings.get_wata_display_name()}',
|
||||
'platega': f'💳 {settings.get_platega_display_name()}',
|
||||
'cloudpayments': f'💳 {settings.get_cloudpayments_display_name()}',
|
||||
'freekassa': f'💳 {settings.get_freekassa_display_name()}',
|
||||
'kassa_ai': f'💳 {settings.get_kassa_ai_display_name()}',
|
||||
'manual': '🛠️ Вручную (админ)',
|
||||
'balance': '💰 С баланса',
|
||||
}
|
||||
|
||||
if not payment_method:
|
||||
return '💰 С баланса'
|
||||
|
||||
return method_names.get(payment_method, '💰 С баланса')
|
||||
return method_names.get(payment_method, f'💳 {payment_method}')
|
||||
|
||||
def _format_traffic(self, traffic_gb: int) -> str:
|
||||
if traffic_gb == 0:
|
||||
@@ -1369,65 +1511,81 @@ class AdminNotificationService:
|
||||
return False
|
||||
|
||||
try:
|
||||
referrer_info = await self._get_referrer_info(db, user.referred_by_id)
|
||||
promo_group = await self._get_user_promo_group(db, user)
|
||||
promo_block = self._format_promo_group_block(promo_group)
|
||||
user_display = self._get_user_display(user)
|
||||
|
||||
update_types = {
|
||||
'traffic': ('📊 ИЗМЕНЕНИЕ ТРАФИКА', 'трафик'),
|
||||
'devices': ('📱 ИЗМЕНЕНИЕ УСТРОЙСТВ', 'количество устройств'),
|
||||
'servers': ('🌐 ИЗМЕНЕНИЕ СЕРВЕРОВ', 'серверы'),
|
||||
'modem': ('📡 ИЗМЕНЕНИЕ МОДЕМА', 'модем'),
|
||||
}
|
||||
|
||||
title, param_name = update_types.get(update_type, ('⚙️ ИЗМЕНЕНИЕ ПОДПИСКИ', 'параметры'))
|
||||
user_id_label = self._get_user_identifier_label(user)
|
||||
user_id_display = self._get_user_identifier_display(user)
|
||||
|
||||
# Определяем заголовок по типу операции
|
||||
update_titles = {
|
||||
'traffic': '📊 ДОКУПКА ТРАФИКА',
|
||||
'devices': '📱 ДОКУПКА УСТРОЙСТВ',
|
||||
'servers': '🌐 СМЕНА СЕРВЕРОВ',
|
||||
'modem': '📡 МОДЕМ',
|
||||
}
|
||||
title = update_titles.get(update_type, '⚙️ ИЗМЕНЕНИЕ ПОДПИСКИ')
|
||||
|
||||
# Получаем название тарифа
|
||||
tariff_name = await self._get_tariff_name(db, subscription)
|
||||
|
||||
# Формируем компактное сообщение
|
||||
message_lines = [
|
||||
f'{title}',
|
||||
f'<b>{title}</b>',
|
||||
'',
|
||||
f'👤 <b>Пользователь:</b> {user_display}',
|
||||
f'🆔 <b>{user_id_label}:</b> {user_id_display}',
|
||||
f'📱 <b>Username:</b> @{getattr(user, "username", None) or "отсутствует"}',
|
||||
'',
|
||||
promo_block,
|
||||
'',
|
||||
'🔧 <b>Изменение:</b>',
|
||||
f'📋 Параметр: {param_name}',
|
||||
f'👤 {user_display} ({user_id_display})',
|
||||
]
|
||||
|
||||
# Добавляем username только если есть
|
||||
username = getattr(user, 'username', None)
|
||||
if username:
|
||||
message_lines.append(f'📱 @{username}')
|
||||
|
||||
# Тариф (если есть)
|
||||
if tariff_name:
|
||||
message_lines.append(f'🏷️ Тариф: <b>{tariff_name}</b>')
|
||||
|
||||
message_lines.append('')
|
||||
|
||||
# Форматируем изменение в зависимости от типа
|
||||
if update_type == 'servers':
|
||||
old_servers_info = await self._format_servers_detailed(old_value)
|
||||
new_servers_info = await self._format_servers_detailed(new_value)
|
||||
message_lines.extend(
|
||||
[
|
||||
f'📉 Было: {old_servers_info}',
|
||||
f'📈 Стало: {new_servers_info}',
|
||||
]
|
||||
)
|
||||
message_lines.append(f'🔄 {old_servers_info} → {new_servers_info}')
|
||||
elif update_type == 'traffic':
|
||||
old_formatted = self._format_update_value(old_value, update_type)
|
||||
new_formatted = self._format_update_value(new_value, update_type)
|
||||
message_lines.append(f'🔄 {old_formatted} → {new_formatted}')
|
||||
elif update_type == 'devices':
|
||||
message_lines.append(f'🔄 {old_value} → {new_value} устр.')
|
||||
elif update_type == 'modem':
|
||||
old_state = '✅ Вкл' if old_value else '❌ Выкл'
|
||||
new_state = '✅ Вкл' if new_value else '❌ Выкл'
|
||||
message_lines.append(f'🔄 {old_state} → {new_state}')
|
||||
else:
|
||||
message_lines.extend(
|
||||
[
|
||||
f'📉 Было: {self._format_update_value(old_value, update_type)}',
|
||||
f'📈 Стало: {self._format_update_value(new_value, update_type)}',
|
||||
]
|
||||
)
|
||||
message_lines.append(f'🔄 {old_value} → {new_value}')
|
||||
|
||||
# Стоимость операции
|
||||
if price_paid > 0:
|
||||
message_lines.append(f'💰 Доплачено: {settings.format_price(price_paid)}')
|
||||
message_lines.append(f'💵 <b>{settings.format_price(price_paid)}</b>')
|
||||
else:
|
||||
message_lines.append('💸 Бесплатно')
|
||||
|
||||
message_lines.extend(
|
||||
[
|
||||
'',
|
||||
f'📅 <b>Подписка действует до:</b> {format_local_datetime(subscription.end_date, "%d.%m.%Y %H:%M")}',
|
||||
f'💰 <b>Баланс после операции:</b> {settings.format_price(user.balance_kopeks)}',
|
||||
f'🔗 <b>Рефер:</b> {referrer_info}',
|
||||
f'📅 До {format_local_datetime(subscription.end_date, "%d.%m.%Y")}',
|
||||
f'💰 Баланс: {settings.format_price(user.balance_kopeks)}',
|
||||
]
|
||||
)
|
||||
|
||||
# Реферер (только если есть)
|
||||
if user.referred_by_id:
|
||||
referrer_info = await self._get_referrer_info(db, user.referred_by_id)
|
||||
if referrer_info != 'Нет':
|
||||
message_lines.append(f'🔗 Реф: {referrer_info}')
|
||||
|
||||
message_lines.extend(
|
||||
[
|
||||
'',
|
||||
f'⏰ <i>{format_local_datetime(datetime.utcnow(), "%d.%m.%Y %H:%M:%S")}</i>',
|
||||
f'<i>{format_local_datetime(datetime.utcnow(), "%d.%m.%Y %H:%M")}</i>',
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import aiohttp
|
||||
@@ -27,6 +28,9 @@ class BlacklistService:
|
||||
interval_hours = self.get_blacklist_update_interval_hours()
|
||||
self.update_interval = timedelta(hours=interval_hours)
|
||||
self.lock = asyncio.Lock() # Блокировка для предотвращения одновременных обновлений
|
||||
# Кэш результатов проверки: {telegram_id: (is_blacklisted, reason, timestamp)}
|
||||
self._check_cache: dict[int, tuple[bool, str | None, float]] = {}
|
||||
self._cache_ttl = 300 # 5 минут
|
||||
|
||||
def is_blacklist_check_enabled(self) -> bool:
|
||||
"""Проверяет, включена ли проверка черного списка"""
|
||||
@@ -117,6 +121,7 @@ class BlacklistService:
|
||||
|
||||
self.blacklist_data = blacklist_data
|
||||
self.last_update = datetime.utcnow()
|
||||
self._check_cache.clear()
|
||||
logger.info(f'Черный список успешно обновлен. Найдено {len(blacklist_data)} записей')
|
||||
return True
|
||||
|
||||
@@ -141,9 +146,17 @@ class BlacklistService:
|
||||
if not self.is_blacklist_check_enabled():
|
||||
return False, None
|
||||
|
||||
# Проверяем кэш
|
||||
now = time.monotonic()
|
||||
cached = self._check_cache.get(telegram_id)
|
||||
if cached is not None:
|
||||
is_bl, reason, ts = cached
|
||||
if now - ts < self._cache_ttl:
|
||||
return is_bl, reason
|
||||
|
||||
# Проверяем, является ли пользователь администратором и нужно ли его игнорировать
|
||||
if self.should_ignore_admins() and self.is_admin(telegram_id):
|
||||
logger.info(f'Пользователь {telegram_id} является администратором, игнорируем проверку черного списка')
|
||||
self._check_cache[telegram_id] = (False, None, now)
|
||||
return False, None
|
||||
|
||||
# Если черный список пуст или устарел, обновляем его
|
||||
@@ -156,17 +169,21 @@ class BlacklistService:
|
||||
for bl_id, bl_username, bl_reason in self.blacklist_data:
|
||||
if bl_id == telegram_id:
|
||||
logger.info(f'Пользователь {telegram_id} найден в черном списке по ID: {bl_reason}')
|
||||
self._check_cache[telegram_id] = (True, bl_reason, now)
|
||||
return True, bl_reason
|
||||
|
||||
# Проверяем по username, если он передан
|
||||
if username:
|
||||
username_lower = username.lower().lstrip('@')
|
||||
for bl_id, bl_username, bl_reason in self.blacklist_data:
|
||||
if bl_username and (bl_username == username or bl_username == f'@{username}'):
|
||||
if bl_username and bl_username.lower().lstrip('@') == username_lower:
|
||||
logger.info(
|
||||
f'Пользователь {username} ({telegram_id}) найден в черном списке по username: {bl_reason}'
|
||||
)
|
||||
self._check_cache[telegram_id] = (True, bl_reason, now)
|
||||
return True, bl_reason
|
||||
|
||||
self._check_cache[telegram_id] = (False, None, now)
|
||||
return False, None
|
||||
|
||||
async def get_all_blacklisted_users(self) -> list[tuple[int, str, str]]:
|
||||
|
||||
@@ -0,0 +1,449 @@
|
||||
"""
|
||||
Сервис для проверки пользователей, заблокировавших бота.
|
||||
|
||||
Проверяет возможность отправки сообщений пользователям и позволяет
|
||||
очистить БД и панель Remnawave от неактивных пользователей.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from enum import Enum
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.exceptions import TelegramAPIError, TelegramBadRequest, TelegramForbiddenError
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import (
|
||||
AdvertisingCampaignRegistration,
|
||||
ButtonClickLog,
|
||||
CabinetRefreshToken,
|
||||
CloudPaymentsPayment,
|
||||
ContestAttempt,
|
||||
CryptoBotPayment,
|
||||
DiscountOffer,
|
||||
FreekassaPayment,
|
||||
HeleketPayment,
|
||||
KassaAiPayment,
|
||||
MulenPayPayment,
|
||||
Pal24Payment,
|
||||
PlategaPayment,
|
||||
PollResponse,
|
||||
PromoCodeUse,
|
||||
ReferralContestEvent,
|
||||
ReferralEarning,
|
||||
SentNotification,
|
||||
Subscription,
|
||||
SubscriptionConversion,
|
||||
SubscriptionEvent,
|
||||
SubscriptionServer,
|
||||
Ticket,
|
||||
TicketMessage,
|
||||
TicketNotification,
|
||||
Transaction,
|
||||
User,
|
||||
UserPromoGroup,
|
||||
UserStatus,
|
||||
WataPayment,
|
||||
WheelSpin,
|
||||
WithdrawalRequest,
|
||||
YooKassaPayment,
|
||||
)
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BlockCheckStatus(Enum):
|
||||
"""Статус проверки блокировки пользователя."""
|
||||
|
||||
BLOCKED = 'blocked'
|
||||
ACTIVE = 'active'
|
||||
NO_TELEGRAM_ID = 'no_telegram_id'
|
||||
ERROR = 'error'
|
||||
|
||||
|
||||
class BlockedUserAction(Enum):
|
||||
"""Действия над заблокированными пользователями."""
|
||||
|
||||
DELETE_FROM_DB = 'delete_from_db'
|
||||
DELETE_FROM_REMNAWAVE = 'delete_from_remnawave'
|
||||
DELETE_BOTH = 'delete_both'
|
||||
MARK_AS_BLOCKED = 'mark_as_blocked'
|
||||
|
||||
|
||||
@dataclass
|
||||
class BlockCheckResult:
|
||||
"""Результат проверки одного пользователя."""
|
||||
|
||||
user_id: int
|
||||
telegram_id: int | None
|
||||
username: str | None
|
||||
full_name: str
|
||||
status: BlockCheckStatus
|
||||
error_message: str | None = None
|
||||
remnawave_uuid: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class BlockedUsersScanResult:
|
||||
"""Результат сканирования пользователей на блокировку."""
|
||||
|
||||
total_checked: int = 0
|
||||
blocked_users: list[BlockCheckResult] = field(default_factory=list)
|
||||
active_users: int = 0
|
||||
errors: int = 0
|
||||
skipped_no_telegram: int = 0
|
||||
scan_duration_seconds: float = 0.0
|
||||
|
||||
@property
|
||||
def blocked_count(self) -> int:
|
||||
return len(self.blocked_users)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CleanupResult:
|
||||
"""Результат очистки заблокированных пользователей."""
|
||||
|
||||
deleted_from_db: int = 0
|
||||
deleted_from_remnawave: int = 0
|
||||
marked_as_blocked: int = 0
|
||||
errors: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
class BlockedUsersService:
|
||||
"""Сервис проверки и очистки заблокированных пользователей."""
|
||||
|
||||
# Задержка между проверками для избежания rate limit
|
||||
CHECK_DELAY_SECONDS: float = 0.05
|
||||
# Максимальное количество параллельных проверок
|
||||
MAX_CONCURRENT_CHECKS: int = 10
|
||||
# Задержка между API запросами к Remnawave (rate limit protection)
|
||||
API_DELAY_SECONDS: float = 0.15
|
||||
|
||||
def __init__(self, bot: Bot):
|
||||
self.bot = bot
|
||||
self.remnawave_service = RemnaWaveService()
|
||||
|
||||
async def check_user_blocked(self, telegram_id: int) -> BlockCheckStatus:
|
||||
"""
|
||||
Проверяет, заблокировал ли пользователь бота.
|
||||
|
||||
Отправляет ChatAction.TYPING - это не создает видимого сообщения,
|
||||
но позволяет определить блокировку.
|
||||
"""
|
||||
try:
|
||||
await self.bot.send_chat_action(chat_id=telegram_id, action='typing')
|
||||
return BlockCheckStatus.ACTIVE
|
||||
except TelegramForbiddenError:
|
||||
# Пользователь заблокировал бота
|
||||
return BlockCheckStatus.BLOCKED
|
||||
except TelegramBadRequest as e:
|
||||
error_lower = str(e).lower()
|
||||
if 'chat not found' in error_lower or 'user not found' in error_lower:
|
||||
# Пользователь удалил аккаунт или никогда не начинал диалог
|
||||
return BlockCheckStatus.BLOCKED
|
||||
logger.warning(f'TelegramBadRequest при проверке {telegram_id}: {e}')
|
||||
return BlockCheckStatus.ERROR
|
||||
except TelegramAPIError as e:
|
||||
logger.warning(f'TelegramAPIError при проверке {telegram_id}: {e}')
|
||||
return BlockCheckStatus.ERROR
|
||||
except Exception as e:
|
||||
logger.error(f'Неожиданная ошибка при проверке {telegram_id}: {e}')
|
||||
return BlockCheckStatus.ERROR
|
||||
|
||||
async def _check_single_user(self, user: User) -> BlockCheckResult:
|
||||
"""Проверяет одного пользователя."""
|
||||
if not user.telegram_id:
|
||||
return BlockCheckResult(
|
||||
user_id=user.id,
|
||||
telegram_id=None,
|
||||
username=user.username,
|
||||
full_name=user.full_name,
|
||||
status=BlockCheckStatus.NO_TELEGRAM_ID,
|
||||
remnawave_uuid=user.remnawave_uuid,
|
||||
)
|
||||
|
||||
status = await self.check_user_blocked(user.telegram_id)
|
||||
|
||||
return BlockCheckResult(
|
||||
user_id=user.id,
|
||||
telegram_id=user.telegram_id,
|
||||
username=user.username,
|
||||
full_name=user.full_name,
|
||||
status=status,
|
||||
remnawave_uuid=user.remnawave_uuid,
|
||||
)
|
||||
|
||||
async def scan_all_users(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
*,
|
||||
only_active: bool = True,
|
||||
batch_size: int = 100,
|
||||
progress_callback: Callable | None = None,
|
||||
) -> BlockedUsersScanResult:
|
||||
"""
|
||||
Сканирует всех пользователей на предмет блокировки бота.
|
||||
|
||||
Args:
|
||||
db: Сессия БД
|
||||
only_active: Проверять только активных пользователей
|
||||
batch_size: Размер батча для загрузки из БД
|
||||
progress_callback: Callback для отчета о прогрессе (checked, total)
|
||||
|
||||
Returns:
|
||||
Результат сканирования
|
||||
"""
|
||||
start_time = datetime.now(tz=UTC)
|
||||
result = BlockedUsersScanResult()
|
||||
|
||||
# Формируем запрос
|
||||
query = select(User).options(selectinload(User.subscription))
|
||||
if only_active:
|
||||
query = query.where(User.status == UserStatus.ACTIVE.value)
|
||||
query = query.where(User.telegram_id.isnot(None))
|
||||
|
||||
# Получаем всех пользователей
|
||||
users_result = await db.execute(query)
|
||||
all_users = users_result.scalars().all()
|
||||
total_users = len(all_users)
|
||||
|
||||
logger.info(f'Начинаем проверку {total_users} пользователей на блокировку бота')
|
||||
|
||||
# Проверяем пользователей батчами с ограничением параллелизма
|
||||
semaphore = asyncio.Semaphore(self.MAX_CONCURRENT_CHECKS)
|
||||
|
||||
async def check_with_semaphore(user: User) -> BlockCheckResult:
|
||||
async with semaphore:
|
||||
check_result = await self._check_single_user(user)
|
||||
await asyncio.sleep(self.CHECK_DELAY_SECONDS)
|
||||
return check_result
|
||||
|
||||
checked = 0
|
||||
for i in range(0, total_users, batch_size):
|
||||
batch = all_users[i : i + batch_size]
|
||||
tasks = [check_with_semaphore(user) for user in batch]
|
||||
batch_results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
for check_result in batch_results:
|
||||
if isinstance(check_result, Exception):
|
||||
result.errors += 1
|
||||
logger.error(f'Ошибка при проверке пользователя: {check_result}')
|
||||
continue
|
||||
|
||||
result.total_checked += 1
|
||||
|
||||
if check_result.status == BlockCheckStatus.BLOCKED:
|
||||
result.blocked_users.append(check_result)
|
||||
elif check_result.status == BlockCheckStatus.ACTIVE:
|
||||
result.active_users += 1
|
||||
elif check_result.status == BlockCheckStatus.NO_TELEGRAM_ID:
|
||||
result.skipped_no_telegram += 1
|
||||
else:
|
||||
result.errors += 1
|
||||
|
||||
checked += len(batch)
|
||||
if progress_callback:
|
||||
await progress_callback(checked, total_users)
|
||||
|
||||
result.scan_duration_seconds = (datetime.now(tz=UTC) - start_time).total_seconds()
|
||||
|
||||
logger.info(
|
||||
f'Сканирование завершено: {result.blocked_count} заблокированных '
|
||||
f'из {result.total_checked} проверенных за {result.scan_duration_seconds:.1f}с'
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
async def delete_user_from_remnawave(self, remnawave_uuid: str) -> bool:
|
||||
"""Удаляет пользователя из панели Remnawave."""
|
||||
if not remnawave_uuid:
|
||||
return False
|
||||
|
||||
try:
|
||||
if not self.remnawave_service.is_configured:
|
||||
logger.warning('Remnawave API не настроен')
|
||||
return False
|
||||
|
||||
async with self.remnawave_service.get_api_client() as api:
|
||||
await api.delete_user(remnawave_uuid)
|
||||
logger.info(f'Удален пользователь {remnawave_uuid} из Remnawave')
|
||||
return True
|
||||
except Exception as e:
|
||||
error_msg = str(e).lower()
|
||||
if 'not found' in error_msg or '404' in error_msg:
|
||||
logger.info(f'Пользователь {remnawave_uuid} уже удален из Remnawave')
|
||||
return True
|
||||
logger.error(f'Ошибка удаления {remnawave_uuid} из Remnawave: {e}')
|
||||
return False
|
||||
|
||||
async def delete_user_from_db(self, db: AsyncSession, user_id: int) -> bool:
|
||||
"""
|
||||
Полностью удаляет пользователя из БД со всеми связанными данными.
|
||||
"""
|
||||
try:
|
||||
# Получаем пользователя
|
||||
user_result = await db.execute(
|
||||
select(User).options(selectinload(User.subscription)).where(User.id == user_id)
|
||||
)
|
||||
user = user_result.scalar_one_or_none()
|
||||
|
||||
if not user:
|
||||
logger.warning(f'Пользователь {user_id} не найден в БД')
|
||||
return False
|
||||
|
||||
user_display = user.telegram_id or user.email or f'#{user.id}'
|
||||
|
||||
# Удаляем связанные записи (порядок важен из-за foreign keys)
|
||||
|
||||
# 1. Платежные системы (до транзакций, т.к. ссылаются на них)
|
||||
await db.execute(delete(YooKassaPayment).where(YooKassaPayment.user_id == user.id))
|
||||
await db.execute(delete(CryptoBotPayment).where(CryptoBotPayment.user_id == user.id))
|
||||
await db.execute(delete(HeleketPayment).where(HeleketPayment.user_id == user.id))
|
||||
await db.execute(delete(MulenPayPayment).where(MulenPayPayment.user_id == user.id))
|
||||
await db.execute(delete(Pal24Payment).where(Pal24Payment.user_id == user.id))
|
||||
await db.execute(delete(WataPayment).where(WataPayment.user_id == user.id))
|
||||
await db.execute(delete(PlategaPayment).where(PlategaPayment.user_id == user.id))
|
||||
await db.execute(delete(CloudPaymentsPayment).where(CloudPaymentsPayment.user_id == user.id))
|
||||
await db.execute(delete(FreekassaPayment).where(FreekassaPayment.user_id == user.id))
|
||||
await db.execute(delete(KassaAiPayment).where(KassaAiPayment.user_id == user.id))
|
||||
|
||||
# 2. Транзакции (после платежей)
|
||||
await db.execute(delete(Transaction).where(Transaction.user_id == user.id))
|
||||
|
||||
# 3. Подписки
|
||||
if user.subscription:
|
||||
await db.execute(
|
||||
delete(SubscriptionServer).where(SubscriptionServer.subscription_id == user.subscription.id)
|
||||
)
|
||||
await db.execute(delete(Subscription).where(Subscription.user_id == user.id))
|
||||
await db.execute(delete(SubscriptionConversion).where(SubscriptionConversion.user_id == user.id))
|
||||
await db.execute(delete(SubscriptionEvent).where(SubscriptionEvent.user_id == user.id))
|
||||
|
||||
# 4. Тикеты (сначала зависимые)
|
||||
await db.execute(delete(TicketNotification).where(TicketNotification.user_id == user.id))
|
||||
await db.execute(delete(TicketMessage).where(TicketMessage.user_id == user.id))
|
||||
await db.execute(delete(Ticket).where(Ticket.user_id == user.id))
|
||||
|
||||
# 5. Остальные связи
|
||||
await db.execute(delete(ReferralEarning).where(ReferralEarning.user_id == user.id))
|
||||
await db.execute(delete(ReferralEarning).where(ReferralEarning.referral_id == user.id))
|
||||
await db.execute(delete(WithdrawalRequest).where(WithdrawalRequest.user_id == user.id))
|
||||
await db.execute(delete(PromoCodeUse).where(PromoCodeUse.user_id == user.id))
|
||||
await db.execute(delete(DiscountOffer).where(DiscountOffer.user_id == user.id))
|
||||
await db.execute(delete(SentNotification).where(SentNotification.user_id == user.id))
|
||||
await db.execute(delete(PollResponse).where(PollResponse.user_id == user.id))
|
||||
await db.execute(delete(ContestAttempt).where(ContestAttempt.user_id == user.id))
|
||||
await db.execute(delete(ReferralContestEvent).where(ReferralContestEvent.referrer_id == user.id))
|
||||
await db.execute(delete(ReferralContestEvent).where(ReferralContestEvent.referral_id == user.id))
|
||||
await db.execute(
|
||||
delete(AdvertisingCampaignRegistration).where(AdvertisingCampaignRegistration.user_id == user.id)
|
||||
)
|
||||
await db.execute(delete(UserPromoGroup).where(UserPromoGroup.user_id == user.id))
|
||||
await db.execute(delete(CabinetRefreshToken).where(CabinetRefreshToken.user_id == user.id))
|
||||
await db.execute(delete(ButtonClickLog).where(ButtonClickLog.user_id == user.id))
|
||||
await db.execute(delete(WheelSpin).where(WheelSpin.user_id == user.id))
|
||||
|
||||
# Обнуляем referred_by_id у рефералов этого пользователя
|
||||
referrals_query = select(User).where(User.referred_by_id == user.id)
|
||||
referrals_result = await db.execute(referrals_query)
|
||||
for referral in referrals_result.scalars().all():
|
||||
referral.referred_by_id = None
|
||||
|
||||
# Удаляем пользователя
|
||||
await db.delete(user)
|
||||
await db.commit()
|
||||
|
||||
logger.info(f'Пользователь {user_display} полностью удален из БД')
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка удаления пользователя {user_id} из БД: {e}')
|
||||
await db.rollback()
|
||||
return False
|
||||
|
||||
async def mark_user_as_blocked(self, db: AsyncSession, user_id: int) -> bool:
|
||||
"""Помечает пользователя как заблокированного в БД."""
|
||||
try:
|
||||
user_result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = user_result.scalar_one_or_none()
|
||||
|
||||
if not user:
|
||||
return False
|
||||
|
||||
user.status = UserStatus.BLOCKED.value
|
||||
user.updated_at = datetime.now(tz=UTC)
|
||||
await db.commit()
|
||||
|
||||
logger.info(f'Пользователь {user.telegram_id or user.id} помечен как заблокированный')
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка пометки пользователя {user_id}: {e}')
|
||||
await db.rollback()
|
||||
return False
|
||||
|
||||
async def cleanup_blocked_users(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
blocked_users: list[BlockCheckResult],
|
||||
action: BlockedUserAction,
|
||||
*,
|
||||
progress_callback: Callable | None = None,
|
||||
) -> CleanupResult:
|
||||
"""
|
||||
Выполняет очистку заблокированных пользователей.
|
||||
|
||||
Args:
|
||||
db: Сессия БД
|
||||
blocked_users: Список заблокированных пользователей
|
||||
action: Действие для выполнения
|
||||
progress_callback: Callback для отчета о прогрессе
|
||||
|
||||
Returns:
|
||||
Результат очистки
|
||||
"""
|
||||
result = CleanupResult()
|
||||
total = len(blocked_users)
|
||||
|
||||
for i, user_result in enumerate(blocked_users):
|
||||
try:
|
||||
if action in (BlockedUserAction.DELETE_FROM_REMNAWAVE, BlockedUserAction.DELETE_BOTH):
|
||||
if user_result.remnawave_uuid:
|
||||
success = await self.delete_user_from_remnawave(user_result.remnawave_uuid)
|
||||
if success:
|
||||
result.deleted_from_remnawave += 1
|
||||
else:
|
||||
result.errors.append(f'Ошибка удаления {user_result.telegram_id} из Remnawave')
|
||||
# Задержка для избежания rate limit
|
||||
await asyncio.sleep(self.API_DELAY_SECONDS)
|
||||
|
||||
if action in (BlockedUserAction.DELETE_FROM_DB, BlockedUserAction.DELETE_BOTH):
|
||||
success = await self.delete_user_from_db(db, user_result.user_id)
|
||||
if success:
|
||||
result.deleted_from_db += 1
|
||||
else:
|
||||
result.errors.append(f'Ошибка удаления {user_result.telegram_id} из БД')
|
||||
|
||||
if action == BlockedUserAction.MARK_AS_BLOCKED:
|
||||
success = await self.mark_user_as_blocked(db, user_result.user_id)
|
||||
if success:
|
||||
result.marked_as_blocked += 1
|
||||
else:
|
||||
result.errors.append(f'Ошибка пометки {user_result.telegram_id}')
|
||||
|
||||
if progress_callback:
|
||||
await progress_callback(i + 1, total)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f'Ошибка обработки {user_result.telegram_id}: {e}'
|
||||
result.errors.append(error_msg)
|
||||
logger.error(error_msg)
|
||||
|
||||
return result
|
||||
+562
-148
@@ -4,8 +4,10 @@ import asyncio
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError, TelegramRetryAfter
|
||||
from aiogram.types import InlineKeyboardMarkup
|
||||
from sqlalchemy.exc import InterfaceError, SQLAlchemyError
|
||||
|
||||
@@ -18,12 +20,30 @@ from app.handlers.admin.messages import (
|
||||
)
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.cabinet.services.email_service import EmailService
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
VALID_MEDIA_TYPES = {'photo', 'video', 'document'}
|
||||
LARGE_BROADCAST_THRESHOLD = 20_000
|
||||
PROGRESS_UPDATE_STEP = 5_000
|
||||
|
||||
# =========================================================================
|
||||
# Telegram rate limits: ~30 msg/sec для бота.
|
||||
# batch_size=25 + 1 sec delay = ~25 msg/sec с запасом.
|
||||
# =========================================================================
|
||||
_TG_BATCH_SIZE = 25
|
||||
_TG_BATCH_DELAY = 1.0 # секунда между батчами
|
||||
_TG_MAX_RETRIES = 3 # retry при FloodWait / transient errors
|
||||
|
||||
# Прогресс обновляется каждые ~500 сообщений ИЛИ раз в 5 секунд (что наступит раньше)
|
||||
_PROGRESS_UPDATE_MESSAGES = 500
|
||||
_PROGRESS_MIN_INTERVAL_SEC = 5.0
|
||||
|
||||
# Email broadcast rate limiting: max 8 emails per second
|
||||
EMAIL_RATE_LIMIT = 8
|
||||
EMAIL_BATCH_SIZE = 50
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@@ -42,6 +62,24 @@ class BroadcastConfig:
|
||||
initiator_name: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class EmailBroadcastConfig:
|
||||
"""Configuration for email broadcast."""
|
||||
|
||||
target: str
|
||||
email_subject: str
|
||||
email_html_content: str
|
||||
initiator_name: str | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _EmailRecipient:
|
||||
"""Скалярные данные получателя email (без ORM)."""
|
||||
|
||||
email: str
|
||||
user_name: str
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _BroadcastTask:
|
||||
task: asyncio.Task
|
||||
@@ -117,7 +155,8 @@ class BroadcastService:
|
||||
broadcast.failed_count = 0
|
||||
await session.commit()
|
||||
|
||||
recipients = await self._fetch_recipients(config.target)
|
||||
# _fetch_recipients теперь возвращает list[int] (telegram_id), а не ORM-объекты
|
||||
recipient_ids: list[int] = await self._fetch_recipients(config.target)
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
broadcast = await session.get(BroadcastHistory, broadcast_id)
|
||||
@@ -125,45 +164,35 @@ class BroadcastService:
|
||||
logger.error('Запись рассылки %s удалена до запуска', broadcast_id)
|
||||
return
|
||||
|
||||
broadcast.total_count = len(recipients)
|
||||
broadcast.total_count = len(recipient_ids)
|
||||
await session.commit()
|
||||
|
||||
if cancel_event.is_set():
|
||||
await self._mark_cancelled(broadcast_id, sent_count, failed_count)
|
||||
return
|
||||
|
||||
if not recipients:
|
||||
if not recipient_ids:
|
||||
logger.info('Рассылка %s: получатели не найдены', broadcast_id)
|
||||
await self._mark_finished(broadcast_id, sent_count, failed_count, cancelled=False)
|
||||
return
|
||||
|
||||
keyboard = self._build_keyboard(config.selected_buttons)
|
||||
|
||||
if len(recipients) > LARGE_BROADCAST_THRESHOLD:
|
||||
logger.info('Запускаем стабильный режим рассылки для %s получателей', len(recipients))
|
||||
(
|
||||
sent_count,
|
||||
failed_count,
|
||||
cancelled_during_run,
|
||||
) = await self._run_resilient_broadcast(
|
||||
broadcast_id,
|
||||
recipients,
|
||||
config,
|
||||
keyboard,
|
||||
cancel_event,
|
||||
)
|
||||
else:
|
||||
(
|
||||
sent_count,
|
||||
failed_count,
|
||||
cancelled_during_run,
|
||||
) = await self._run_standard_broadcast(
|
||||
broadcast_id,
|
||||
recipients,
|
||||
config,
|
||||
keyboard,
|
||||
cancel_event,
|
||||
)
|
||||
logger.info(
|
||||
'Рассылка %s: начинаем отправку %d получателям (batch=%d, delay=%.1fs)',
|
||||
broadcast_id,
|
||||
len(recipient_ids),
|
||||
_TG_BATCH_SIZE,
|
||||
_TG_BATCH_DELAY,
|
||||
)
|
||||
|
||||
sent_count, failed_count, cancelled_during_run = await self._send_batched(
|
||||
broadcast_id,
|
||||
recipient_ids,
|
||||
config,
|
||||
keyboard,
|
||||
cancel_event,
|
||||
)
|
||||
|
||||
if cancelled_during_run:
|
||||
logger.info(
|
||||
@@ -192,140 +221,125 @@ class BroadcastService:
|
||||
logger.exception('Критическая ошибка при выполнении рассылки %s: %s', broadcast_id, exc)
|
||||
await self._mark_failed(broadcast_id, sent_count, failed_count)
|
||||
|
||||
async def _fetch_recipients(self, target: str):
|
||||
async def _fetch_recipients(self, target: str) -> list[int]:
|
||||
"""Загружает получателей и возвращает список telegram_id (скаляры, не ORM-объекты)."""
|
||||
async with AsyncSessionLocal() as session:
|
||||
if target.startswith('custom_'):
|
||||
criteria = target[len('custom_') :]
|
||||
return await get_custom_users(session, criteria)
|
||||
return await get_target_users(session, target)
|
||||
users_orm = await get_custom_users(session, criteria)
|
||||
else:
|
||||
users_orm = await get_target_users(session, target)
|
||||
|
||||
async def _run_standard_broadcast(
|
||||
# Извлекаем telegram_id сразу, пока сессия жива.
|
||||
# После выхода из блока ORM-объекты станут detached.
|
||||
return [u.telegram_id for u in users_orm if u.telegram_id is not None]
|
||||
|
||||
async def _send_batched(
|
||||
self,
|
||||
broadcast_id: int,
|
||||
recipients: list,
|
||||
recipient_ids: list[int],
|
||||
config: BroadcastConfig,
|
||||
keyboard: InlineKeyboardMarkup | None,
|
||||
cancel_event: asyncio.Event,
|
||||
) -> tuple[int, int, bool]:
|
||||
"""Базовый режим рассылки для небольших списков."""
|
||||
"""
|
||||
Единый метод рассылки для любого количества получателей.
|
||||
|
||||
Батчинг по _TG_BATCH_SIZE сообщений с _TG_BATCH_DELAY задержкой.
|
||||
Прогресс обновляется каждые _PROGRESS_UPDATE_MESSAGES сообщений.
|
||||
Глобальная пауза при FloodWait.
|
||||
"""
|
||||
sent_count = 0
|
||||
failed_count = 0
|
||||
|
||||
# Ограничение на количество одновременных отправок
|
||||
semaphore = asyncio.Semaphore(20)
|
||||
# Глобальная пауза при FloodWait — все корутины ждут
|
||||
flood_wait_until: float = 0.0
|
||||
last_progress_update: float = 0.0
|
||||
last_progress_count: int = 0
|
||||
|
||||
async def send_single(telegram_id: int) -> bool:
|
||||
nonlocal flood_wait_until
|
||||
|
||||
for attempt in range(_TG_MAX_RETRIES):
|
||||
# Глобальная пауза при FloodWait
|
||||
now = asyncio.get_event_loop().time()
|
||||
if flood_wait_until > now:
|
||||
await asyncio.sleep(flood_wait_until - now)
|
||||
|
||||
async def send_single_message(user):
|
||||
"""Отправляет одно сообщение с семафором ограничения"""
|
||||
async with semaphore:
|
||||
if cancel_event.is_set():
|
||||
return False
|
||||
|
||||
telegram_id = getattr(user, 'telegram_id', None)
|
||||
if telegram_id is None:
|
||||
# Email-пользователи без telegram_id - пропускаем (не считаем ошибкой)
|
||||
return None
|
||||
|
||||
try:
|
||||
await self._deliver_message(telegram_id, config, keyboard)
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
'Ошибка отправки рассылки %s пользователю %s: %s',
|
||||
|
||||
except TelegramRetryAfter as e:
|
||||
wait_seconds = e.retry_after + 1
|
||||
flood_wait_until = asyncio.get_event_loop().time() + wait_seconds
|
||||
logger.warning(
|
||||
'FloodWait рассылки %s: Telegram просит %d сек (user=%d, попытка %d/%d)',
|
||||
broadcast_id,
|
||||
e.retry_after,
|
||||
telegram_id,
|
||||
exc,
|
||||
attempt + 1,
|
||||
_TG_MAX_RETRIES,
|
||||
)
|
||||
await asyncio.sleep(wait_seconds)
|
||||
|
||||
except TelegramForbiddenError:
|
||||
return False
|
||||
|
||||
# Отправляем сообщения пакетами для эффективности
|
||||
batch_size = 100
|
||||
skipped_count = 0
|
||||
for i in range(0, len(recipients), batch_size):
|
||||
except TelegramBadRequest:
|
||||
return False
|
||||
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
'Ошибка отправки рассылки %s пользователю %d (попытка %d/%d): %s',
|
||||
broadcast_id,
|
||||
telegram_id,
|
||||
attempt + 1,
|
||||
_TG_MAX_RETRIES,
|
||||
exc,
|
||||
)
|
||||
if attempt < _TG_MAX_RETRIES - 1:
|
||||
await asyncio.sleep(0.5 * (attempt + 1))
|
||||
|
||||
return False
|
||||
|
||||
for i in range(0, len(recipient_ids), _TG_BATCH_SIZE):
|
||||
if cancel_event.is_set():
|
||||
await self._mark_cancelled(broadcast_id, sent_count, failed_count)
|
||||
return sent_count, failed_count, True
|
||||
|
||||
batch = recipients[i : i + batch_size]
|
||||
tasks = [send_single_message(user) for user in batch]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
batch = recipient_ids[i : i + _TG_BATCH_SIZE]
|
||||
results = await asyncio.gather(
|
||||
*[send_single(tid) for tid in batch],
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
for result in results:
|
||||
if result is True:
|
||||
sent_count += 1
|
||||
elif result is None:
|
||||
# Email-пользователи - пропускаем без ошибки
|
||||
skipped_count += 1
|
||||
else:
|
||||
failed_count += 1
|
||||
|
||||
# Небольшая задержка между пакетами для снижения нагрузки на API
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
return sent_count, failed_count, False
|
||||
|
||||
async def _run_resilient_broadcast(
|
||||
self,
|
||||
broadcast_id: int,
|
||||
recipients: list,
|
||||
config: BroadcastConfig,
|
||||
keyboard: InlineKeyboardMarkup | None,
|
||||
cancel_event: asyncio.Event,
|
||||
) -> tuple[int, int, bool]:
|
||||
"""Режим рассылки с периодическим обновлением статуса для больших списков."""
|
||||
|
||||
sent_count = 0
|
||||
failed_count = 0
|
||||
|
||||
# Ограничение на количество одновременных отправок
|
||||
semaphore = asyncio.Semaphore(15)
|
||||
|
||||
async def send_single_message(user):
|
||||
async with semaphore:
|
||||
if cancel_event.is_set():
|
||||
return False
|
||||
|
||||
telegram_id = getattr(user, 'telegram_id', None)
|
||||
if telegram_id is None:
|
||||
# Email-пользователи без telegram_id - пропускаем (не считаем ошибкой)
|
||||
return None
|
||||
|
||||
try:
|
||||
await self._deliver_message(telegram_id, config, keyboard)
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
'Ошибка отправки рассылки %s пользователю %s: %s',
|
||||
broadcast_id,
|
||||
telegram_id,
|
||||
exc,
|
||||
)
|
||||
return False
|
||||
|
||||
batch_size = 100
|
||||
for i in range(0, len(recipients), batch_size):
|
||||
if cancel_event.is_set():
|
||||
await self._mark_cancelled(broadcast_id, sent_count, failed_count)
|
||||
return sent_count, failed_count, True
|
||||
|
||||
batch = recipients[i : i + batch_size]
|
||||
tasks = [send_single_message(user) for user in batch]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
for result in results:
|
||||
if result is True:
|
||||
sent_count += 1
|
||||
elif result is None:
|
||||
# Email-пользователи - пропускаем без ошибки
|
||||
pass
|
||||
else:
|
||||
if isinstance(result, bool):
|
||||
if result:
|
||||
sent_count += 1
|
||||
else:
|
||||
failed_count += 1
|
||||
elif isinstance(result, Exception):
|
||||
failed_count += 1
|
||||
logger.error('Необработанное исключение в рассылке %s: %s', broadcast_id, result)
|
||||
|
||||
# Обновляем прогресс в БД периодически
|
||||
processed = sent_count + failed_count
|
||||
if processed % PROGRESS_UPDATE_STEP == 0:
|
||||
now = asyncio.get_event_loop().time()
|
||||
if (
|
||||
processed - last_progress_count >= _PROGRESS_UPDATE_MESSAGES
|
||||
or now - last_progress_update >= _PROGRESS_MIN_INTERVAL_SEC
|
||||
):
|
||||
await self._update_progress(broadcast_id, sent_count, failed_count)
|
||||
last_progress_count = processed
|
||||
last_progress_update = now
|
||||
|
||||
await asyncio.sleep(0.1)
|
||||
# Задержка между батчами для rate limiting
|
||||
await asyncio.sleep(_TG_BATCH_DELAY)
|
||||
|
||||
return sent_count, failed_count, False
|
||||
|
||||
@@ -340,37 +354,36 @@ class BroadcastService:
|
||||
config: BroadcastConfig,
|
||||
keyboard: InlineKeyboardMarkup | None,
|
||||
) -> None:
|
||||
"""
|
||||
Отправляет одно сообщение.
|
||||
|
||||
НЕ ловит исключения — TelegramRetryAfter, TelegramForbiddenError и др.
|
||||
обрабатываются в вызывающем коде (_send_batched).
|
||||
"""
|
||||
if not self._bot:
|
||||
raise RuntimeError('Телеграм-бот не инициализирован')
|
||||
|
||||
if config.media and config.media.type in VALID_MEDIA_TYPES:
|
||||
caption = config.media.caption or config.message_text
|
||||
if config.media.type == 'photo':
|
||||
await self._bot.send_photo(
|
||||
chat_id=telegram_id,
|
||||
photo=config.media.file_id,
|
||||
caption=caption,
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
elif config.media.type == 'video':
|
||||
await self._bot.send_video(
|
||||
chat_id=telegram_id,
|
||||
video=config.media.file_id,
|
||||
caption=caption,
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
elif config.media.type == 'document':
|
||||
await self._bot.send_document(
|
||||
chat_id=telegram_id,
|
||||
document=config.media.file_id,
|
||||
caption=caption,
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
media_methods = {
|
||||
'photo': ('photo', self._bot.send_photo),
|
||||
'video': ('video', self._bot.send_video),
|
||||
'document': ('document', self._bot.send_document),
|
||||
}
|
||||
kwarg_name, send_method = media_methods[config.media.type]
|
||||
await send_method(
|
||||
chat_id=telegram_id,
|
||||
**{kwarg_name: config.media.file_id},
|
||||
caption=caption,
|
||||
parse_mode='HTML',
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
return
|
||||
|
||||
await self._bot.send_message(
|
||||
chat_id=telegram_id,
|
||||
text=config.message_text,
|
||||
parse_mode='HTML',
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
|
||||
@@ -473,3 +486,404 @@ class BroadcastService:
|
||||
|
||||
|
||||
broadcast_service = BroadcastService()
|
||||
|
||||
|
||||
class EmailBroadcastService:
|
||||
"""Handles email broadcast execution triggered from the admin web API."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._email_service: EmailService | None = None
|
||||
self._tasks: dict[int, _BroadcastTask] = {}
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
def set_email_service(self, email_service: EmailService) -> None:
|
||||
"""Set email service instance."""
|
||||
self._email_service = email_service
|
||||
|
||||
def is_running(self, broadcast_id: int) -> bool:
|
||||
"""Check if broadcast is currently running."""
|
||||
task_entry = self._tasks.get(broadcast_id)
|
||||
return bool(task_entry and not task_entry.task.done())
|
||||
|
||||
async def start_broadcast(self, broadcast_id: int, config: EmailBroadcastConfig) -> None:
|
||||
"""Start email broadcast in background."""
|
||||
if self._email_service is None:
|
||||
logger.error('Cannot start email broadcast %s: email service not initialized', broadcast_id)
|
||||
await self._mark_failed(broadcast_id)
|
||||
return
|
||||
|
||||
if not self._email_service.is_configured():
|
||||
logger.error('Cannot start email broadcast %s: SMTP not configured', broadcast_id)
|
||||
await self._mark_failed(broadcast_id)
|
||||
return
|
||||
|
||||
cancel_event = asyncio.Event()
|
||||
|
||||
async with self._lock:
|
||||
if broadcast_id in self._tasks and not self._tasks[broadcast_id].task.done():
|
||||
logger.warning('Email broadcast %s is already running', broadcast_id)
|
||||
return
|
||||
|
||||
task = asyncio.create_task(
|
||||
self._run_broadcast(broadcast_id, config, cancel_event),
|
||||
name=f'email-broadcast-{broadcast_id}',
|
||||
)
|
||||
self._tasks[broadcast_id] = _BroadcastTask(task=task, cancel_event=cancel_event)
|
||||
task.add_done_callback(lambda _: self._tasks.pop(broadcast_id, None))
|
||||
|
||||
async def request_stop(self, broadcast_id: int) -> bool:
|
||||
"""Request to stop a running broadcast."""
|
||||
async with self._lock:
|
||||
task_entry = self._tasks.get(broadcast_id)
|
||||
if not task_entry:
|
||||
return False
|
||||
|
||||
task_entry.cancel_event.set()
|
||||
return True
|
||||
|
||||
async def _run_broadcast(
|
||||
self,
|
||||
broadcast_id: int,
|
||||
config: EmailBroadcastConfig,
|
||||
cancel_event: asyncio.Event,
|
||||
) -> None:
|
||||
"""Execute email broadcast."""
|
||||
sent_count = 0
|
||||
failed_count = 0
|
||||
|
||||
try:
|
||||
if cancel_event.is_set():
|
||||
await self._mark_cancelled(broadcast_id, sent_count, failed_count)
|
||||
return
|
||||
|
||||
# Update status to in_progress
|
||||
async with AsyncSessionLocal() as session:
|
||||
broadcast = await session.get(BroadcastHistory, broadcast_id)
|
||||
if not broadcast:
|
||||
logger.error('Broadcast record %s not found', broadcast_id)
|
||||
return
|
||||
|
||||
broadcast.status = 'in_progress'
|
||||
broadcast.sent_count = 0
|
||||
broadcast.failed_count = 0
|
||||
await session.commit()
|
||||
|
||||
# Fetch email recipients
|
||||
recipients = await self._fetch_email_recipients(config.target)
|
||||
|
||||
# Update total count
|
||||
async with AsyncSessionLocal() as session:
|
||||
broadcast = await session.get(BroadcastHistory, broadcast_id)
|
||||
if not broadcast:
|
||||
logger.error('Broadcast record %s deleted before start', broadcast_id)
|
||||
return
|
||||
|
||||
broadcast.total_count = len(recipients)
|
||||
await session.commit()
|
||||
|
||||
if cancel_event.is_set():
|
||||
await self._mark_cancelled(broadcast_id, sent_count, failed_count)
|
||||
return
|
||||
|
||||
if not recipients:
|
||||
logger.info('Email broadcast %s: no recipients found', broadcast_id)
|
||||
await self._mark_finished(broadcast_id, sent_count, failed_count, cancelled=False)
|
||||
return
|
||||
|
||||
# Send emails with rate limiting
|
||||
sent_count, failed_count, was_cancelled = await self._send_emails(
|
||||
broadcast_id,
|
||||
recipients,
|
||||
config,
|
||||
cancel_event,
|
||||
)
|
||||
|
||||
if was_cancelled:
|
||||
logger.info('Email broadcast %s was cancelled during execution', broadcast_id)
|
||||
return
|
||||
|
||||
await self._mark_finished(broadcast_id, sent_count, failed_count, cancelled=False)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
await self._mark_cancelled(broadcast_id, sent_count, failed_count)
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.exception('Critical error in email broadcast %s: %s', broadcast_id, exc)
|
||||
await self._mark_failed(broadcast_id, sent_count, failed_count)
|
||||
|
||||
async def _fetch_email_recipients(self, target: str) -> list[_EmailRecipient]:
|
||||
"""
|
||||
Загружает получателей email-рассылки.
|
||||
|
||||
Возвращает список _EmailRecipient (скалярные данные), а не ORM-объектов,
|
||||
чтобы избежать detached state при долгих рассылках.
|
||||
"""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.database.models import Subscription, SubscriptionStatus, User
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
# Base query: verified email users with active status
|
||||
base_conditions = [
|
||||
User.email.isnot(None),
|
||||
User.email_verified == True,
|
||||
User.status == 'active',
|
||||
]
|
||||
|
||||
if target == 'all_email':
|
||||
query = select(User).where(*base_conditions)
|
||||
|
||||
elif target == 'email_only':
|
||||
query = select(User).where(
|
||||
*base_conditions,
|
||||
User.auth_type == 'email',
|
||||
)
|
||||
|
||||
elif target == 'telegram_with_email':
|
||||
query = select(User).where(
|
||||
*base_conditions,
|
||||
User.auth_type == 'telegram',
|
||||
User.telegram_id.isnot(None),
|
||||
)
|
||||
|
||||
elif target == 'active_email':
|
||||
query = (
|
||||
select(User)
|
||||
.join(Subscription, User.id == Subscription.user_id)
|
||||
.where(
|
||||
*base_conditions,
|
||||
Subscription.status == SubscriptionStatus.ACTIVE.value,
|
||||
)
|
||||
)
|
||||
|
||||
elif target == 'expired_email':
|
||||
query = (
|
||||
select(User)
|
||||
.join(Subscription, User.id == Subscription.user_id)
|
||||
.where(
|
||||
*base_conditions,
|
||||
Subscription.status.in_(
|
||||
[
|
||||
SubscriptionStatus.EXPIRED.value,
|
||||
SubscriptionStatus.DISABLED.value,
|
||||
]
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
else:
|
||||
logger.warning('Unknown email target filter: %s', target)
|
||||
return []
|
||||
|
||||
# Загружаем батчами и извлекаем скаляры сразу
|
||||
recipients: list[_EmailRecipient] = []
|
||||
offset = 0
|
||||
batch_size = 1000
|
||||
|
||||
while True:
|
||||
result = await session.execute(query.offset(offset).limit(batch_size))
|
||||
batch = result.scalars().all()
|
||||
|
||||
if not batch:
|
||||
break
|
||||
|
||||
for user in batch:
|
||||
email = user.email
|
||||
if not email:
|
||||
continue
|
||||
|
||||
# Формируем имя пользователя
|
||||
user_name = user.username
|
||||
if not user_name:
|
||||
user_name = user.first_name or ''
|
||||
if last_name := user.last_name:
|
||||
user_name = f'{user_name} {last_name}'.strip()
|
||||
if not user_name:
|
||||
user_name = email.split('@')[0]
|
||||
|
||||
recipients.append(_EmailRecipient(email=email, user_name=user_name))
|
||||
|
||||
offset += batch_size
|
||||
|
||||
return recipients
|
||||
|
||||
async def _send_emails(
|
||||
self,
|
||||
broadcast_id: int,
|
||||
recipients: list[_EmailRecipient],
|
||||
config: EmailBroadcastConfig,
|
||||
cancel_event: asyncio.Event,
|
||||
) -> tuple[int, int, bool]:
|
||||
"""
|
||||
Отправляет email-рассылку с rate limiting.
|
||||
|
||||
Использует run_in_executor для синхронного SMTP, ограничивая
|
||||
параллельность семафором EMAIL_RATE_LIMIT.
|
||||
"""
|
||||
sent_count = 0
|
||||
failed_count = 0
|
||||
last_progress_count = 0
|
||||
last_progress_time: float = 0.0
|
||||
|
||||
semaphore = asyncio.Semaphore(EMAIL_RATE_LIMIT)
|
||||
|
||||
async def send_single_email(recipient: _EmailRecipient) -> bool | None:
|
||||
"""Отправляет один email."""
|
||||
async with semaphore:
|
||||
if cancel_event.is_set():
|
||||
return None
|
||||
|
||||
html_content = self._render_template(config.email_html_content, recipient)
|
||||
subject = self._render_template(config.email_subject, recipient)
|
||||
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
success = await loop.run_in_executor(
|
||||
None,
|
||||
self._email_service.send_email,
|
||||
recipient.email,
|
||||
subject,
|
||||
html_content,
|
||||
)
|
||||
return success
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
'Ошибка отправки email рассылки %s на %s: %s',
|
||||
broadcast_id,
|
||||
recipient.email,
|
||||
exc,
|
||||
)
|
||||
return False
|
||||
|
||||
for i in range(0, len(recipients), EMAIL_BATCH_SIZE):
|
||||
if cancel_event.is_set():
|
||||
await self._mark_cancelled(broadcast_id, sent_count, failed_count)
|
||||
return sent_count, failed_count, True
|
||||
|
||||
batch = recipients[i : i + EMAIL_BATCH_SIZE]
|
||||
tasks = [send_single_email(r) for r in batch]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
for result in results:
|
||||
if result is True:
|
||||
sent_count += 1
|
||||
elif result is None:
|
||||
pass # Cancelled or skipped
|
||||
else:
|
||||
failed_count += 1
|
||||
|
||||
# Обновляем прогресс периодически
|
||||
processed = sent_count + failed_count
|
||||
now = asyncio.get_event_loop().time()
|
||||
if (
|
||||
processed - last_progress_count >= _PROGRESS_UPDATE_MESSAGES
|
||||
or now - last_progress_time >= _PROGRESS_MIN_INTERVAL_SEC
|
||||
or i + EMAIL_BATCH_SIZE >= len(recipients)
|
||||
):
|
||||
await self._update_progress(broadcast_id, sent_count, failed_count)
|
||||
last_progress_count = processed
|
||||
last_progress_time = now
|
||||
|
||||
# Rate limiting: ~8 emails/sec
|
||||
await asyncio.sleep(EMAIL_BATCH_SIZE / EMAIL_RATE_LIMIT)
|
||||
|
||||
return sent_count, failed_count, False
|
||||
|
||||
@staticmethod
|
||||
def _render_template(template: str, recipient: _EmailRecipient) -> str:
|
||||
"""Подставляет переменные в шаблон email."""
|
||||
if not template:
|
||||
return template
|
||||
|
||||
result = template.replace('{{user_name}}', recipient.user_name)
|
||||
result = result.replace('{{email}}', recipient.email)
|
||||
return result
|
||||
|
||||
async def _mark_finished(
|
||||
self,
|
||||
broadcast_id: int,
|
||||
sent_count: int,
|
||||
failed_count: int,
|
||||
*,
|
||||
cancelled: bool,
|
||||
) -> None:
|
||||
"""Mark broadcast as finished."""
|
||||
status = 'cancelled' if cancelled else ('completed' if failed_count == 0 else 'partial')
|
||||
await self._safe_status_update(broadcast_id, sent_count, failed_count, status=status)
|
||||
|
||||
async def _mark_cancelled(
|
||||
self,
|
||||
broadcast_id: int,
|
||||
sent_count: int,
|
||||
failed_count: int,
|
||||
) -> None:
|
||||
"""Mark broadcast as cancelled."""
|
||||
await self._mark_finished(broadcast_id, sent_count, failed_count, cancelled=True)
|
||||
|
||||
async def _mark_failed(
|
||||
self,
|
||||
broadcast_id: int,
|
||||
sent_count: int = 0,
|
||||
failed_count: int = 0,
|
||||
) -> None:
|
||||
"""Mark broadcast as failed."""
|
||||
await self._safe_status_update(broadcast_id, sent_count, failed_count, status='failed')
|
||||
|
||||
async def _update_progress(
|
||||
self,
|
||||
broadcast_id: int,
|
||||
sent_count: int,
|
||||
failed_count: int,
|
||||
) -> None:
|
||||
"""Update broadcast progress."""
|
||||
await self._safe_status_update(
|
||||
broadcast_id,
|
||||
sent_count,
|
||||
failed_count,
|
||||
status='in_progress',
|
||||
update_completed_at=False,
|
||||
)
|
||||
|
||||
async def _safe_status_update(
|
||||
self,
|
||||
broadcast_id: int,
|
||||
sent_count: int,
|
||||
failed_count: int,
|
||||
*,
|
||||
status: str,
|
||||
update_completed_at: bool = True,
|
||||
) -> None:
|
||||
"""Safely update broadcast status with retry."""
|
||||
attempts = 0
|
||||
|
||||
while attempts < 2:
|
||||
try:
|
||||
async with AsyncSessionLocal() as session:
|
||||
broadcast = await session.get(BroadcastHistory, broadcast_id)
|
||||
if not broadcast:
|
||||
return
|
||||
|
||||
broadcast.sent_count = sent_count
|
||||
broadcast.failed_count = failed_count
|
||||
broadcast.status = status
|
||||
|
||||
if update_completed_at:
|
||||
broadcast.completed_at = datetime.utcnow()
|
||||
|
||||
await session.commit()
|
||||
return
|
||||
except InterfaceError as exc:
|
||||
attempts += 1
|
||||
logger.warning(
|
||||
'Connection issue updating email broadcast %s: %s. Retry %s/2',
|
||||
broadcast_id,
|
||||
exc,
|
||||
attempts,
|
||||
)
|
||||
await asyncio.sleep(0.2)
|
||||
except SQLAlchemyError:
|
||||
logger.exception('Failed to update email broadcast status %s', broadcast_id)
|
||||
return
|
||||
|
||||
|
||||
email_broadcast_service = EmailBroadcastService()
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Service for blocking disposable/temporary email domains."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import aiohttp
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DisposableEmailService:
|
||||
"""
|
||||
Downloads and caches a list of disposable email domains from GitHub.
|
||||
|
||||
Domains are stored in a frozenset for O(1) thread-safe lookups.
|
||||
The list is refreshed every 24 hours via an asyncio background task.
|
||||
If the download fails, the service falls back to an empty set (no blocking).
|
||||
"""
|
||||
|
||||
DOMAINS_URL = 'https://raw.githubusercontent.com/disposable/disposable-email-domains/master/domains.txt'
|
||||
UPDATE_INTERVAL_HOURS = 24
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._domains: frozenset[str] = frozenset()
|
||||
self._task: asyncio.Task[None] | None = None
|
||||
self._last_updated: datetime | None = None
|
||||
self._domain_count: int = 0
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Load domains and start periodic refresh task."""
|
||||
await self._update_domains()
|
||||
self._task = asyncio.create_task(self._periodic_loop())
|
||||
logger.info('DisposableEmailService started (%d domains loaded)', self._domain_count)
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Cancel periodic refresh task."""
|
||||
if self._task and not self._task.done():
|
||||
self._task.cancel()
|
||||
try:
|
||||
await self._task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._task = None
|
||||
logger.info('DisposableEmailService stopped')
|
||||
|
||||
async def _update_domains(self) -> None:
|
||||
"""Fetch domains.txt from GitHub and swap the in-memory set."""
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session, session.get(self.DOMAINS_URL) as resp:
|
||||
if resp.status != 200:
|
||||
logger.error(
|
||||
'Failed to fetch disposable domains: HTTP %d',
|
||||
resp.status,
|
||||
)
|
||||
return
|
||||
|
||||
text = await resp.text()
|
||||
|
||||
domains = frozenset(
|
||||
line.strip().lower() for line in text.splitlines() if line.strip() and not line.startswith('#')
|
||||
)
|
||||
|
||||
self._domains = domains
|
||||
self._domain_count = len(domains)
|
||||
self._last_updated = datetime.now(UTC)
|
||||
logger.info('Disposable email domains updated: %d domains', self._domain_count)
|
||||
|
||||
except Exception:
|
||||
logger.exception('Error updating disposable email domains')
|
||||
|
||||
async def _periodic_loop(self) -> None:
|
||||
"""Sleep then refresh, repeating forever until cancelled."""
|
||||
while True:
|
||||
await asyncio.sleep(self.UPDATE_INTERVAL_HOURS * 3600)
|
||||
await self._update_domains()
|
||||
|
||||
def is_disposable(self, email: str) -> bool:
|
||||
"""Check if the email uses a disposable domain.
|
||||
|
||||
Returns False when the feature is disabled via settings.
|
||||
"""
|
||||
if not getattr(settings, 'DISPOSABLE_EMAIL_CHECK_ENABLED', True):
|
||||
return False
|
||||
|
||||
if not self._domains:
|
||||
return False
|
||||
|
||||
try:
|
||||
domain = email.rsplit('@', 1)[1].lower()
|
||||
except IndexError:
|
||||
return False
|
||||
|
||||
return domain in self._domains
|
||||
|
||||
def get_status(self) -> dict:
|
||||
"""Return service status for monitoring / health checks."""
|
||||
return {
|
||||
'enabled': getattr(settings, 'DISPOSABLE_EMAIL_CHECK_ENABLED', True),
|
||||
'domain_count': self._domain_count,
|
||||
'last_updated': self._last_updated.isoformat() if self._last_updated else None,
|
||||
'running': self._task is not None and not self._task.done(),
|
||||
}
|
||||
|
||||
|
||||
disposable_email_service = DisposableEmailService()
|
||||
@@ -246,7 +246,7 @@ class KassaAiService:
|
||||
}
|
||||
params['signature'] = self._generate_hmac_signature(params)
|
||||
|
||||
logger.debug(f'KassaAI get_order_status: order_id={order_id}')
|
||||
logger.info(f'KassaAI get_order_status: order_id={order_id}')
|
||||
|
||||
try:
|
||||
async with (
|
||||
@@ -259,7 +259,7 @@ class KassaAiService:
|
||||
) as response,
|
||||
):
|
||||
text = await response.text()
|
||||
logger.debug(f'KassaAI get_order_status response: {text}')
|
||||
logger.info(f'KassaAI get_order_status response: {text}')
|
||||
return await response.json()
|
||||
except aiohttp.ClientError as e:
|
||||
logger.exception(f'KassaAI API connection error: {e}')
|
||||
|
||||
@@ -219,17 +219,8 @@ class MaintenanceService:
|
||||
settings.get_maintenance_retry_attempts(),
|
||||
)
|
||||
|
||||
await self._notify_admins(
|
||||
f"""Мониторинг технических работ запущен
|
||||
|
||||
🔄 <b>Интервал проверки:</b> {settings.get_maintenance_check_interval()} секунд
|
||||
🤖 <b>Автовключение:</b> {'Включено' if settings.is_maintenance_auto_enable() else 'Отключено'}
|
||||
🎯 <b>Порог ошибок:</b> {self._max_consecutive_failures}
|
||||
🔁 <b>Повторных попыток:</b> {settings.get_maintenance_retry_attempts()}
|
||||
|
||||
Система будет следить за доступностью API.""",
|
||||
'info',
|
||||
)
|
||||
# Сообщение о запуске мониторинга убрано - теперь используется
|
||||
# единое стартовое уведомление через StartupNotificationService
|
||||
|
||||
return True
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user